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

This commit is contained in:
2026-07-18 05:19:31 -07:00
commit b6be87b72d
677 changed files with 102939 additions and 0 deletions
+10
View File
@@ -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"]
+9
View File
@@ -0,0 +1,9 @@
# Commits not to include in changelog
a8ba7982d9ca3746cd6f6e3c26783e9d1d765466
448e60c51cb721bd6991c00bc39e926c7a1ffdac
6db7ffa0d3ccfbf1e757d5a4eb096fd51d65cc19
5400c8267db15ed56b5758a6fc35667678a5c856
87ad64f9ae2044d1aa70effd50244491a765c33e
6567ede8f0a9be622176dc5e840e5b93344aeb15
+8
View File
@@ -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"
+55
View File
@@ -0,0 +1,55 @@
FROM ubuntu:latest
ARG USERNAME=devel
ARG USER_UID=1000
ARG USER_GID=$USER_UID
# Install basic utilities and prerequisites.
# bash-completion - for completion testing
# bsdmainutils - provides hexdump, used by integration tests
# neovim - for convenience and modern editing
RUN apt-get update -y && \
apt-get install -y \
bash \
bash-completion \
bsdmainutils \
build-essential \
curl \
git \
iputils-ping \
language-pack-en \
neovim \
sed \
sudo \
wget
# Install gh cli
# Reference: https://github.com/cli/cli/blob/trunk/docs/install_linux.md
RUN mkdir -p -m 755 /etc/apt/keyrings && \
wget -qO- https://cli.github.com/packages/githubcli-archive-keyring.gpg | tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null && \
chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg && \
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null && \
apt-get update -y && \
apt-get install -y gh
# Add a non-root user that we'll do our best to use for development.
RUN userdel ubuntu && \
groupadd --gid $USER_GID $USERNAME && \
useradd --uid $USER_UID --gid $USER_GID -m $USERNAME && \
echo "devel ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers
# Switch to user.
USER devel
# Set up path to include rust components.
ENV PATH="${PATH}:/home/devel/.cargo/bin"
# Copy scripts to temp dir.
WORKDIR /tmp
# Install rust toolchain and cargo tools.
COPY install-rust-tools.sh .
RUN ./install-rust-tools.sh
# Switch back to home dir for normal usage.
WORKDIR /home/devel
+17
View File
@@ -0,0 +1,17 @@
{
"build": {
"dockerfile": "./Dockerfile",
"context": "."
},
"customizations": {
"vscode": {
"extensions": [
"rust-lang.rust-analyzer",
"tamasfe.even-better-toml"
]
}
},
"features": {},
"remoteUser": "devel",
"updateRemoteUserUID": true
}
+18
View File
@@ -0,0 +1,18 @@
#!/bin/bash
set -euo pipefail
# Install rustup and needed components
# Reference: https://rustup.rs/
curl https://sh.rustup.rs -sSf | sh -s -- -y
rustup component add llvm-tools-preview
# Install cargo binstall
# Reference: https://github.com/cargo-bins/cargo-binstall
curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash
# Install cargo tools using binstall
cargo binstall --no-confirm cargo-audit
cargo binstall --no-confirm cargo-deny
# cargo binstall --no-confirm cargo-flamegraph
cargo binstall --no-confirm cargo-llvm-cov
cargo binstall --no-confirm cargo-nextest
@@ -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
+5
View File
@@ -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
+19
View File
@@ -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
+394
View File
@@ -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.
+23
View File
@@ -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:
- "*"
+203
View File
@@ -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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
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@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: "Install Rust toolchain"
uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1.16.0
- name: "Setup cross-compiling toolchain"
if: startsWith(matrix.os, 'ubuntu') && !contains(matrix.target, '-musl')
uses: taiki-e/setup-cross-toolchain-action@d62f33b587e73b0004731caebc7d2d46b18a7567 # v1.39.2
with:
target: ${{ matrix.target }}
- name: "Install musl cross tools"
if: contains(matrix.target, '-musl')
uses: taiki-e/install-action@055f5df8c3f65ea01cd41e9dc855becd88953486 # v2.75.18
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@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
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 }}.*"
+138
View File
@@ -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@c950f6fb443cb5af20a377fd0dfaa78838901040 # v2.23.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@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
if: steps.get-pr.outputs.pr_number != ''
with:
path: reports/*.md
number: ${{ steps.get-pr.outputs.pr_number }}
+714
View File
@@ -0,0 +1,714 @@
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: ""
name: "Build (${{ matrix.arch }}/${{ matrix.os }})"
runs-on: ${{ matrix.host }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up rust toolchain
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
with:
toolchain: stable
targets: ${{ matrix.target }}
- 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.target != '' }}
uses: taiki-e/install-action@055f5df8c3f65ea01cd41e9dc855becd88953486 # v2.75.18
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: "Build (cross)"
if: ${{ matrix.target != '' }}
env:
CROSS_COMMAND: ${{ matrix.cross_command }}
TARGET: ${{ matrix.target }}
EXTRA_BUILD_ARGS: ${{ matrix.extra_build_args }}
CARGO_NDK_PLATFORM: 24
run: ${CROSS_COMMAND} build --release --target=${TARGET} ${EXTRA_BUILD_ARGS}
- name: "Upload binaries"
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
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@055f5df8c3f65ea01cd41e9dc855becd88953486 # v2.75.18
with:
tool: cargo-nextest
- name: Install cargo-llvm-cov
if: ${{ matrix.coverage }}
uses: taiki-e/install-action@055f5df8c3f65ea01cd41e9dc855becd88953486 # v2.75.18
with:
tool: cargo-llvm-cov
- name: Install WASI runtime
if: ${{ matrix.wasi_runtime }}
uses: taiki-e/install-action@055f5df8c3f65ea01cd41e9dc855becd88953486 # v2.75.18
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@b04401e20b1aef21843d7c3e261d27a89f65af7d # 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
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@9e2cd22e39153feba8b4477e4fefcc0b5e2727ba # v6.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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
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@055f5df8c3f65ea01cd41e9dc855becd88953486 # v2.75.18
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
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@055f5df8c3f65ea01cd41e9dc855becd88953486 # v2.75.18
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
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@055f5df8c3f65ea01cd41e9dc855becd88953486 # v2.75.18
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
path: pr
- name: Checkout main sources
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
path: "brush"
- name: Checkout bash-completion
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
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"
- container: "mcr.microsoft.com/azurelinux/base/core:3.0"
description: "Azure Linux/3.0"
prereqs_command: "tdnf install -y bash-completion ca-certificates git iputils grep less sed util-linux"
- container: "debian:testing"
description: "Debian/testing"
prereqs_command: "apt-get update -y && apt-get install -y bash-completion bsdmainutils iputils-ping grep less sed"
- container: "fedora:latest"
description: "Fedora/latest"
prereqs_command: "dnf install -y bash-completion iputils grep less sed util-linux"
# NOTE: NixOS tests are temporarily disabled due to containerd v2.2.0+ regression.
# The nixos/nix image uses absolute symlinks for /etc/passwd and /etc/group which
# causes "path escapes from parent" errors during container initialization.
# This cannot be worked around from within the container since the error occurs
# before any workflow steps run.
# Reference: https://github.com/containerd/containerd/issues/12683
# Reference: https://github.com/actions/runner-images/issues/13682
# TODO: Re-enable when containerd or NixOS fixes the upstream issue.
# - 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"
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"
# 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
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}
+69
View File
@@ -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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
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@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
with:
category: "/language:${{matrix.language}}"
+72
View File
@@ -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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Login to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Pre-build dev container image
uses: devcontainers/ci@b63b30de439b47a52267f241112c5b453b673db5 # v0.3.1900000449
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Login to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Pre-build dev container image
uses: devcontainers/ci@b63b30de439b47a52267f241112c5b453b673db5 # v0.3.1900000449
with:
imageName: ghcr.io/reubeno/brush/devcontainer
imageTag: latest
cacheFrom: ghcr.io/reubeno/brush/devcontainer
push: filter
refFilterForPush: refs/heads/main
eventFilterForPush: push
+77
View File
@@ -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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Check repo-local links
uses: lycheeverse/lychee-action@8646ba30535128ac92d33dfc9133794bfdd9b411 # v2.8.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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
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
+29
View File
@@ -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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Spell check repo
uses: crate-ci/typos@cf5f1c29a8ac336af8568821ec41919923b05a83 # v1.45.1
+46
View File
@@ -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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install the latest version of uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
- 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@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
with:
sarif_file: results.sarif
category: zizmor
+2
View File
@@ -0,0 +1,2 @@
target/
mutants.out*/
+2
View File
@@ -0,0 +1,2 @@
https://github.com/reubeno/brush/*
target/*
+7
View File
@@ -0,0 +1,7 @@
{
// List of extensions to recommend for users of this workspace.
// See https://go.microsoft.com/fwlink/?LinkId=827846 for more details.
"recommendations": [
"rust-lang.rust-analyzer",
],
}
+65
View File
@@ -0,0 +1,65 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "lldb",
"request": "launch",
"name": "Debug executable 'brush'",
"cargo": {
"args": [
"build",
"--bin=brush",
"--package=brush-shell"
],
"filter": {
"name": "brush",
"kind": "bin"
}
},
"args": [
"--enable-highlighting"
],
"cwd": "${workspaceFolder}"
},
{
"type": "lldb",
"request": "launch",
"name": "Debug integration test 'brush-compat-tests'",
"cargo": {
"args": [
"test",
"--no-run",
"--test=brush-compat-tests",
"--package=brush-shell"
],
"filter": {
"name": "brush-compat-tests",
"kind": "test"
}
},
"args": [],
"cwd": "${workspaceFolder}"
},
{
"type": "lldb",
"request": "launch",
"name": "Debug executable 'xtask'",
"cargo": {
"args": [
"build",
"--bin=xtask",
"--package=xtask"
],
"filter": {
"name": "xtask",
"kind": "bin"
}
},
"args": [],
"cwd": "${workspaceFolder}"
}
]
}
+20
View File
@@ -0,0 +1,20 @@
{
"editor.formatOnSave": true,
"files.exclude": {
"target/": true
},
"terminal.integrated.defaultProfile.linux": "bash",
"terminal.integrated.profiles.linux": {
"bash": {
"path": "/bin/bash"
}
},
"rust-analyzer.check.command": "clippy",
"rewrap.wrappingColumn": 90,
"rust-analyzer.debug.openDebugPane": true,
"rust-analyzer.testExplorer": true,
"rust-analyzer.check.features": "all",
"rust-analyzer.check.allTargets": true,
"rust-analyzer.check.workspace": true,
"sarif-viewer.connectToGithubCodeScanning": "on",
}
+317
View File
@@ -0,0 +1,317 @@
# 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
---
## 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
### 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
+1074
View File
File diff suppressed because it is too large Load Diff
+131
View File
@@ -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
+138
View File
@@ -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)!
Generated
+6801
View File
File diff suppressed because it is too large Load Diff
+119
View File
@@ -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.
+21
View File
@@ -0,0 +1,21 @@
# Nucleic fork of brush
Vendored copy of [reubeno/brush](https://github.com/reubeno/brush), pinned at
tag **`brush-shell-v0.4.0`** (upstream commit `96a26d0`), consumed by the
`shell/` workspace to build **nash**, the Nucleic agent shell
([docs/NASH.md](../../docs/NASH.md)).
Every fork change is marked with a `// nash:` comment. Current patches:
| File | Change |
| --- | --- |
| `brush-shell/src/productinfo.rs` | `PRODUCT_NAME` → `nash`; display string identifies the fork |
| `brush-shell/src/args.rs` | usage/version strings rebranded |
| `brush-core/src/expansion.rs` | corpus divergence D1: added `ExpansionPiece::LiteralText` — literal unquoted text is never field-split (bash splits only expansion results) but keeps glob characters active. Upstream candidate. |
Planned (M1, per docs/NASH.md §4.2): `brush-core` `Gate` trait (verdict-shaped
observation hooks) — the only substantial divergence from upstream.
Updating: diff against the upstream tag, re-vendor, re-apply `// nash:` patches
(grep for the marker), then re-run `shell/corpus/replay.py` and the brush compat
suite (`cargo test -p brush-shell --test brush-compat-tests`).
+204
View File
@@ -0,0 +1,204 @@
<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>🐧 <b>Installing on Arch Linux</b></summary>
Arch Linux users can install `brush` from the official [extra repository](https://archlinux.org/packages/extra/x86_64/brush/):
```bash
pacman -S brush
```
</details>
<details>
<summary>🚀 <b>Installing 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).
+1
View File
@@ -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()
+175
View File
@@ -0,0 +1,175 @@
[package]
name = "brush-builtins"
description = "Builtins for a POSIX/bash shell (used by brush-shell)"
version = "0.2.0"
categories.workspace = true
edition.workspace = true
keywords.workspace = true
license.workspace = true
readme.workspace = true
repository.workspace = true
rust-version.workspace = true
[lib]
bench = false
[features]
default = [
'builtin.alias',
'builtin.bg',
'builtin.bind',
'builtin.break',
'builtin.builtin',
'builtin.caller',
'builtin.cd',
'builtin.colon',
'builtin.command',
'builtin.compgen',
'builtin.compopt',
'builtin.complete',
'builtin.continue',
'builtin.declare',
'builtin.dirs',
'builtin.dot',
'builtin.echo',
'builtin.enable',
'builtin.eval',
'builtin.exec',
'builtin.exit',
'builtin.export',
'builtin.false',
'builtin.fc',
'builtin.fg',
'builtin.getopts',
'builtin.hash',
'builtin.help',
'builtin.history',
'builtin.jobs',
'builtin.kill',
'builtin.let',
'builtin.mapfile',
'builtin.popd',
'builtin.printf',
'builtin.pushd',
'builtin.pwd',
'builtin.read',
'builtin.return',
'builtin.set',
'builtin.shift',
'builtin.shopt',
'builtin.suspend',
'builtin.test',
'builtin.times',
'builtin.trap',
'builtin.true',
'builtin.type',
'builtin.ulimit',
'builtin.umask',
'builtin.unalias',
'builtin.unset',
'builtin.wait',
]
'builtin.alias' = []
'builtin.bg' = []
'builtin.bind' = []
'builtin.break' = []
'builtin.builtin' = []
'builtin.caller' = []
'builtin.cd' = []
'builtin.colon' = []
'builtin.command' = []
'builtin.compgen' = []
'builtin.complete' = []
'builtin.compopt' = []
'builtin.continue' = []
'builtin.declare' = []
'builtin.dirs' = []
'builtin.dot' = []
'builtin.echo' = []
'builtin.enable' = []
'builtin.eval' = []
'builtin.exec' = []
'builtin.exit' = []
'builtin.export' = []
'builtin.false' = []
'builtin.fc' = []
'builtin.fg' = []
'builtin.getopts' = []
'builtin.hash' = []
'builtin.help' = []
'builtin.history' = []
'builtin.jobs' = []
'builtin.kill' = []
'builtin.let' = []
'builtin.mapfile' = []
'builtin.popd' = []
'builtin.printf' = []
'builtin.pushd' = []
'builtin.pwd' = []
'builtin.read' = []
'builtin.return' = []
'builtin.set' = []
'builtin.shift' = []
'builtin.shopt' = []
'builtin.suspend' = []
'builtin.test' = []
'builtin.times' = []
'builtin.trap' = []
'builtin.true' = []
'builtin.type' = []
'builtin.ulimit' = []
'builtin.umask' = []
'builtin.unalias' = []
'builtin.unset' = []
'builtin.wait' = []
[lints]
workspace = true
[dependencies]
brush-core = { version = "^0.5.0", path = "../brush-core" }
brush-parser = { version = "^0.4.0", path = "../brush-parser" }
cfg-if = "1.0.4"
chrono = "0.4.44"
clap = { version = "4.6.0", features = ["derive", "wrap_help"] }
fancy-regex = "0.17.0"
futures = "0.3.32"
itertools = "0.14.0"
strum = "0.28.0"
strum_macros = "0.28.0"
thiserror = "2.0.18"
tracing = "0.1.44"
[target.'cfg(target_family = "wasm")'.dependencies]
tokio = { version = "1.52.1", features = ["io-util", "macros", "rt"] }
[target.'cfg(any(unix, windows))'.dependencies]
tokio = { version = "1.50.0", features = [
"io-util",
"macros",
"process",
"rt",
"rt-multi-thread",
"signal",
"sync",
] }
uucore = { version = "0.8.0", default-features = false, features = ["format"] }
[target.'cfg(unix)'.dependencies]
nix = { version = "0.31.2", features = [
"fs",
"process",
"resource",
"signal",
"term",
"user",
] }
rlimit = "0.11.0"
[target.'cfg(any(target_os = "linux", target_os = "android"))'.dependencies]
procfs = "0.18.0"
[dev-dependencies]
anyhow = "1.0.102"
pretty_assertions = { version = "1.4.1", features = ["unstable"] }
+1
View File
@@ -0,0 +1 @@
../LICENSE
+55
View File
@@ -0,0 +1,55 @@
use clap::Parser;
use std::io::Write;
use brush_core::{ExecutionResult, builtins};
/// Manage aliases within the shell.
#[derive(Parser)]
pub(crate) struct AliasCommand {
/// Print all defined aliases in a reusable format.
#[arg(short = 'p')]
print: bool,
/// List of aliases to display or update.
#[arg(name = "name[=value]")]
aliases: Vec<String>,
}
impl builtins::Command for AliasCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
let mut exit_code = ExecutionResult::success();
if self.print || self.aliases.is_empty() {
for (name, value) in context.shell.aliases() {
writeln!(context.stdout(), "alias {name}='{value}'")?;
}
} else {
for alias in &self.aliases {
if let Some((name, unexpanded_value)) = alias.split_once('=')
&& !name.is_empty()
{
context
.shell
.aliases_mut()
.insert(name.to_owned(), unexpanded_value.to_owned());
} else if let Some(value) = context.shell.aliases().get(alias) {
writeln!(context.stdout(), "alias {alias}='{value}'")?;
} else {
writeln!(
context.stderr(),
"{}: {alias}: not found",
context.command_name
)?;
exit_code = ExecutionResult::general_error();
}
}
}
Ok(exit_code)
}
}
+47
View File
@@ -0,0 +1,47 @@
use clap::Parser;
use std::io::Write;
use brush_core::{ExecutionResult, builtins};
/// Moves a job to run in the background.
#[derive(Parser)]
pub(crate) struct BgCommand {
/// List of job specs to move to background.
job_specs: Vec<String>,
}
impl builtins::Command for BgCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
let mut exit_code = ExecutionResult::success();
if !self.job_specs.is_empty() {
for job_spec in &self.job_specs {
if let Some(job) = context.shell.jobs_mut().resolve_job_spec(job_spec) {
job.move_to_background()?;
} else {
writeln!(
context.stderr(),
"{}: {}: no such job",
context.command_name,
job_spec
)?;
exit_code = ExecutionResult::general_error();
}
}
} else {
if let Some(job) = context.shell.jobs_mut().current_job_mut() {
job.move_to_background()?;
} else {
writeln!(context.stderr(), "{}: no current job", context.command_name)?;
exit_code = ExecutionResult::general_error();
}
}
Ok(exit_code)
}
}
+571
View File
@@ -0,0 +1,571 @@
use clap::{Parser, ValueEnum};
use itertools::Itertools as _;
use std::{collections::HashMap, io::Write, str::FromStr as _, sync::Arc};
use strum::IntoEnumIterator;
use tokio::sync::Mutex;
use brush_core::{
ExecutionExitCode, ExecutionResult, builtins,
interfaces::{self, InputFunction, KeyAction, KeySequence},
sys, trace_categories,
};
/// Identifier for a keymap
#[derive(Clone, ValueEnum)]
enum BindKeyMap {
#[clap(name = "emacs-standard", alias = "emacs")]
EmacsStandard,
#[clap(name = "emacs-meta")]
EmacsMeta,
#[clap(name = "emacs-ctlx")]
EmacsCtlx,
#[clap(name = "vi-command", aliases = &["vi", "vi-move"])]
ViCommand,
#[clap(name = "vi-insert")]
ViInsert,
}
impl BindKeyMap {
const fn is_vi(&self) -> bool {
matches!(self, Self::ViCommand | Self::ViInsert)
}
#[expect(dead_code)]
const fn is_emacs(&self) -> bool {
matches!(
self,
Self::EmacsStandard | Self::EmacsMeta | Self::EmacsCtlx
)
}
}
/// Inspect and modify key bindings and other input configuration.
#[derive(Parser)]
pub(crate) struct BindCommand {
/// Name of key map to use.
#[arg(short = 'm')]
keymap: Option<BindKeyMap>,
/// List functions.
#[arg(short = 'l')]
list_funcs: bool,
/// List functions and bindings.
#[arg(short = 'P')]
list_funcs_and_bindings: bool,
/// List functions and bindings in a format suitable for use as input.
#[arg(short = 'p')]
list_funcs_and_bindings_reusable: bool,
/// List key sequences that invoke macros.
#[arg(short = 'S')]
list_key_seqs_that_invoke_macros: bool,
/// List key sequences that invoke macros in a format suitable for use as input.
#[arg(short = 's')]
list_key_seqs_that_invoke_macros_reusable: bool,
/// List variables.
#[arg(short = 'V')]
list_vars: bool,
/// List variables in a format suitable for use as input.
#[arg(short = 'v')]
list_vars_reusable: bool,
/// Find the keys bound to the given named function.
#[arg(short = 'q', value_name = "FUNC_NAME")]
query_func_bindings: Option<String>,
/// Remove all bindings for the given named function.
#[arg(short = 'u', value_name = "FUNC_NAME")]
remove_func_bindings: Option<String>,
/// Remove the binding for the given key sequence.
#[arg(short = 'r', value_name = "KEY_SEQ")]
remove_key_seq_binding: Option<String>,
/// Import bindings from the given file.
#[arg(short = 'f', value_name = "PATH")]
bindings_file: Option<String>,
/// Bind key sequence to command.
#[arg(short = 'x', value_name = "BINDING")]
key_seq_bindings: Vec<String>,
/// List key sequence bindings.
#[arg(short = 'X')]
list_key_seq_bindings: bool,
/// Key sequence binding to readline function or command.
key_sequence: Option<String>,
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum BindError {
/// Unknown function specified.
#[error("unknown function: {0}")]
UnknownFunction(String),
/// Unknown key binding function.
#[error("unknown key binding function: {0}")]
UnknownKeyBindingFunction(String),
/// Unimplemented functionality.
#[error("unimplemented: {0}")]
Unimplemented(&'static str),
/// An I/O error occurred.
#[error("I/O error occurred")]
IoError(#[from] std::io::Error),
/// A binding parse error occurred.
#[error(transparent)]
BindingParseError(#[from] brush_parser::BindingParseError),
}
impl brush_core::BuiltinError for BindError {}
impl From<&BindError> for brush_core::ExecutionExitCode {
fn from(_err: &BindError) -> Self {
Self::GeneralError
}
}
impl builtins::Command for BindCommand {
type Error = BindError;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
if let Some(key_bindings) = context.shell.key_bindings() {
Ok(self.execute_impl(key_bindings, &context).await?)
} else {
tracing::debug!(target: trace_categories::INPUT,
"bind: key bindings not supported in this config");
// Silently succeed when key bindings are unavailable (e.g., in
// non-interactive mode or with an input backend that doesn't
// yet support them).
Ok(ExecutionExitCode::Success.into())
}
}
}
impl BindCommand {
#[allow(clippy::too_many_lines)]
async fn execute_impl(
&self,
bindings: &Arc<Mutex<dyn interfaces::KeyBindings>>,
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
) -> Result<ExecutionResult, BindError> {
let mut bindings = bindings.lock().await;
if self.list_funcs {
for func in interfaces::InputFunction::iter() {
writeln!(context.stdout(), "{func}")?;
}
}
if self.list_funcs_and_bindings {
display_funcs_and_bindings(&*bindings, context, false /* reusable? */)?;
}
if self.list_funcs_and_bindings_reusable {
display_funcs_and_bindings(&*bindings, context, true /* reusable? */)?;
}
if self.list_key_seqs_that_invoke_macros {
display_macros(&*bindings, context, false /* reusable? */)?;
}
if self.list_key_seqs_that_invoke_macros_reusable {
display_macros(&*bindings, context, true /* reusable? */)?;
}
if self.list_vars {
let options = &context.shell.completion_config().fallback_options;
// For now we'll just display a few items and show defaults.
writeln!(
context.stdout(),
"mark-directories is set to `{}'",
to_onoff(options.mark_directories)
)?;
writeln!(
context.stdout(),
"mark-symlinked-directories is set to `{}'",
to_onoff(options.mark_symlinked_directories)
)?;
}
if self.list_vars_reusable {
let options = &context.shell.completion_config().fallback_options;
// For now we'll just display a few items and show defaults.
writeln!(
context.stdout(),
"set mark-directories {}",
to_onoff(options.mark_directories)
)?;
writeln!(
context.stdout(),
"set mark-symlinked-directories {}",
to_onoff(options.mark_symlinked_directories)
)?;
}
if let Some(func_str) = &self.query_func_bindings {
let seqs = find_key_seqs_bound_to_function(&*bindings, func_str)?;
if !seqs.is_empty() {
writeln!(
context.stdout(),
"{func_str} can be invoked via {}.",
seqs.iter().map(|seq| std::format!("\"{seq}\"")).join(", ")
)?;
} else {
writeln!(context.stdout(), "{func_str} is not bound to any keys.")?;
return Ok(ExecutionResult::general_error());
}
}
if let Some(func_str) = &self.remove_func_bindings {
let found_seqs = find_key_seqs_bound_to_function(&*bindings, func_str)?;
for seq in found_seqs {
let _ = bindings.try_unbind(seq);
}
}
if let Some(key_seq_str) = &self.remove_key_seq_binding {
let key_seq = parse_key_sequence(key_seq_str)?;
let _ = bindings.try_unbind(key_seq);
}
if self.bindings_file.is_some() {
return Err(BindError::Unimplemented("bind -f"));
}
if self.list_key_seq_bindings {
for (seq, action) in &bindings.get_current() {
let KeyAction::ShellCommand(cmd) = action else {
continue;
};
writeln!(context.stdout(), "\"{seq}\" \"{cmd}\"")?;
}
}
if !self.key_seq_bindings.is_empty() {
if self.keymap.as_ref().is_some_and(|k| k.is_vi()) {
// NOTE(vi): Quietly ignore since we don't support vi mode.
return Ok(ExecutionResult::success());
}
for key_seq_and_command in &self.key_seq_bindings {
let (key_seq, command) = parse_key_sequence_and_shell_command(key_seq_and_command)?;
bind_key_sequence_to_shell_cmd(&mut *bindings, key_seq, command)?;
}
}
if let Some(key_sequence) = &self.key_sequence {
if self.keymap.as_ref().is_some_and(|k| k.is_vi()) {
// NOTE(vi): Quietly ignore since we don't support vi mode.
return Ok(ExecutionResult::success());
}
let (key_seq, target) = parse_key_sequence_and_readline_target(key_sequence.as_str())?;
bind_key_sequence_to_readline_target(&mut *bindings, key_seq, target)?;
}
drop(bindings);
Ok(ExecutionResult::success())
}
}
fn parse_key_sequence(input: &str) -> Result<interfaces::KeySequence, BindError> {
// First trim any whitespace.
let input = input.trim();
let parsed = brush_parser::readline_binding::parse_key_sequence(input)?;
let abstract_seq = key_sequence_to_abstract_strokes(&parsed)?;
Ok(abstract_seq)
}
fn parse_key_sequence_and_shell_command(
input: &str,
) -> Result<(interfaces::KeySequence, String), BindError> {
tracing::debug!(target: trace_categories::INPUT,
"parsing key binding entry: '{input}'"
);
// First trim any whitespace.
let input = input.trim();
// This should be something of the form:
// "KEY-SEQUENCE": SHELL-COMMAND
let binding = brush_parser::readline_binding::parse_key_sequence_shell_cmd_binding(input)?;
let abstract_seq = key_sequence_to_abstract_strokes(&binding.seq)?;
Ok((abstract_seq, binding.shell_cmd))
}
#[derive(Debug)]
#[allow(dead_code, reason = "not all variants implemented yet")]
enum BindableReadlineTarget {
Function(interfaces::InputFunction),
Macro(interfaces::KeySequence),
}
fn parse_key_sequence_and_readline_target(
input: &str,
) -> Result<(interfaces::KeySequence, BindableReadlineTarget), BindError> {
tracing::debug!(target: trace_categories::INPUT,
"parsing key binding entry: '{input}'"
);
// First trim any whitespace.
let input = input.trim();
// This should be of one of these forms:
// "KEY-SEQUENCE":function-name
// "KEY-SEQUENCE":readline-command
let binding = brush_parser::readline_binding::parse_key_sequence_readline_binding(input)?;
let abstract_seq = key_sequence_to_abstract_strokes(&binding.seq)?;
match binding.target {
brush_parser::readline_binding::ReadlineTarget::Function(func_name) => {
let func = parse_readline_function(func_name.as_str())?;
Ok((abstract_seq, BindableReadlineTarget::Function(func)))
}
brush_parser::readline_binding::ReadlineTarget::Macro(target_seq_str) => {
let parsed_target =
brush_parser::readline_binding::parse_key_sequence(&target_seq_str)?;
let abstract_target = key_sequence_to_abstract_strokes(&parsed_target)?;
Ok((abstract_seq, BindableReadlineTarget::Macro(abstract_target)))
}
}
}
fn bind_key_sequence_to_shell_cmd(
bindings: &mut dyn interfaces::KeyBindings,
key_sequence: interfaces::KeySequence,
command: String,
) -> Result<(), BindError> {
tracing::debug!(target: trace_categories::INPUT,
"binding key sequence: '{key_sequence}' => command '{command}'"
);
bindings.bind(key_sequence, interfaces::KeyAction::ShellCommand(command))?;
Ok(())
}
fn bind_key_sequence_to_readline_target(
bindings: &mut dyn interfaces::KeyBindings,
key_sequence: interfaces::KeySequence,
target: BindableReadlineTarget,
) -> Result<(), BindError> {
match target {
BindableReadlineTarget::Function(func) => {
tracing::debug!(target: trace_categories::INPUT,
"binding key sequence: '{key_sequence}' => readline function '{func}'"
);
if matches!(func, interfaces::InputFunction::ViEditingMode) {
// NOTE(vi): We don't support vi mode; silently ignore.
return Ok(());
}
bindings.bind(key_sequence, interfaces::KeyAction::DoInputFunction(func))?;
Ok(())
}
BindableReadlineTarget::Macro(cmd_macro) => {
tracing::debug!(target: trace_categories::INPUT,
"binding key sequence: '{key_sequence}' => readline macro '{cmd_macro}'"
);
bindings.define_macro(key_sequence, cmd_macro)?;
Ok(())
}
}
}
fn key_sequence_to_abstract_strokes(
seq: &brush_parser::readline_binding::KeySequence,
) -> Result<interfaces::KeySequence, BindError> {
let phys_strokes = brush_parser::readline_binding::key_sequence_to_strokes(seq)?;
// Lift from key codes to abstract keys.
let mut abstract_strokes = vec![];
let mut key_code_bytes = vec![];
let mut uninterpretable = false;
for mut phys_stroke in phys_strokes {
let mut key = sys::input::try_get_key_from_key_code(phys_stroke.key_code.as_slice());
// If we couldn't interpret it directly but we see it starts with the escape character,
// try to see if we can parse it as an Alt+<key> sequence.
if key.is_none() && phys_stroke.key_code.len() > 1 && phys_stroke.key_code[0] == b'\x1b' {
key = sys::input::try_get_key_from_key_code(&phys_stroke.key_code[1..]);
if key.is_some() {
phys_stroke.meta = true;
}
}
// When storing as bytes, apply control modifier to the key code.
let mut raw_bytes = phys_stroke.key_code.clone();
if phys_stroke.control {
for byte in &mut raw_bytes {
// Control characters are computed by ANDing with 0x1F
*byte &= 0x1F;
}
}
key_code_bytes.push(raw_bytes);
if let Some(key) = key {
abstract_strokes.push(interfaces::KeyStroke {
alt: phys_stroke.meta,
control: phys_stroke.control,
shift: false,
key,
});
} else {
uninterpretable = true;
}
}
if uninterpretable {
Ok(interfaces::KeySequence::Bytes(key_code_bytes))
} else {
Ok(interfaces::KeySequence::Strokes(abstract_strokes))
}
}
fn parse_readline_function(func_name: &str) -> Result<interfaces::InputFunction, BindError> {
interfaces::InputFunction::from_str(func_name)
.map_err(|_err| BindError::UnknownKeyBindingFunction(func_name.to_owned()))
}
const fn to_onoff(value: bool) -> &'static str {
if value { "on" } else { "off" }
}
fn display_funcs_and_bindings(
bindings: &dyn interfaces::KeyBindings,
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
reusable: bool,
) -> Result<(), BindError> {
let mut sequences_by_func: HashMap<InputFunction, Vec<KeySequence>> = HashMap::new();
for (seq, action) in &bindings.get_current() {
let KeyAction::DoInputFunction(func) = action else {
continue;
};
sequences_by_func
.entry(func.clone())
.or_default()
.push(seq.clone());
}
let sorted_funcs = interfaces::InputFunction::iter().sorted_by_key(|f| f.to_string());
for func in sorted_funcs {
if let Some(seqs) = sequences_by_func.get(&func) {
if reusable {
for seq in seqs {
writeln!(context.stdout(), "\"{seq}\": {func}")?;
}
} else {
writeln!(
context.stdout(),
"{func} can be found on {}.",
seqs.iter().map(|seq| std::format!("\"{seq}\"")).join(", ")
)?;
}
} else {
if reusable {
writeln!(context.stdout(), "# {func} (not bound)")?;
} else {
writeln!(context.stdout(), "{func} is not bound to any keys")?;
}
}
}
Ok(())
}
fn display_macros(
bindings: &dyn interfaces::KeyBindings,
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
reusable: bool,
) -> Result<(), BindError> {
for (left, right) in bindings.get_macros() {
if reusable {
writeln!(context.stdout(), "\"{left}\": \"{right}\"")?;
} else {
writeln!(context.stdout(), "{left} outputs {right}")?;
}
}
Ok(())
}
fn find_key_seqs_bound_to_function(
bindings: &dyn interfaces::KeyBindings,
func_str: &str,
) -> Result<Vec<interfaces::KeySequence>, BindError> {
let Ok(func_to_find) = InputFunction::from_str(func_str) else {
return Err(BindError::UnknownFunction(func_str.to_owned()));
};
let mut found_seqs = vec![];
for (seq, action) in &bindings.get_current() {
if let KeyAction::DoInputFunction(func) = action
&& *func == func_to_find
{
found_seqs.push(seq.clone());
}
}
Ok(found_seqs)
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::{assert_eq, assert_matches};
#[test]
fn parse_example_key_sequence_and_readline_func() {
let (key_seq, target) =
parse_key_sequence_and_readline_target(r#""\C-a":beginning-of-line"#).unwrap();
assert_eq!(
key_seq,
interfaces::KeySequence::Strokes(vec![interfaces::KeyStroke {
alt: false,
control: true,
shift: false,
key: interfaces::Key::Character('a'),
}])
);
assert_matches!(
target,
BindableReadlineTarget::Function(interfaces::InputFunction::BeginningOfLine)
);
}
#[test]
fn parse_escape_char_key_binding() {
let (key_seq, target) =
parse_key_sequence_and_readline_target(r#""\er":transpose-chars"#).unwrap();
assert_eq!(
key_seq,
interfaces::KeySequence::Strokes(vec![interfaces::KeyStroke {
alt: true,
control: false,
shift: false,
key: interfaces::Key::Character('r'),
}])
);
assert_matches!(
target,
BindableReadlineTarget::Function(interfaces::InputFunction::TransposeChars)
);
}
}
+34
View File
@@ -0,0 +1,34 @@
use clap::Parser;
use brush_core::{ExecutionControlFlow, ExecutionExitCode, ExecutionResult, builtins};
/// Breaks out of a control-flow loop.
#[derive(Parser)]
pub(crate) struct BreakCommand {
/// If specified, indicates which nested loop to break out of.
#[clap(default_value_t = 1)]
which_loop: i8,
}
impl builtins::Command for BreakCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
_context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
// If specified, which_loop needs to be positive.
if self.which_loop <= 0 {
return Ok(ExecutionExitCode::InvalidUsage.into());
}
let mut result = ExecutionResult::success();
result.next_control_flow = ExecutionControlFlow::BreakLoop {
#[expect(clippy::cast_sign_loss)]
levels: (self.which_loop - 1) as usize,
};
Ok(result)
}
}
+20
View File
@@ -0,0 +1,20 @@
use crate::BuiltinSet;
/// Extension trait that simplifies adding default builtins to a shell builder.
pub trait ShellBuilderExt {
/// Add default builtins to the shell being built.
///
/// # Arguments
///
/// * `set` - The well-known set of built-ins to add.
#[must_use]
fn default_builtins(self, set: BuiltinSet) -> Self;
}
impl<SE: brush_core::extensions::ShellExtensions, S: brush_core::ShellBuilderState> ShellBuilderExt
for brush_core::ShellBuilder<SE, S>
{
fn default_builtins(self, set: BuiltinSet) -> Self {
self.builtins(crate::default_builtins(set))
}
}
+45
View File
@@ -0,0 +1,45 @@
use clap::Parser;
use brush_core::{ExecutionResult, builtins};
/// Directly invokes a built-in, without going through typical search order.
#[derive(Default, Parser)]
pub(crate) struct BuiltinCommand {
#[clap(skip)]
args: Vec<brush_core::CommandArg>,
}
impl builtins::DeclarationCommand for BuiltinCommand {
fn set_declarations(&mut self, args: Vec<brush_core::CommandArg>) {
self.args = args;
}
}
impl builtins::Command for BuiltinCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
mut context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
if self.args.is_empty() {
return Ok(ExecutionResult::success());
}
let args: Vec<_> = self.args.iter().skip(1).cloned().collect();
if args.is_empty() {
return Ok(ExecutionResult::success());
}
let builtin_name = args[0].to_string();
if let Some(builtin) = context.shell.builtins().get(&builtin_name)
&& !builtin.disabled
{
context.command_name = builtin_name;
(builtin.execute_func)(context, args).await
} else {
Err(brush_core::ErrorKind::BuiltinNotFound(builtin_name).into())
}
}
}
+55
View File
@@ -0,0 +1,55 @@
use brush_core::{ExecutionResult, builtins, callstack};
use clap::Parser;
use std::io::Write;
/// Return the context of the current subroutine call.
#[derive(Parser)]
pub(crate) struct CallerCommand {
/// The number of call frames to go back.
expr: Option<usize>,
}
impl builtins::Command for CallerCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<ExecutionResult, Self::Error> {
let stack = context.shell.call_stack();
// See how far back we need to look. Frame N represents the Nth caller
// (e.g., 0 = immediate caller, 1 = caller's caller, etc.).
let expr = self.expr.unwrap_or(0);
// Get all frames into a vector we can easily index into.
let frames: Vec<_> = stack
.iter()
.filter(|frame| frame.frame_type.is_function() || frame.frame_type.is_script())
.collect();
// Look for the last-known location in the parent of frame N.
let Some(calling_frame) = frames.get(expr + 1) else {
return Ok(ExecutionResult::general_error());
};
let line = calling_frame.current_line().unwrap_or(1);
let filename = &calling_frame.source_info.source;
// When the expr is provided, we display "LINE FUNCTION_NAME FILENAME"
// When the expr is omitted, we only display "LINE FILENAME"
if self.expr.is_some() {
let function_name = match &calling_frame.frame_type {
callstack::FrameType::Function(func_call) => func_call.name(),
callstack::FrameType::Script(..) => "source".into(),
_ => "".into(),
};
writeln!(context.stdout(), "{line} {function_name} {filename}")?;
} else {
writeln!(context.stdout(), "{line} {filename}")?;
}
Ok(ExecutionResult::success())
}
}
+97
View File
@@ -0,0 +1,97 @@
use std::io::Write;
use std::path::PathBuf;
use clap::Parser;
use brush_core::{ExecutionResult, builtins, error};
/// Change the current shell working directory.
#[derive(Parser)]
pub(crate) struct CdCommand {
/// Force following symlinks.
#[arg(short = 'L', overrides_with = "use_physical_dir")]
force_follow_symlinks: bool,
/// Use physical dir structure without following symlinks.
#[arg(short = 'P', overrides_with = "force_follow_symlinks")]
use_physical_dir: bool,
/// Exit with non zero exit status if current working directory resolution fails.
#[arg(short = 'e')]
exit_on_failed_cwd_resolution: bool,
/// Show file with extended attributes as a dir with extended
/// attributes.
#[arg(short = '@')]
file_with_xattr_as_dir: bool,
/// By default it is the value of the HOME shell variable. If `TARGET_DIR` is "-", it is
/// converted to $OLDPWD.
target_dir: Option<PathBuf>,
}
impl builtins::Command for CdCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<ExecutionResult, Self::Error> {
// TODO(cd): implement 'cd -@'
if self.file_with_xattr_as_dir {
return error::unimp("cd -@");
}
let mut should_print = false;
let mut target_dir = if let Some(target_dir) = &self.target_dir {
// `cd -', equivalent to `cd $OLDPWD'
if target_dir.as_os_str() == "-" {
should_print = true;
if let Some(oldpwd) = context.shell.env_str("OLDPWD") {
PathBuf::from(oldpwd.to_string())
} else {
writeln!(context.stderr(), "OLDPWD not set")?;
return Ok(ExecutionResult::general_error());
}
} else {
// TODO(cd): remove clone, and use temporary lifetime extension after rust 1.75
target_dir.clone()
}
// `cd' without arguments is equivalent to `cd $HOME'
} else {
if let Some(home_var) = context.shell.env_str("HOME") {
PathBuf::from(home_var.to_string())
} else {
writeln!(context.stderr(), "HOME not set")?;
return Ok(ExecutionResult::general_error());
}
};
if self.use_physical_dir
|| context
.shell
.options()
.do_not_resolve_symlinks_when_changing_dir
{
// -e is only relevant in physical mode.
if self.exit_on_failed_cwd_resolution {
return error::unimp("cd -e");
}
target_dir = context.shell.absolute_path(target_dir).canonicalize()?;
}
context.shell.set_working_dir(&target_dir)?;
// Bash compatibility
// https://www.gnu.org/software/bash/manual/bash.html#index-cd
// If a non-empty directory name from CDPATH is used, or if '-' is the first argument, and
// the directory change is successful, the absolute pathname of the new working
// directory is written to the standard output.
if should_print {
writeln!(context.stdout(), "{}", target_dir.display())?;
}
Ok(ExecutionResult::success())
}
}
+28
View File
@@ -0,0 +1,28 @@
use brush_core::{ExecutionResult, builtins, error};
/// No-op command.
pub(crate) struct ColonCommand {}
impl builtins::SimpleCommand for ColonCommand {
fn get_content(
_name: &str,
content_type: builtins::ContentType,
_options: &builtins::ContentOptions,
) -> Result<String, brush_core::Error> {
match content_type {
builtins::ContentType::DetailedHelp => {
Ok("Null command; always returns success.".into())
}
builtins::ContentType::ShortUsage => Ok(":: :".into()),
builtins::ContentType::ShortDescription => Ok(": - Null command".into()),
builtins::ContentType::ManPage => error::unimp("man page not yet implemented"),
}
}
fn execute<SE: brush_core::ShellExtensions, I: Iterator<Item = S>, S: AsRef<str>>(
_context: brush_core::ExecutionContext<'_, SE>,
_args: I,
) -> Result<ExecutionResult, brush_core::Error> {
Ok(ExecutionResult::success())
}
}
+159
View File
@@ -0,0 +1,159 @@
use clap::Parser;
use std::{fmt::Display, io::Write, path::Path};
use brush_core::{
ExecutionResult, builtins, commands, pathsearch,
sys::{self, fs::PathExt},
};
/// Directly invokes an external command, without going through typical search order.
#[derive(Default, Parser)]
pub(crate) struct CommandCommand {
/// Use default PATH value.
#[arg(short = 'p')]
pub use_default_path: bool,
/// Display a short description of the command.
#[arg(short = 'v')]
pub print_description: bool,
/// Display a more verbose description of the command.
#[arg(short = 'V')]
pub print_verbose_description: bool,
/// Command and arguments.
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
pub command_and_args: Vec<String>,
}
impl CommandCommand {
fn command(&self) -> Option<&String> {
self.command_and_args.first()
}
}
impl builtins::Command for CommandCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<ExecutionResult, Self::Error> {
// Silently exit if no command was provided.
if let Some(command_name) = self.command() {
if self.print_description || self.print_verbose_description {
if let Some(found_cmd) = Self::try_find_command(
context.shell,
command_name.as_str(),
self.use_default_path,
) {
if self.print_description {
writeln!(context.stdout(), "{found_cmd}")?;
} else {
match found_cmd {
FoundCommand::Builtin(_name) => {
writeln!(context.stdout(), "{command_name} is a shell builtin")?;
}
FoundCommand::External(path) => {
writeln!(context.stdout(), "{command_name} is {path}")?;
}
}
}
Ok(ExecutionResult::success())
} else {
if self.print_verbose_description {
writeln!(context.stderr(), "command: {command_name}: not found")?;
}
Ok(ExecutionResult::general_error())
}
} else {
self.execute_command(context, command_name, self.use_default_path)
.await
}
} else {
Ok(ExecutionResult::success())
}
}
}
enum FoundCommand {
Builtin(String),
External(String),
}
impl Display for FoundCommand {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Builtin(name) => write!(f, "{name}"),
Self::External(path) => write!(f, "{path}"),
}
}
}
impl CommandCommand {
fn try_find_command(
shell: &mut brush_core::Shell<impl brush_core::ShellExtensions>,
command_name: &str,
use_default_path: bool,
) -> Option<FoundCommand> {
// Look in path.
if sys::fs::contains_path_separator(command_name) {
let candidate_path = shell.absolute_path(Path::new(command_name));
if candidate_path.executable() {
Some(FoundCommand::External(
candidate_path.to_string_lossy().to_string(),
))
} else {
None
}
} else {
if let Some(builtin_cmd) = shell.builtins().get(command_name)
&& !builtin_cmd.disabled
{
return Some(FoundCommand::Builtin(command_name.to_owned()));
}
if use_default_path {
let dirs = sys::fs::get_default_standard_utils_paths();
pathsearch::search_for_executable(dirs.iter(), command_name)
.next()
.map(|path| FoundCommand::External(path.to_string_lossy().to_string()))
} else {
shell
.find_first_executable_in_path_using_cache(command_name)
.map(|path| FoundCommand::External(path.to_string_lossy().to_string()))
}
}
}
async fn execute_command(
&self,
mut context: brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
command_name: &str,
use_default_path: bool,
) -> Result<ExecutionResult, brush_core::Error> {
command_name.clone_into(&mut context.command_name);
let command_and_args = self.command_and_args.iter().map(|arg| arg.into()).collect();
let path_dirs = if use_default_path {
Some(sys::fs::get_default_standard_utils_paths())
} else {
None
};
let mut cmd = commands::SimpleCommand::new(
commands::ShellForCommand::ParentShell(context.shell),
context.params,
context.command_name,
command_and_args,
);
cmd.use_functions = false;
cmd.path_dirs = path_dirs;
let spawn_result = cmd.execute().await?;
let wait_result = spawn_result.wait().await?;
Ok(wait_result.into())
}
}
+661
View File
@@ -0,0 +1,661 @@
use clap::Parser;
use std::collections::HashMap;
use std::fmt::Write as _;
use std::io::Write;
use brush_core::completion::{self, CompleteAction, CompleteOption, Spec};
use brush_core::{ExecutionExitCode, ExecutionResult, builtins, error, escape};
#[derive(Parser)]
struct CommonCompleteCommandArgs {
/// Options governing the behavior of completions.
#[arg(short = 'o')]
options: Vec<CompleteOption>,
/// Actions to apply to generate completions.
#[arg(short = 'A')]
actions: Vec<CompleteAction>,
/// File glob pattern to be expanded to generate completions.
#[arg(short = 'G', allow_hyphen_values = true, value_name = "GLOB")]
glob_pattern: Option<String>,
/// List of words that will be considered as completions.
#[arg(short = 'W', allow_hyphen_values = true)]
word_list: Option<String>,
/// Name of a shell function to invoke to generate completions.
#[arg(short = 'F', allow_hyphen_values = true, value_name = "FUNC_NAME")]
function_name: Option<String>,
/// Command to execute to generate completions.
#[arg(short = 'C', allow_hyphen_values = true)]
command: Option<String>,
/// Pattern used as filter for completions.
#[arg(short = 'X', allow_hyphen_values = true, value_name = "PATTERN")]
filter_pattern: Option<String>,
/// Prefix pattern used as filter for completions.
#[arg(short = 'P', allow_hyphen_values = true)]
prefix: Option<String>,
/// Suffix pattern used as filter for completions.
#[arg(short = 'S', allow_hyphen_values = true)]
suffix: Option<String>,
/// Complete with valid aliases.
#[arg(short = 'a')]
action_alias: bool,
/// Complete with names of shell builtins.
#[arg(short = 'b')]
action_builtin: bool,
/// Complete with names of executable commands.
#[arg(short = 'c')]
action_command: bool,
/// Complete with directory names.
#[arg(short = 'd')]
action_directory: bool,
/// Complete with names of exported shell variables.
#[arg(short = 'e')]
action_exported: bool,
/// Complete with filenames.
#[arg(short = 'f')]
action_file: bool,
/// Complete with valid user groups.
#[arg(short = 'g')]
action_group: bool,
/// Complete with job specs.
#[arg(short = 'j')]
action_job: bool,
/// Complete with keywords.
#[arg(short = 'k')]
action_keyword: bool,
/// Complete with names of system services.
#[arg(short = 's')]
action_service: bool,
/// Complete with valid usernames.
#[arg(short = 'u')]
action_user: bool,
/// Complete with names of shell variables.
#[arg(short = 'v')]
action_variable: bool,
}
impl CommonCompleteCommandArgs {
fn create_spec(&self, extglob_enabled: bool) -> completion::Spec {
let filter_pattern_excludes;
let filter_pattern = if let Some(filter_pattern) = self.filter_pattern.as_ref() {
// If the pattern starts with a '!' that's not the start of an extglob pattern,
// then we invert.
if let Some(remaining_pattern) = filter_pattern.strip_prefix('!') {
if !extglob_enabled || !remaining_pattern.starts_with('(') {
filter_pattern_excludes = false;
Some(remaining_pattern.to_owned())
} else {
filter_pattern_excludes = true;
Some(filter_pattern.to_owned())
}
} else {
filter_pattern_excludes = true;
Some(filter_pattern.clone())
}
} else {
filter_pattern_excludes = false;
None
};
let mut spec = completion::Spec {
options: completion::GenerationOptions::default(),
actions: self.resolve_actions(),
glob_pattern: self.glob_pattern.clone(),
word_list: self.word_list.clone(),
function_name: self.function_name.clone(),
command: self.command.clone(),
filter_pattern,
filter_pattern_excludes,
prefix: self.prefix.clone(),
suffix: self.suffix.clone(),
};
for option in &self.options {
match option {
CompleteOption::BashDefault => spec.options.bash_default = true,
CompleteOption::Default => spec.options.default = true,
CompleteOption::DirNames => spec.options.dir_names = true,
CompleteOption::FileNames => spec.options.file_names = true,
CompleteOption::NoQuote => spec.options.no_quote = true,
CompleteOption::NoSort => spec.options.no_sort = true,
CompleteOption::NoSpace => spec.options.no_space = true,
CompleteOption::PlusDirs => spec.options.plus_dirs = true,
}
}
spec
}
fn resolve_actions(&self) -> Vec<CompleteAction> {
let mut actions = self.actions.clone();
if self.action_alias {
actions.push(CompleteAction::Alias);
}
if self.action_builtin {
actions.push(CompleteAction::Builtin);
}
if self.action_command {
actions.push(CompleteAction::Command);
}
if self.action_directory {
actions.push(CompleteAction::Directory);
}
if self.action_exported {
actions.push(CompleteAction::Export);
}
if self.action_file {
actions.push(CompleteAction::File);
}
if self.action_group {
actions.push(CompleteAction::Group);
}
if self.action_job {
actions.push(CompleteAction::Job);
}
if self.action_keyword {
actions.push(CompleteAction::Keyword);
}
if self.action_service {
actions.push(CompleteAction::Service);
}
if self.action_user {
actions.push(CompleteAction::User);
}
if self.action_variable {
actions.push(CompleteAction::Variable);
}
actions
}
}
/// Configure programmable command completion.
#[derive(Parser)]
pub(crate) struct CompleteCommand {
/// Display registered completion settings.
#[arg(short = 'p')]
print: bool,
/// Remove the completion settings associated with the given command.
#[arg(short = 'r')]
remove: bool,
/// Apply these settings to the default completion scenario.
#[arg(short = 'D')]
use_as_default: bool,
/// Apply these settings to completion of empty lines.
#[arg(short = 'E')]
use_for_empty_line: bool,
/// Apply these settings to completion of the initial word of the input line.
#[arg(short = 'I')]
use_for_initial_word: bool,
#[clap(flatten)]
common_args: CommonCompleteCommandArgs,
names: Vec<String>,
}
impl builtins::Command for CompleteCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
mut context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
let mut result = ExecutionResult::success();
// If -D, -E, or -I are specified, then any names provided are ignored.
if self.use_as_default
|| self.use_for_empty_line
|| self.use_for_initial_word
|| self.names.is_empty()
{
self.process_global(&mut context)?;
} else {
for name in &self.names {
if !self.try_process_for_command(&mut context, name.as_str())? {
result = ExecutionResult::general_error();
}
}
}
Ok(result)
}
}
impl CompleteCommand {
fn process_global(
&self,
context: &mut brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
) -> Result<(), brush_core::Error> {
// Read options before taking mutable borrow on completion_config
let extended_globbing = context.shell.options().extended_globbing;
// These are processed in an intentional order.
let special_option_name;
let target_spec = if self.use_as_default {
special_option_name = "-D";
Some(&mut context.shell.completion_config_mut().default)
} else if self.use_for_empty_line {
special_option_name = "-E";
Some(&mut context.shell.completion_config_mut().empty_line)
} else if self.use_for_initial_word {
special_option_name = "-I";
Some(&mut context.shell.completion_config_mut().initial_word)
} else {
special_option_name = "";
None
};
// Treat 'complete' with no options the same as 'complete -p'.
if self.print || (!self.remove && target_spec.is_none()) {
if let Some(target_spec) = target_spec {
if let Some(existing_spec) = target_spec {
let existing_spec = existing_spec.clone();
Self::display_spec(context, Some(special_option_name), None, &existing_spec)?;
} else {
return error::unimp("special spec not found");
}
} else {
for (command_name, spec) in context.shell.completion_config().iter() {
Self::display_spec(context, None, Some(command_name.as_str()), spec)?;
}
}
} else if self.remove {
if let Some(target_spec) = target_spec {
let mut new_spec = None;
std::mem::swap(&mut new_spec, target_spec);
} else {
context.shell.completion_config_mut().clear();
}
} else {
if let Some(target_spec) = target_spec {
let mut new_spec = Some(self.common_args.create_spec(extended_globbing));
std::mem::swap(&mut new_spec, target_spec);
} else {
return error::unimp("set unspecified spec");
}
}
Ok(())
}
fn try_display_spec_for_command(
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
name: &str,
) -> Result<bool, brush_core::Error> {
if let Some(spec) = context.shell.completion_config().get(name) {
Self::display_spec(context, None, Some(name), spec)?;
Ok(true)
} else {
writeln!(context.stderr(), "no completion found for command")?;
Ok(false)
}
}
#[expect(clippy::too_many_lines)]
fn display_spec(
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
special_name: Option<&str>,
command_name: Option<&str>,
spec: &Spec,
) -> Result<(), brush_core::Error> {
let mut s = String::from("complete");
if let Some(special_name) = special_name {
s.push(' ');
s.push_str(special_name);
}
for action in &spec.actions {
s.push(' ');
let action_str = match action {
CompleteAction::Alias => "-a",
CompleteAction::ArrayVar => "-A arrayvar",
CompleteAction::Binding => "-A binding",
CompleteAction::Builtin => "-b",
CompleteAction::Command => "-c",
CompleteAction::Directory => "-d",
CompleteAction::Disabled => "-A disabled",
CompleteAction::Enabled => "-A enabled",
CompleteAction::Export => "-e",
CompleteAction::File => "-f",
CompleteAction::Function => "-A function",
CompleteAction::Group => "-g",
CompleteAction::HelpTopic => "-A helptopic",
CompleteAction::HostName => "-A hostname",
CompleteAction::Job => "-j",
CompleteAction::Keyword => "-k",
CompleteAction::Running => "-A running",
CompleteAction::Service => "-s",
CompleteAction::SetOpt => "-A setopt",
CompleteAction::ShOpt => "-A shopt",
CompleteAction::Signal => "-A signal",
CompleteAction::Stopped => "-A stopped",
CompleteAction::User => "-u",
CompleteAction::Variable => "-v",
};
s.push_str(action_str);
}
if spec.options.bash_default {
s.push_str(" -o bashdefault");
}
if spec.options.default {
s.push_str(" -o default");
}
if spec.options.dir_names {
s.push_str(" -o dirnames");
}
if spec.options.file_names {
s.push_str(" -o filenames");
}
if spec.options.no_quote {
s.push_str(" -o noquote");
}
if spec.options.no_sort {
s.push_str(" -o nosort");
}
if spec.options.no_space {
s.push_str(" -o nospace");
}
if spec.options.plus_dirs {
s.push_str(" -o plusdirs");
}
if let Some(glob_pattern) = &spec.glob_pattern {
write!(
s,
" -G {}",
escape::force_quote(glob_pattern, escape::QuoteMode::SingleQuote)
)?;
}
if let Some(word_list) = &spec.word_list {
write!(
s,
" -W {}",
escape::force_quote(word_list, escape::QuoteMode::SingleQuote)
)?;
}
if let Some(function_name) = &spec.function_name {
write!(s, " -F {function_name}")?;
}
if let Some(command) = &spec.command {
write!(
s,
" -C {}",
escape::force_quote(command, escape::QuoteMode::SingleQuote)
)?;
}
if let Some(filter_pattern) = &spec.filter_pattern {
write!(
s,
" -X {}",
escape::force_quote(filter_pattern, escape::QuoteMode::SingleQuote)
)?;
}
if let Some(prefix) = &spec.prefix {
write!(
s,
" -P {}",
escape::force_quote(prefix, escape::QuoteMode::SingleQuote)
)?;
}
if let Some(suffix) = &spec.suffix {
write!(
s,
" -S {}",
escape::force_quote(suffix, escape::QuoteMode::SingleQuote)
)?;
}
if let Some(command_name) = command_name {
s.push(' ');
s.push_str(command_name);
}
writeln!(context.stdout(), "{s}")?;
Ok(())
}
fn try_process_for_command(
&self,
context: &mut brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
name: &str,
) -> Result<bool, brush_core::Error> {
if self.print {
return Self::try_display_spec_for_command(context, name);
} else if self.remove {
let mut result = context.shell.completion_config_mut().remove(name);
if !result {
if context.shell.options().interactive {
writeln!(context.stderr(), "complete: {name}: not found")?;
} else {
// For some reason, this is not supposed to be treated as a failure
// in non-interactive execution.
result = true;
}
}
return Ok(result);
}
let config = self
.common_args
.create_spec(context.shell.options().extended_globbing);
context.shell.completion_config_mut().set(name, config);
Ok(true)
}
}
/// Generate command completions.
#[derive(Parser)]
pub(crate) struct CompGenCommand {
#[clap(flatten)]
common_args: CommonCompleteCommandArgs,
// N.B. The word can only start with a hyphen if it's after a --.
word: Option<String>,
}
impl builtins::Command for CompGenCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
let mut spec = self
.common_args
.create_spec(context.shell.options().extended_globbing);
spec.options.no_sort = true;
let token_to_complete = self.word.as_deref().unwrap_or_default();
// We unquote the token-to-be-completed before passing it to the completion system.
let unquoted_token = brush_parser::unquote_str(token_to_complete);
let completion_context = completion::Context {
token_to_complete: unquoted_token.as_str(),
preceding_token: None,
command_name: None,
token_index: 0,
tokens: &[&completion::CompletionToken {
text: token_to_complete,
start: 0,
}],
input_line: token_to_complete,
cursor_index: token_to_complete.len(),
trigger: completion::CompletionTrigger::Programmatic,
};
let result = spec
.get_completions(context.shell, &completion_context)
.await?;
match result {
completion::Answer::Candidates(candidates, _options) => {
// We are expected to return 1 if there are no candidates, even if no errors
// occurred along the way.
if candidates.is_empty() {
return Ok(ExecutionResult::general_error());
}
for candidate in candidates {
writeln!(context.stdout(), "{candidate}")?;
}
}
completion::Answer::RestartCompletionProcess => {
return error::unimp("restart completion");
}
}
Ok(ExecutionResult::success())
}
}
/// Set programmable command completion options.
#[derive(Parser)]
pub(crate) struct CompOptCommand {
/// Update the default completion settings.
#[arg(short = 'D')]
update_default: bool,
/// Update the completion settings for empty lines.
#[arg(short = 'E')]
update_empty: bool,
/// Update the completion settings for the initial word of the input line.
#[arg(short = 'I')]
update_initial_word: bool,
/// Enable the specified option for selected completion scenarios.
#[arg(short = 'o', value_name = "OPT")]
enabled_options: Vec<CompleteOption>,
#[arg(long = concat!("+o"), hide = true)]
disabled_options: Vec<CompleteOption>,
/// If specified, scopes updates to completions of the named commands.
names: Vec<String>,
}
impl builtins::Command for CompOptCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
let mut options = HashMap::new();
for option in &self.disabled_options {
options.insert(option.clone(), false);
}
for option in &self.enabled_options {
options.insert(option.clone(), true);
}
if !self.names.is_empty() {
if self.update_default || self.update_empty || self.update_initial_word {
writeln!(
context.stderr(),
"compopt: cannot specify names with -D, -E, or -I"
)?;
return Ok(ExecutionExitCode::InvalidUsage.into());
}
for name in &self.names {
let spec = context.shell.completion_config_mut().get_or_add_mut(name);
Self::set_options_for_spec(spec, &options);
}
} else if self.update_default {
if let Some(spec) = &mut context.shell.completion_config_mut().default {
Self::set_options_for_spec(spec, &options);
} else {
let mut spec = Spec::default();
Self::set_options_for_spec(&mut spec, &options);
context.shell.completion_config_mut().default = Some(spec);
}
} else if self.update_empty {
if let Some(spec) = &mut context.shell.completion_config_mut().empty_line {
Self::set_options_for_spec(spec, &options);
} else {
let mut spec = Spec::default();
Self::set_options_for_spec(&mut spec, &options);
context.shell.completion_config_mut().empty_line = Some(spec);
}
} else if self.update_initial_word {
if let Some(spec) = &mut context.shell.completion_config_mut().initial_word {
Self::set_options_for_spec(spec, &options);
} else {
let mut spec = Spec::default();
Self::set_options_for_spec(&mut spec, &options);
context.shell.completion_config_mut().initial_word = Some(spec);
}
} else {
// If we got here, then we need to apply to any completion actively in-flight.
if let Some(in_flight_options) = context
.shell
.completion_config_mut()
.current_completion_options
.as_mut()
{
Self::set_options(in_flight_options, &options);
}
}
Ok(ExecutionResult::success())
}
}
impl CompOptCommand {
fn set_options_for_spec(spec: &mut Spec, options: &HashMap<CompleteOption, bool>) {
Self::set_options(&mut spec.options, options);
}
fn set_options(
target_options: &mut completion::GenerationOptions,
options: &HashMap<CompleteOption, bool>,
) {
for (option, value) in options {
match option {
CompleteOption::BashDefault => target_options.bash_default = *value,
CompleteOption::Default => target_options.default = *value,
CompleteOption::DirNames => target_options.dir_names = *value,
CompleteOption::FileNames => target_options.file_names = *value,
CompleteOption::NoQuote => target_options.no_quote = *value,
CompleteOption::NoSort => target_options.no_sort = *value,
CompleteOption::NoSpace => target_options.no_space = *value,
CompleteOption::PlusDirs => target_options.plus_dirs = *value,
}
}
}
}
+34
View File
@@ -0,0 +1,34 @@
use clap::Parser;
use brush_core::{ExecutionControlFlow, ExecutionExitCode, ExecutionResult, builtins};
/// Continue to the next iteration of a control-flow loop.
#[derive(Parser)]
pub(crate) struct ContinueCommand {
/// If specified, indicates which nested loop to continue to the next iteration of.
#[clap(default_value_t = 1)]
which_loop: i8,
}
impl builtins::Command for ContinueCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
_context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
// If specified, which_loop needs to be positive.
if self.which_loop <= 0 {
return Ok(ExecutionExitCode::InvalidUsage.into());
}
let mut result = ExecutionResult::success();
result.next_control_flow = ExecutionControlFlow::ContinueLoop {
#[expect(clippy::cast_sign_loss)]
levels: (self.which_loop - 1) as usize,
};
Ok(result)
}
}
+644
View File
@@ -0,0 +1,644 @@
use clap::Parser;
use itertools::Itertools;
use std::{io::Write, sync::LazyLock};
use brush_core::{
ErrorKind, ExecutionResult, builtins,
env::{self, EnvironmentLookup, EnvironmentScope},
error,
parser::ast,
variables::{
self, ArrayLiteral, ShellValue, ShellValueLiteral, ShellValueUnsetType, ShellVariable,
ShellVariableUpdateTransform,
},
};
crate::minus_or_plus_flag_arg!(
MakeIndexedArrayFlag,
'a',
"Make the variable an indexed array."
);
crate::minus_or_plus_flag_arg!(
MakeAssociativeArrayFlag,
'A',
"Make the variable an associative array."
);
crate::minus_or_plus_flag_arg!(
CapitalizeValueOnAssignmentFlag,
'c',
"Enable capitalize-on-assignment for the variable."
);
crate::minus_or_plus_flag_arg!(MakeIntegerFlag, 'i', "Mark the variable as integer-typed");
crate::minus_or_plus_flag_arg!(
LowercaseValueOnAssignmentFlag,
'l',
"Enable lowercase-on-assignment for the variable."
);
crate::minus_or_plus_flag_arg!(
MakeNameRefFlag,
'n',
"Mark the variable as a name reference"
);
crate::minus_or_plus_flag_arg!(MakeReadonlyFlag, 'r', "Mark the variable as read-only.");
crate::minus_or_plus_flag_arg!(MakeTracedFlag, 't', "Enable tracing for the variable.");
crate::minus_or_plus_flag_arg!(
UppercaseValueOnAssignmentFlag,
'u',
"Enable uppercase-on-assignment for the variable."
);
crate::minus_or_plus_flag_arg!(MakeExportedFlag, 'x', "Mark the variable for export.");
/// Display or update variables and their attributes.
#[derive(Parser)]
#[clap(override_usage = "declare [OPTIONS] [DECLARATIONS]...")]
pub(crate) struct DeclareCommand {
/// Constrain to function names or definitions.
#[arg(short = 'f')]
function_names_or_defs_only: bool,
/// Constrain to function names only.
#[arg(short = 'F')]
function_names_only: bool,
/// Create global variable, if applicable.
#[arg(short = 'g')]
create_global: bool,
/// When creating a local variable that shadows another variable of the same name,
/// then initialize it with the contents and attributes of the variable being shadowed.
#[arg(short = 'I')]
locals_inherit_from_prev_scope: bool,
/// Display each item's attributes and values.
#[arg(short = 'p')]
print: bool,
//
// Attribute options
#[clap(flatten)] // -a
make_indexed_array: MakeIndexedArrayFlag,
#[clap(flatten)] // -A
make_associative_array: MakeAssociativeArrayFlag,
#[clap(flatten)] // -c
capitalize_value_on_assignment: CapitalizeValueOnAssignmentFlag,
#[clap(flatten)] // -i
make_integer: MakeIntegerFlag,
#[clap(flatten)] // -l
lowercase_value_on_assignment: LowercaseValueOnAssignmentFlag,
#[clap(flatten)] // -n
make_nameref: MakeNameRefFlag,
#[clap(flatten)] // -r
make_readonly: MakeReadonlyFlag,
#[clap(flatten)] // -t
make_traced: MakeTracedFlag,
#[clap(flatten)] // -u
uppercase_value_on_assignment: UppercaseValueOnAssignmentFlag,
#[clap(flatten)] // -x
make_exported: MakeExportedFlag,
//
// Declarations
//
// N.B. These are skipped by clap, but filled in by the BuiltinDeclarationCommand trait.
#[clap(skip)]
declarations: Vec<brush_core::CommandArg>,
}
#[derive(Clone, Copy)]
enum DeclareVerb {
Declare,
Local,
Readonly,
}
impl builtins::DeclarationCommand for DeclareCommand {
fn set_declarations(&mut self, declarations: Vec<brush_core::CommandArg>) {
self.declarations = declarations;
}
}
impl builtins::Command for DeclareCommand {
fn takes_plus_options() -> bool {
true
}
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
mut context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
let verb = match context.command_name.as_str() {
"local" => DeclareVerb::Local,
"readonly" => DeclareVerb::Readonly,
_ => DeclareVerb::Declare,
};
if matches!(verb, DeclareVerb::Local) && !context.shell.in_function() {
writeln!(context.stderr(), "can only be used in a function")?;
return Ok(ExecutionResult::general_error());
}
if self.locals_inherit_from_prev_scope {
return error::unimp("declare -I");
}
let mut result = ExecutionResult::success();
if !self.declarations.is_empty() {
for declaration in &self.declarations {
if self.print && !matches!(verb, DeclareVerb::Readonly) {
if !self.try_display_declaration(&context, declaration, verb)? {
result = ExecutionResult::general_error();
}
} else {
if !self.process_declaration(&mut context, declaration, verb)? {
result = ExecutionResult::general_error();
}
}
}
} else {
// Display matching declarations from the variable environment.
if !self.function_names_only && !self.function_names_or_defs_only {
self.display_matching_env_declarations(&context, verb)?;
}
// Do the same for functions.
if !matches!(verb, DeclareVerb::Local | DeclareVerb::Readonly)
&& (!self.print || self.function_names_only || self.function_names_or_defs_only)
{
self.display_matching_functions(&context)?;
}
}
Ok(result)
}
}
impl DeclareCommand {
fn try_display_declaration(
&self,
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
declaration: &brush_core::CommandArg,
verb: DeclareVerb,
) -> Result<bool, brush_core::Error> {
let name = match declaration {
brush_core::CommandArg::String(s) => s,
brush_core::CommandArg::Assignment(_) => {
writeln!(context.stderr(), "declare: {declaration}: not found")?;
return Ok(false);
}
};
let lookup = if matches!(verb, DeclareVerb::Local) {
EnvironmentLookup::OnlyInCurrentLocal
} else {
EnvironmentLookup::Anywhere
};
if self.function_names_only || self.function_names_or_defs_only {
if let Some(func_registration) = context.shell.funcs().get(name) {
if self.function_names_only {
if self.print {
writeln!(context.stdout(), "declare -f {name}")?;
} else {
writeln!(context.stdout(), "{name}")?;
}
} else {
writeln!(context.stdout(), "{}", func_registration.definition())?;
}
Ok(true)
} else {
// For some reason, bash does not print an error message in this case.
Ok(false)
}
} else if let Some(variable) = context.shell.env().get_using_policy(name, lookup) {
let mut cs = variable.attribute_flags(context.shell);
if cs.is_empty() {
cs.push('-');
}
let resolved_value = variable.resolve_value(context.shell);
let separator_str = if matches!(resolved_value, ShellValue::Unset(_)) {
""
} else {
"="
};
writeln!(
context.stdout(),
"declare -{cs} {name}{separator_str}{}",
resolved_value.format(variables::FormatStyle::DeclarePrint, context.shell)?
)?;
Ok(true)
} else {
writeln!(context.stderr(), "declare: {name}: not found")?;
Ok(false)
}
}
fn process_declaration(
&self,
context: &mut brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
declaration: &brush_core::CommandArg,
verb: DeclareVerb,
) -> Result<bool, brush_core::Error> {
let create_var_local = matches!(verb, DeclareVerb::Local)
|| (matches!(verb, DeclareVerb::Declare)
&& context.shell.in_function()
&& !self.create_global);
if self.function_names_or_defs_only || self.function_names_only {
return self.try_display_declaration(context, declaration, verb);
}
// Extract the variable name and the initial value being assigned (if any).
let (name, assigned_index, initial_value, name_is_array) =
Self::declaration_to_name_and_value(declaration)?;
// Special-case: `local -`
if name == "-" && matches!(verb, DeclareVerb::Local) {
// TODO(local): `local -` allows shadowing the current `set` options (i.e., $-), with
// subsequent updates getting discarded when the current local scope is popped.
tracing::warn!("not yet implemented: local -");
return Ok(true);
}
// Make sure it's a valid name.
if !env::valid_variable_name(name.as_str()) {
writeln!(
context.stderr(),
"{}: {name}: not a valid variable name",
context.command_name
)?;
return Ok(false);
}
// Figure out where we should look.
let lookup = if create_var_local {
EnvironmentLookup::OnlyInCurrentLocal
} else {
EnvironmentLookup::Anywhere
};
// Look up the variable.
if let Some(var) = context
.shell
.env_mut()
.get_mut_using_policy(name.as_str(), lookup)
{
if self.make_associative_array.is_some() {
var.convert_to_associative_array()?;
}
if self.make_indexed_array.is_some() {
var.convert_to_indexed_array()?;
}
self.apply_attributes_before_update(var)?;
if let Some(initial_value) = initial_value {
// We append if the declaration included an explicit index.
var.assign(initial_value, assigned_index.is_some())?;
}
self.apply_attributes_after_update(var, verb)?;
} else {
let unset_type = if self.make_indexed_array.is_some() {
ShellValueUnsetType::IndexedArray
} else if self.make_associative_array.is_some() {
ShellValueUnsetType::AssociativeArray
} else if name_is_array {
ShellValueUnsetType::IndexedArray
} else {
ShellValueUnsetType::Untyped
};
let mut var = ShellVariable::new(ShellValue::Unset(unset_type));
self.apply_attributes_before_update(&mut var)?;
if let Some(initial_value) = initial_value {
var.assign(initial_value, false)?;
}
if context.shell.options().export_variables_on_modification && !var.value().is_array() {
var.export();
}
self.apply_attributes_after_update(&mut var, verb)?;
let scope = if create_var_local {
EnvironmentScope::Local
} else {
EnvironmentScope::Global
};
context.shell.env_mut().add(name, var, scope)?;
}
Ok(true)
}
fn declaration_to_name_and_value(
declaration: &brush_core::CommandArg,
) -> Result<(String, Option<String>, Option<ShellValueLiteral>, bool), brush_core::Error> {
let name;
let assigned_index;
let initial_value;
let name_is_array;
match declaration {
brush_core::CommandArg::String(s) => {
// We need to handle the case of someone invoking `declare array[index]`.
// In such case, we ignore the index and treat it as a declaration of
// the array.
#[allow(
clippy::unwrap_in_result,
clippy::unwrap_used,
reason = "regex is valid and should not fail"
)]
static ARRAY_AND_INDEX_RE: LazyLock<fancy_regex::Regex> =
LazyLock::new(|| fancy_regex::Regex::new(r"^(.*?)\[(.*?)\]$").unwrap());
if let Some(captures) = ARRAY_AND_INDEX_RE.captures(s)? {
name = captures
.get(1)
.ok_or_else(|| {
brush_core::ErrorKind::InternalError("declaration parse error".into())
})?
.as_str()
.to_owned();
assigned_index = captures.get(2).map(|m| m.as_str().to_owned());
name_is_array = true;
} else {
name = s.clone();
assigned_index = None;
name_is_array = false;
}
initial_value = None;
}
brush_core::CommandArg::Assignment(assignment) => {
match &assignment.name {
ast::AssignmentName::VariableName(var_name) => {
name = var_name.to_owned();
assigned_index = None;
}
ast::AssignmentName::ArrayElementName(var_name, index) => {
if matches!(assignment.value, ast::AssignmentValue::Array(_)) {
return Err(ErrorKind::AssigningListToArrayMember.into());
}
name = var_name.to_owned();
assigned_index = Some(index.to_owned());
}
}
match &assignment.value {
ast::AssignmentValue::Scalar(s) => {
if let Some(index) = &assigned_index {
initial_value = Some(ShellValueLiteral::Array(ArrayLiteral(vec![(
Some(index.to_owned()),
s.value.clone(),
)])));
name_is_array = true;
} else {
initial_value = Some(ShellValueLiteral::Scalar(s.value.clone()));
name_is_array = false;
}
}
ast::AssignmentValue::Array(a) => {
initial_value = Some(ShellValueLiteral::Array(ArrayLiteral(
a.iter()
.map(|(i, v)| {
(i.as_ref().map(|w| w.value.clone()), v.value.clone())
})
.collect(),
)));
name_is_array = true;
}
}
}
}
Ok((name, assigned_index, initial_value, name_is_array))
}
fn display_matching_env_declarations(
&self,
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
verb: DeclareVerb,
) -> Result<(), brush_core::Error> {
//
// Dump all declarations. Use attribute flags to filter which variables are dumped.
//
// We start by excluding all variables that are not enumerable.
#[expect(clippy::type_complexity)]
let mut filters: Vec<Box<dyn Fn((&String, &ShellVariable)) -> bool>> =
vec![Box::new(|(_, v)| v.is_enumerable())];
// Add filters depending on verb.
if matches!(verb, DeclareVerb::Readonly) {
filters.push(Box::new(|(_, v)| v.is_readonly()));
}
// Add filters depending on attribute flags.
if let Some(value) = self.make_indexed_array.to_bool() {
filters.push(Box::new(move |(_, v)| {
matches!(v.value(), ShellValue::IndexedArray(_)) == value
}));
}
if let Some(value) = self.make_associative_array.to_bool() {
filters.push(Box::new(move |(_, v)| {
matches!(v.value(), ShellValue::AssociativeArray(_)) == value
}));
}
if let Some(value) = self.make_integer.to_bool() {
filters.push(Box::new(move |(_, v)| v.is_treated_as_integer() == value));
}
if let Some(value) = self.capitalize_value_on_assignment.to_bool() {
filters.push(Box::new(move |(_, v)| {
matches!(
v.get_update_transform(),
ShellVariableUpdateTransform::Capitalize
) == value
}));
}
if let Some(value) = self.lowercase_value_on_assignment.to_bool() {
filters.push(Box::new(move |(_, v)| {
matches!(
v.get_update_transform(),
ShellVariableUpdateTransform::Lowercase
) == value
}));
}
if let Some(value) = self.make_nameref.to_bool() {
filters.push(Box::new(move |(_, v)| v.is_treated_as_nameref() == value));
}
if let Some(value) = self.make_readonly.to_bool() {
filters.push(Box::new(move |(_, v)| v.is_readonly() == value));
}
if let Some(value) = self.make_readonly.to_bool() {
filters.push(Box::new(move |(_, v)| v.is_trace_enabled() == value));
}
if let Some(value) = self.uppercase_value_on_assignment.to_bool() {
filters.push(Box::new(move |(_, v)| {
matches!(
v.get_update_transform(),
ShellVariableUpdateTransform::Uppercase
) == value
}));
}
if let Some(value) = self.make_exported.to_bool() {
filters.push(Box::new(move |(_, v)| v.is_exported() == value));
}
let iter_policy = if matches!(verb, DeclareVerb::Local) {
EnvironmentLookup::OnlyInCurrentLocal
} else {
EnvironmentLookup::Anywhere
};
// Iterate through an ordered list of all matching declarations tracked in the
// environment.
for (name, variable) in context
.shell
.env()
.iter_using_policy(iter_policy)
.filter(|pair| filters.iter().all(|f| f(*pair)))
.sorted_by_key(|v| v.0)
{
if self.print {
let mut cs = variable.attribute_flags(context.shell);
if cs.is_empty() {
cs.push('-');
}
let separator_str = if matches!(variable.value(), ShellValue::Unset(_)) {
""
} else {
"="
};
writeln!(
context.stdout(),
"declare -{cs} {name}{separator_str}{}",
variable
.value()
.format(variables::FormatStyle::DeclarePrint, context.shell)?
)?;
} else {
writeln!(
context.stdout(),
"{name}={}",
variable
.value()
.format(variables::FormatStyle::Basic, context.shell)?
)?;
}
}
Ok(())
}
fn display_matching_functions(
&self,
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
) -> Result<(), brush_core::Error> {
for (name, registration) in context.shell.funcs().iter().sorted_by_key(|v| v.0) {
if self.function_names_only {
writeln!(context.stdout(), "declare -f {name}")?;
} else {
writeln!(context.stdout(), "{}", registration.definition())?;
}
}
Ok(())
}
#[expect(clippy::unnecessary_wraps)]
const fn apply_attributes_before_update(
&self,
var: &mut ShellVariable,
) -> Result<(), brush_core::Error> {
if let Some(value) = self.make_integer.to_bool() {
if value {
var.treat_as_integer();
} else {
var.unset_treat_as_integer();
}
}
if let Some(value) = self.capitalize_value_on_assignment.to_bool() {
if value {
var.set_update_transform(ShellVariableUpdateTransform::Capitalize);
} else if matches!(
var.get_update_transform(),
ShellVariableUpdateTransform::Capitalize
) {
var.set_update_transform(ShellVariableUpdateTransform::None);
}
}
if let Some(value) = self.lowercase_value_on_assignment.to_bool() {
if value {
var.set_update_transform(ShellVariableUpdateTransform::Lowercase);
} else if matches!(
var.get_update_transform(),
ShellVariableUpdateTransform::Lowercase
) {
var.set_update_transform(ShellVariableUpdateTransform::None);
}
}
if let Some(value) = self.make_nameref.to_bool() {
if value {
var.treat_as_nameref();
} else {
var.unset_treat_as_nameref();
}
}
if let Some(value) = self.make_traced.to_bool() {
if value {
var.enable_trace();
} else {
var.disable_trace();
}
}
if let Some(value) = self.uppercase_value_on_assignment.to_bool() {
if value {
var.set_update_transform(ShellVariableUpdateTransform::Uppercase);
} else if matches!(
var.get_update_transform(),
ShellVariableUpdateTransform::Uppercase
) {
var.set_update_transform(ShellVariableUpdateTransform::None);
}
}
if let Some(value) = self.make_exported.to_bool() {
if value {
var.export();
} else {
var.unexport();
}
}
Ok(())
}
fn apply_attributes_after_update(
&self,
var: &mut ShellVariable,
verb: DeclareVerb,
) -> Result<(), brush_core::Error> {
if matches!(verb, DeclareVerb::Readonly) {
var.set_readonly();
} else if let Some(value) = self.make_readonly.to_bool() {
if value {
var.set_readonly();
} else {
var.unset_readonly()?;
}
}
Ok(())
}
}
+101
View File
@@ -0,0 +1,101 @@
use clap::Parser;
use std::io::Write;
use brush_core::{ExecutionResult, builtins};
#[derive(Debug, thiserror::Error)]
pub(crate) enum DirError {
/// Directory stack is empty.
#[error("directory stack is empty")]
DirStackEmpty,
/// A shell error occurred.
#[error(transparent)]
ShellError(#[from] brush_core::Error),
}
impl From<&DirError> for brush_core::ExecutionExitCode {
fn from(value: &DirError) -> Self {
match value {
DirError::DirStackEmpty => Self::GeneralError,
DirError::ShellError(e) => e.into(),
}
}
}
impl brush_core::BuiltinError for DirError {}
/// Manage the current directory stack.
#[derive(Default, Parser)]
pub(crate) struct DirsCommand {
/// Clear the directory stack.
#[arg(short = 'c')]
clear: bool,
/// Don't tilde-shorten paths.
#[arg(short = 'l')]
tilde_long: bool,
/// Print one directory per line instead of all on one line.
#[arg(short = 'p')]
print_one_per_line: bool,
/// Print one directory per line with its index.
#[arg(short = 'v')]
print_one_per_line_with_index: bool,
//
// TODO(dirs): implement +N and -N
}
impl builtins::Command for DirsCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
if self.clear {
context.shell.directory_stack_mut().clear();
} else {
let dirs = vec![context.shell.working_dir()]
.into_iter()
.chain(
context
.shell
.directory_stack()
.iter()
.rev()
.map(|p| p.as_path()),
)
.collect::<Vec<_>>();
let one_per_line = self.print_one_per_line || self.print_one_per_line_with_index;
for (i, dir) in dirs.iter().enumerate() {
if !one_per_line && i > 0 {
write!(context.stdout(), " ")?;
}
if self.print_one_per_line_with_index {
write!(context.stdout(), "{i:2} ")?;
}
let mut dir_str = dir.to_string_lossy().to_string();
if !self.tilde_long {
dir_str = context.shell.tilde_shorten(dir_str);
}
write!(context.stdout(), "{dir_str}")?;
if one_per_line || i == dirs.len() - 1 {
writeln!(context.stdout())?;
}
}
return Ok(ExecutionResult::success());
}
Ok(ExecutionResult::success())
}
}
+34
View File
@@ -0,0 +1,34 @@
use std::path::Path;
use brush_core::builtins;
use clap::Parser;
/// Evaluate the provided script in the current shell environment.
#[derive(Parser)]
pub(crate) struct DotCommand {
/// Path to the script to evaluate.
script_path: String,
/// Any arguments to be passed as positional parameters to the script.
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
script_args: Vec<String>,
}
impl builtins::Command for DotCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
// TODO(dot): Handle trap inheritance.
context
.shell
.source_script(
Path::new(&self.script_path),
self.script_args.iter(),
&context.params,
)
.await
}
}
+81
View File
@@ -0,0 +1,81 @@
use clap::Parser;
use std::io::Write;
use brush_core::{ExecutionResult, builtins, escape};
/// Echo text to standard output.
#[derive(Parser)]
#[clap(disable_help_flag = true, disable_version_flag = true)]
pub(crate) struct EchoCommand {
/// Suppress the trailing newline from the output.
#[arg(short = 'n')]
no_trailing_newline: bool,
/// Interpret backslash escapes in the provided text.
#[arg(short = 'e')]
interpret_backslash_escapes: bool,
/// Do not interpret backslash escapes in the provided text.
#[arg(short = 'E')]
no_interpret_backslash_escapes: bool,
/// Tokens to echo to standard output.
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
}
impl builtins::Command for EchoCommand {
type Error = brush_core::Error;
/// Override the default [`builtins::Command::new`] function to handle clap's limitation related
/// to `--`. See [`builtins::parse_known`] for more information
/// TODO(echo): we can safely remove this after the issue is resolved
fn new<I>(args: I) -> Result<Self, clap::Error>
where
I: IntoIterator<Item = String>,
{
let (mut this, rest_args) = brush_core::builtins::try_parse_known::<Self>(args)?;
if let Some(args) = rest_args {
this.args.extend(args);
}
Ok(this)
}
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
let mut trailing_newline = !self.no_trailing_newline;
let mut s;
if self.interpret_backslash_escapes {
s = String::new();
for (i, arg) in self.args.iter().enumerate() {
if i > 0 {
s.push(' ');
}
let (expanded_arg, keep_going) = escape::expand_backslash_escapes(
arg.as_str(),
escape::EscapeExpansionMode::EchoBuiltin,
)?;
s.push_str(&String::from_utf8_lossy(expanded_arg.as_slice()));
if !keep_going {
trailing_newline = false;
break;
}
}
} else {
s = self.args.join(" ");
}
if trailing_newline {
s.push('\n');
}
write!(context.stdout(), "{s}")?;
context.stdout().flush()?;
Ok(ExecutionResult::success())
}
}
+96
View File
@@ -0,0 +1,96 @@
use brush_core::ExecutionResult;
use clap::Parser;
use itertools::Itertools;
use std::io::Write;
use brush_core::builtins;
use brush_core::error;
/// Enable, disable, or display built-in commands.
#[derive(Parser)]
pub(crate) struct EnableCommand {
/// Print a list of built-in commands.
#[arg(short = 'a')]
print_list: bool,
/// Disables the specified built-in commands.
#[arg(short = 'n')]
disable: bool,
/// Print a list of built-in commands with reusable output.
#[arg(short = 'p')]
print_reusably: bool,
/// Only operate on special built-in commands.
#[arg(short = 's')]
special_only: bool,
/// Path to a shared object from which built-in commands will be loaded.
#[arg(short = 'f', value_name = "PATH")]
shared_object_path: Option<String>,
/// Remove the built-in commands loaded from the indicated object path.
#[arg(short = 'd')]
remove_loaded_builtin: bool,
/// Names of built-in commands to operate on.
names: Vec<String>,
}
impl builtins::Command for EnableCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<ExecutionResult, Self::Error> {
let mut result = ExecutionResult::success();
if self.shared_object_path.is_some() {
return error::unimp("enable -f");
}
if self.remove_loaded_builtin {
return error::unimp("enable -d");
}
if !self.names.is_empty() {
for name in &self.names {
if let Some(builtin) = context.shell.builtin_mut(name) {
builtin.disabled = self.disable;
} else {
writeln!(context.stderr(), "{name}: not a shell builtin")?;
result = ExecutionResult::general_error();
}
}
} else {
let builtins: Vec<_> = context
.shell
.builtins()
.iter()
.sorted_by_key(|(name, _reg)| *name)
.collect();
for (builtin_name, builtin) in builtins {
if self.disable {
if !builtin.disabled {
continue;
}
} else if self.print_list {
if builtin.disabled {
continue;
}
}
if self.special_only && !builtin.special_builtin {
continue;
}
let prefix = if builtin.disabled { "-n " } else { "" };
writeln!(context.stdout(), "enable {prefix}{builtin_name}")?;
}
}
Ok(result)
}
}
+42
View File
@@ -0,0 +1,42 @@
use brush_core::{ExecutionResult, builtins};
use clap::Parser;
/// Evaluate the given string as script.
#[derive(Parser)]
pub(crate) struct EvalCommand {
/// The script to evaluate.
#[clap(allow_hyphen_values = true)]
args: Vec<String>,
}
impl builtins::Command for EvalCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
if !self.args.is_empty() {
let args_concatenated = self.args.join(" ");
tracing::debug!("Applying eval to: {:?}", args_concatenated);
// Our new source context is relative to the current position because we are only
// providing the raw string being eval'd.
// TODO(source-info): Provide the location of the specific tokens that make up
// `self.args`.
let source_info = context.shell.call_stack().current_pos_as_source_info();
// Return the direct result of running the string; we intentionally
// pass through the result and honor its requested control flow. eval
// executes in the current environment, so all control flow (return,
// exit, break, continue) should propagate.
context
.shell
.run_string(args_concatenated, &source_info, &context.params)
.await
} else {
Ok(ExecutionResult::success())
}
}
}
+83
View File
@@ -0,0 +1,83 @@
use clap::Parser;
use std::{borrow::Cow, os::unix::process::CommandExt};
use brush_core::{ErrorKind, ExecutionExitCode, ExecutionResult, builtins, commands};
/// Exec the provided command.
#[derive(Parser)]
pub(crate) struct ExecCommand {
/// Pass given name as zeroth argument to command.
#[arg(short = 'a', value_name = "NAME")]
name_for_argv0: Option<String>,
/// Exec command with an empty environment.
#[arg(short = 'c')]
empty_environment: bool,
/// Exec command as a login shell.
#[arg(short = 'l')]
exec_as_login: bool,
/// Command and args.
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
}
impl builtins::Command for ExecCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<ExecutionResult, Self::Error> {
if self.args.is_empty() {
// When no arguments are present, then there's nothing for us to execute -- but we need
// to ensure that any redirections setup for this builtin get applied to the calling
// shell instance.
#[allow(clippy::needless_collect)]
let fds: Vec<_> = context.iter_fds().collect();
context.shell.replace_open_files(fds.into_iter());
return Ok(ExecutionResult::success());
}
// If we know we're already running in a subshell, then `exec`ing is actually
// unsafe, since it would also replace the *parent* shell instance. We instead
// delegate to the `command` builtin to perform the execution, with an expectation
// of returning.
if context.shell.is_subshell() {
if self.empty_environment || self.exec_as_login || self.name_for_argv0.is_some() {
return brush_core::error::unimp("exec with options in subshell not yet supported");
}
let cmd_cmd = crate::command::CommandCommand {
command_and_args: self.args.clone(),
..Default::default()
};
return cmd_cmd.execute(context).await;
}
let mut argv0 = Cow::Borrowed(self.name_for_argv0.as_ref().unwrap_or(&self.args[0]));
if self.exec_as_login {
argv0 = Cow::Owned(std::format!("-{argv0}"));
}
let mut cmd = commands::compose_std_command(
&context,
&self.args[0],
argv0.as_str(),
&self.args[1..],
self.empty_environment,
)?;
let exec_error = cmd.exec();
if exec_error.kind() == std::io::ErrorKind::NotFound {
Ok(ExecutionExitCode::NotFound.into())
} else {
Err(ErrorKind::from(exec_error).into())
}
}
}
+32
View File
@@ -0,0 +1,32 @@
use clap::Parser;
use brush_core::{ExecutionControlFlow, ExecutionResult, builtins};
/// Exit the shell.
#[derive(Parser)]
pub(crate) struct ExitCommand {
/// The exit code to return.
#[arg(allow_hyphen_values = true)]
code: Option<i64>,
}
impl builtins::Command for ExitCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
#[expect(clippy::cast_sign_loss)]
let code_8bit = if let Some(code_32bit) = &self.code {
(code_32bit & 0xFF) as u8
} else {
context.shell.last_exit_status()
};
let mut result = ExecutionResult::new(code_8bit);
result.next_control_flow = ExecutionControlFlow::ExitShell;
Ok(result)
}
}
+158
View File
@@ -0,0 +1,158 @@
use clap::Parser;
use itertools::Itertools;
use std::io::Write;
use brush_core::{
ExecutionExitCode, ExecutionResult, builtins,
env::{EnvironmentLookup, EnvironmentScope},
parser::ast,
variables,
};
/// Add or update exported shell variables.
#[derive(Parser)]
pub(crate) struct ExportCommand {
/// Names are treated as function names.
#[arg(short = 'f')]
names_are_functions: bool,
/// Un-export the names.
#[arg(short = 'n')]
unexport: bool,
/// Display all exported names.
#[arg(short = 'p')]
display_exported_names: bool,
//
// Declarations
//
// N.B. These are skipped by clap, but filled in by the BuiltinDeclarationCommand trait.
#[clap(skip)]
declarations: Vec<brush_core::CommandArg>,
}
impl builtins::DeclarationCommand for ExportCommand {
fn set_declarations(&mut self, declarations: Vec<brush_core::CommandArg>) {
self.declarations = declarations;
}
}
impl builtins::Command for ExportCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
mut context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
if self.declarations.is_empty() {
display_all_exported_vars(&context)?;
return Ok(ExecutionResult::success());
}
let mut result = ExecutionResult::success();
for decl in &self.declarations {
let current_result = self.process_decl(&mut context, decl)?;
if !current_result.is_success() {
result = current_result;
}
}
Ok(result)
}
}
impl ExportCommand {
fn process_decl(
&self,
context: &mut brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
decl: &brush_core::CommandArg,
) -> Result<ExecutionResult, brush_core::Error> {
match decl {
brush_core::CommandArg::String(s) => {
// See if this is supposed to be a function name.
if self.names_are_functions {
// Try to find the function already present; if we find it, then mark it
// exported.
if let Some(func) = context.shell.func_mut(s) {
if self.unexport {
func.unexport();
} else {
func.export();
}
} else {
writeln!(context.stderr(), "{s}: not a function")?;
return Ok(ExecutionExitCode::InvalidUsage.into());
}
}
// Try to find the variable already present; if we find it, then mark it
// exported.
else if let Some((_, variable)) = context.shell.env_mut().get_mut(s) {
if self.unexport {
variable.unexport();
} else {
variable.export();
}
}
}
brush_core::CommandArg::Assignment(assignment) => {
let name = match &assignment.name {
ast::AssignmentName::VariableName(name) => name,
ast::AssignmentName::ArrayElementName(_, _) => {
writeln!(context.stderr(), "not a valid variable name")?;
return Ok(ExecutionExitCode::InvalidUsage.into());
}
};
let value = match &assignment.value {
ast::AssignmentValue::Scalar(s) => {
variables::ShellValueLiteral::Scalar(s.flatten())
}
ast::AssignmentValue::Array(a) => {
variables::ShellValueLiteral::Array(variables::ArrayLiteral(
a.iter()
.map(|(k, v)| (k.as_ref().map(|k| k.flatten()), v.flatten()))
.collect(),
))
}
};
// Update the variable with the provided value and then mark it exported.
context.shell.env_mut().update_or_add(
name,
value,
|var| {
if self.unexport {
var.unexport();
} else {
var.export();
}
Ok(())
},
EnvironmentLookup::Anywhere,
EnvironmentScope::Global,
)?;
}
}
Ok(ExecutionResult::success())
}
}
fn display_all_exported_vars(
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
) -> Result<(), brush_core::Error> {
// Enumerate variables, sorted by key.
for (name, variable) in context.shell.env().iter().sorted_by_key(|v| v.0) {
if variable.is_exported() {
let value = variable.value().try_get_cow_str(context.shell);
if let Some(value) = value {
writeln!(context.stdout(), "declare -x {name}=\"{value}\"")?;
} else {
writeln!(context.stdout(), "declare -x {name}")?;
}
}
}
Ok(())
}
+229
View File
@@ -0,0 +1,229 @@
use std::collections::HashMap;
#[allow(clippy::wildcard_imports)]
use super::*;
#[allow(unused_imports, reason = "not all builtins are used in all configs")]
use brush_core::builtins::{self, builtin, decl_builtin, raw_arg_builtin, simple_builtin};
/// Identifies well-known sets of builtins.
#[derive(Clone, Copy, Eq, PartialEq)]
pub enum BuiltinSet {
/// Identifies builtins appropriate for POSIX `sh` compatibility.
ShMode,
/// Identifies builtins appropriate for a more full-featured `bash`-compatible shell.
BashMode,
}
/// Returns the default set of built-in commands.
///
/// # Arguments
///
/// * `set` - The set of built-ins to return.
#[allow(clippy::too_many_lines)]
pub fn default_builtins<SE: brush_core::ShellExtensions>(
set: BuiltinSet,
) -> HashMap<String, builtins::Registration<SE>> {
let mut m = HashMap::<String, builtins::Registration<SE>>::new();
//
// POSIX special builtins
//
// N.B. There seems to be some inconsistency as to whether 'times'
// should be a special built-in.
//
#[cfg(feature = "builtin.break")]
m.insert(
"break".into(),
builtin::<break_::BreakCommand, SE>().special(),
);
#[cfg(feature = "builtin.colon")]
m.insert(
":".into(),
simple_builtin::<colon::ColonCommand, SE>().special(),
);
#[cfg(feature = "builtin.continue")]
m.insert(
"continue".into(),
builtin::<continue_::ContinueCommand, SE>().special(),
);
#[cfg(feature = "builtin.dot")]
m.insert(".".into(), builtin::<dot::DotCommand, SE>().special());
#[cfg(feature = "builtin.eval")]
m.insert("eval".into(), builtin::<eval::EvalCommand, SE>().special());
#[cfg(all(feature = "builtin.exec", unix))]
m.insert("exec".into(), builtin::<exec::ExecCommand, SE>().special());
#[cfg(feature = "builtin.exit")]
m.insert("exit".into(), builtin::<exit::ExitCommand, SE>().special());
#[cfg(feature = "builtin.export")]
m.insert(
"export".into(),
decl_builtin::<export::ExportCommand, SE>().special(),
);
#[cfg(feature = "builtin.return")]
m.insert(
"return".into(),
builtin::<return_::ReturnCommand, SE>().special(),
);
#[cfg(feature = "builtin.set")]
m.insert("set".into(), builtin::<set::SetCommand, SE>().special());
#[cfg(feature = "builtin.shift")]
m.insert(
"shift".into(),
builtin::<shift::ShiftCommand, SE>().special(),
);
#[cfg(feature = "builtin.trap")]
m.insert("trap".into(), builtin::<trap::TrapCommand, SE>().special());
#[cfg(feature = "builtin.unset")]
m.insert(
"unset".into(),
builtin::<unset::UnsetCommand, SE>().special(),
);
#[cfg(feature = "builtin.declare")]
m.insert(
"readonly".into(),
decl_builtin::<declare::DeclareCommand, SE>().special(),
);
#[cfg(feature = "builtin.times")]
m.insert(
"times".into(),
builtin::<times::TimesCommand, SE>().special(),
);
//
// Non-special builtins
//
#[cfg(feature = "builtin.alias")]
m.insert("alias".into(), builtin::<alias::AliasCommand, SE>()); // TODO(alias): should be exec_declaration_builtin
#[cfg(feature = "builtin.bg")]
m.insert("bg".into(), builtin::<bg::BgCommand, SE>());
#[cfg(feature = "builtin.cd")]
m.insert("cd".into(), builtin::<cd::CdCommand, SE>());
#[cfg(feature = "builtin.command")]
m.insert("command".into(), builtin::<command::CommandCommand, SE>());
#[cfg(feature = "builtin.false")]
m.insert("false".into(), simple_builtin::<false_::FalseCommand, SE>());
#[cfg(feature = "builtin.fg")]
m.insert("fg".into(), builtin::<fg::FgCommand, SE>());
#[cfg(feature = "builtin.getopts")]
m.insert("getopts".into(), builtin::<getopts::GetOptsCommand, SE>());
#[cfg(feature = "builtin.hash")]
m.insert("hash".into(), builtin::<hash::HashCommand, SE>());
#[cfg(feature = "builtin.help")]
m.insert("help".into(), builtin::<help::HelpCommand, SE>());
#[cfg(feature = "builtin.jobs")]
m.insert("jobs".into(), builtin::<jobs::JobsCommand, SE>());
#[cfg(all(feature = "builtin.kill", unix))]
m.insert("kill".into(), builtin::<kill::KillCommand, SE>());
#[cfg(feature = "builtin.declare")]
m.insert(
"local".into(),
decl_builtin::<declare::DeclareCommand, SE>(),
);
#[cfg(feature = "builtin.pwd")]
m.insert("pwd".into(), builtin::<pwd::PwdCommand, SE>());
#[cfg(feature = "builtin.read")]
m.insert("read".into(), builtin::<read::ReadCommand, SE>());
#[cfg(feature = "builtin.true")]
m.insert("true".into(), simple_builtin::<true_::TrueCommand, SE>());
#[cfg(feature = "builtin.type")]
m.insert("type".into(), builtin::<type_::TypeCommand, SE>());
#[cfg(all(feature = "builtin.ulimit", unix))]
m.insert("ulimit".into(), builtin::<ulimit::ULimitCommand, SE>());
#[cfg(all(feature = "builtin.umask", unix))]
m.insert("umask".into(), builtin::<umask::UmaskCommand, SE>());
#[cfg(feature = "builtin.unalias")]
m.insert("unalias".into(), builtin::<unalias::UnaliasCommand, SE>());
#[cfg(feature = "builtin.wait")]
m.insert("wait".into(), builtin::<wait::WaitCommand, SE>());
#[cfg(feature = "builtin.fc")]
m.insert("fc".into(), builtin::<fc::FcCommand, SE>());
if matches!(set, BuiltinSet::BashMode) {
#[cfg(feature = "builtin.builtin")]
m.insert(
"builtin".into(),
raw_arg_builtin::<builtin_::BuiltinCommand, SE>(),
);
#[cfg(feature = "builtin.declare")]
m.insert(
"declare".into(),
decl_builtin::<declare::DeclareCommand, SE>(),
);
#[cfg(feature = "builtin.echo")]
m.insert("echo".into(), builtin::<echo::EchoCommand, SE>());
#[cfg(feature = "builtin.enable")]
m.insert("enable".into(), builtin::<enable::EnableCommand, SE>());
#[cfg(feature = "builtin.let")]
m.insert("let".into(), builtin::<let_::LetCommand, SE>());
#[cfg(feature = "builtin.mapfile")]
m.insert("mapfile".into(), builtin::<mapfile::MapFileCommand, SE>());
#[cfg(feature = "builtin.mapfile")]
m.insert("readarray".into(), builtin::<mapfile::MapFileCommand, SE>());
#[cfg(all(feature = "builtin.printf", any(unix, windows)))]
m.insert("printf".into(), builtin::<printf::PrintfCommand, SE>());
#[cfg(feature = "builtin.shopt")]
m.insert("shopt".into(), builtin::<shopt::ShoptCommand, SE>());
#[cfg(feature = "builtin.dot")]
m.insert("source".into(), builtin::<dot::DotCommand, SE>().special());
#[cfg(all(feature = "builtin.suspend", unix))]
m.insert("suspend".into(), builtin::<suspend::SuspendCommand, SE>());
#[cfg(feature = "builtin.test")]
m.insert("test".into(), builtin::<test::TestCommand, SE>());
#[cfg(feature = "builtin.test")]
m.insert("[".into(), builtin::<test::TestCommand, SE>());
#[cfg(feature = "builtin.declare")]
m.insert(
"typeset".into(),
decl_builtin::<declare::DeclareCommand, SE>(),
);
// Completion builtins
#[cfg(feature = "builtin.complete")]
m.insert(
"complete".into(),
builtin::<complete::CompleteCommand, SE>(),
);
#[cfg(feature = "builtin.compgen")]
m.insert("compgen".into(), builtin::<complete::CompGenCommand, SE>());
#[cfg(feature = "builtin.compopt")]
m.insert("compopt".into(), builtin::<complete::CompOptCommand, SE>());
// Dir stack builtins
#[cfg(feature = "builtin.dirs")]
m.insert("dirs".into(), builtin::<dirs::DirsCommand, SE>());
#[cfg(feature = "builtin.popd")]
m.insert("popd".into(), builtin::<popd::PopdCommand, SE>());
#[cfg(feature = "builtin.pushd")]
m.insert("pushd".into(), builtin::<pushd::PushdCommand, SE>());
// Input configuration builtins
#[cfg(feature = "builtin.bind")]
m.insert("bind".into(), builtin::<bind::BindCommand, SE>());
// History
#[cfg(feature = "builtin.history")]
m.insert("history".into(), builtin::<history::HistoryCommand, SE>());
#[cfg(feature = "builtin.caller")]
m.insert("caller".into(), builtin::<caller::CallerCommand, SE>());
// TODO(disown): implement disown builtin
m.insert(
"disown".into(),
builtin::<unimp::UnimplementedCommand, SE>(),
);
// TODO(logout): implement logout builtin
m.insert(
"logout".into(),
builtin::<unimp::UnimplementedCommand, SE>(),
);
}
m
}
+26
View File
@@ -0,0 +1,26 @@
use brush_core::{ExecutionResult, builtins, error};
/// Return exit code 1.
pub(crate) struct FalseCommand {}
impl builtins::SimpleCommand for FalseCommand {
fn get_content(
_name: &str,
content_type: builtins::ContentType,
_options: &builtins::ContentOptions,
) -> Result<String, brush_core::Error> {
match content_type {
builtins::ContentType::DetailedHelp => Ok("Returns a failure exit status.".into()),
builtins::ContentType::ShortUsage => Ok("false".into()),
builtins::ContentType::ShortDescription => Ok("false - fail".into()),
builtins::ContentType::ManPage => error::unimp("man page not yet implemented"),
}
}
fn execute<SE: brush_core::ShellExtensions, I: Iterator<Item = S>, S: AsRef<str>>(
_context: brush_core::ExecutionContext<'_, SE>,
_args: I,
) -> Result<ExecutionResult, brush_core::Error> {
Ok(ExecutionResult::general_error())
}
}
+301
View File
@@ -0,0 +1,301 @@
use brush_core::{ExecutionResult, builtins, error, history};
use clap::Parser;
use std::io::Write;
/// Process command history list.
#[derive(Parser)]
pub(crate) struct FcCommand {
/// List commands instead of editing them.
#[arg(short = 'l')]
list: bool,
/// Suppress line numbers when listing.
#[arg(short = 'n', requires = "list")]
no_line_numbers: bool,
/// Reverse the order of commands.
#[arg(short = 'r')]
reverse: bool,
/// Re-execute command after substitution (old=new format).
#[arg(short = 's')]
substitute: bool,
/// Editor to use (only relevant when not listing or substituting).
#[arg(short = 'e', value_name = "ENAME")]
editor: Option<String>,
/// First command in range (number or string prefix).
#[arg(value_name = "FIRST", allow_hyphen_values = true)]
first: Option<String>,
/// Last command in range (number or string prefix).
#[arg(value_name = "LAST", allow_hyphen_values = true)]
last: Option<String>,
}
impl builtins::Command for FcCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<ExecutionResult, Self::Error> {
if self.substitute {
return self.do_execute(context).await;
}
if self.list {
return self.do_list(&context);
}
error::unimp("fc editor mode is not yet implemented")
}
}
impl FcCommand {
fn do_list(
&self,
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
) -> Result<ExecutionResult, brush_core::Error> {
let history = context
.shell
.history()
.ok_or_else(|| brush_core::Error::from(brush_core::ErrorKind::HistoryNotEnabled))?;
let (first_idx, last_idx, reverse) = self.resolve_range(history)?;
// Determine the order of iteration
let indices: Vec<usize> = if reverse {
(first_idx..=last_idx).rev().collect()
} else {
(first_idx..=last_idx).collect()
};
for idx in indices {
if let Some(item) = history.get(idx) {
if self.no_line_numbers {
// With -n, bash still outputs a tab before the command
writeln!(context.stdout(), "\t {}", item.command_line)?;
} else {
// Match bash's fc format: number, tab, command
writeln!(context.stdout(), "{}\t {}", idx + 1, item.command_line)?;
}
}
}
Ok(ExecutionResult::success())
}
async fn do_execute(
&self,
context: brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
) -> Result<ExecutionResult, brush_core::Error> {
let history = context
.shell
.history()
.ok_or_else(|| brush_core::Error::from(brush_core::ErrorKind::HistoryNotEnabled))?;
// Parse the first argument for pattern=replacement
let (pattern, replacement) = self
.first
.as_ref()
.and_then(|s| s.split_once('='))
.map_or((None, None), |(p, r)| (Some(p), Some(r)));
// Determine which command to re-execute
let cmd_spec = if pattern.is_some() {
// If we have a pattern, the command spec is in 'last' if present
self.last.as_deref()
} else {
// Otherwise, it's in 'first'
self.first.as_deref()
};
// Find the command
let cmd_line = if let Some(spec) = cmd_spec {
Self::find_command_by_specifier(history, spec)?
} else {
// No spec means use the previous command (excluding the fc command itself)
let effective_count = effective_history_count(history);
history
.get(effective_count.saturating_sub(1))
.map(|item| item.command_line.clone())
.ok_or_else(|| brush_core::Error::from(error::ErrorKind::HistoryItemNotFound))?
};
// Apply substitution if present
let final_cmd = if let (Some(pat), Some(rep)) = (pattern, replacement) {
cmd_line.replace(pat, rep)
} else {
cmd_line
};
// Echo the command to stderr.
writeln!(context.stderr(), "{final_cmd}")?;
// Remove the fc command from history before executing the substituted command
// This matches bash behavior where the fc command is replaced by the executed command
let history_mut = context
.shell
.history_mut()
.ok_or_else(|| brush_core::Error::from(brush_core::ErrorKind::HistoryNotEnabled))?;
history_mut.remove_nth_item(history_mut.count().saturating_sub(1));
let source_info = brush_core::SourceInfo::from("(history)");
// Execute the command
let result = context
.shell
.run_string(final_cmd.clone(), &source_info, &context.params)
.await?;
// Add the executed command to history.
context.shell.add_to_history(&final_cmd)?;
Ok(result)
}
fn resolve_range(
&self,
history: &history::History,
) -> Result<(usize, usize, bool), brush_core::Error> {
let effective_count = effective_history_count(history);
let max_idx = effective_count.saturating_sub(1);
// Resolve first index
let first_idx = self
.first
.as_ref()
.map(|s| Self::resolve_position(history, s))
.transpose()?
.unwrap_or_else(|| {
if self.list {
effective_count.saturating_sub(16) // Default for listing: -16
} else {
max_idx // Default for editing: previous command
}
});
// Resolve last index (default depends on mode and first_idx)
let default_last = if self.list { max_idx } else { first_idx };
let last_idx = self
.last
.as_ref()
.map(|s| Self::resolve_position(history, s))
.transpose()?
.unwrap_or(default_last);
// If first > last, swap them and indicate reversal
let (first_idx, last_idx, force_reverse) = if first_idx > last_idx {
(last_idx, first_idx, true)
} else {
(first_idx, last_idx, false)
};
// Clamp both indices to valid range
Ok((
first_idx.min(max_idx),
last_idx.min(max_idx),
force_reverse || self.reverse,
))
}
/// Resolves a position specifier (number or string prefix) to a history index.
/// NOTE: The returned index may still be out of range if the history is empty.
///
/// # Arguments
///
/// * `history` - The history to resolve against.
/// * `spec` - The position specifier (number or string prefix).
fn resolve_position(
history: &history::History,
spec: &str,
) -> Result<usize, brush_core::Error> {
// Try to parse it as a number. If it's not parseable, then we need to assume
// it's a string prefix we need to search for.
let Ok(num) = spec.parse::<i64>() else {
// Not a number, treat as string prefix
return Self::find_command_by_prefix(history, spec);
};
let effective_count = effective_history_count(history);
#[expect(clippy::cast_sign_loss)]
#[expect(clippy::cast_possible_truncation)]
let result = match num.cmp(&0) {
std::cmp::Ordering::Equal => {
// 0 means -1 for listing (relative to effective count)
effective_count.saturating_sub(1)
}
std::cmp::Ordering::Greater => {
// Positive: 1-based index
let idx = (num - 1) as usize;
if idx < effective_count {
idx
} else {
// Out of range - use 0 (first item)
0
}
}
std::cmp::Ordering::Less => {
// Negative: offset from end (relative to effective count)
let offset = (-num) as usize;
effective_count.saturating_sub(offset)
}
};
Ok(result)
}
/// Finds the command matching the given specifier (number or string prefix). Returns
/// the command line. Returns an error if no such command can be found in the history.
///
/// # Arguments
///
/// * `history` - The history to search.
/// * `spec` - The position spec
fn find_command_by_specifier(
history: &history::History,
spec: &str,
) -> Result<String, brush_core::Error> {
let idx = Self::resolve_position(history, spec)?;
history
.get(idx)
.map(|item| item.command_line.clone())
.ok_or_else(|| brush_core::Error::from(error::ErrorKind::HistoryItemNotFound))
}
/// Finds the most recent command starting with the given prefix. Returns
/// the index of the command in the history. Returns an error if no such
/// command can be found in the history.
///
/// # Arguments
///
/// * `history` - The history to search.
/// * `prefix` - The command prefix to search for.
fn find_command_by_prefix(
history: &history::History,
prefix: &str,
) -> Result<usize, brush_core::Error> {
// Search backwards for a command starting with the prefix (excluding fc command itself)
let effective_count = effective_history_count(history);
for idx in (0..effective_count).rev() {
if let Some(item) = history.get(idx) {
if item.command_line.starts_with(prefix) {
return Ok(idx);
}
}
}
Err(brush_core::Error::from(
error::ErrorKind::HistoryItemNotFound,
))
}
}
/// Returns the effective history count (excluding the fc command itself).
fn effective_history_count(history: &history::History) -> usize {
history.count().saturating_sub(1)
}
+73
View File
@@ -0,0 +1,73 @@
use clap::Parser;
use std::io::Write;
use brush_core::{ExecutionResult, builtins, jobs, sys};
/// Move a specified job to the foreground.
#[derive(Parser)]
pub(crate) struct FgCommand {
/// Job spec for the job to move to the foreground; if not specified, the current job is moved.
job_spec: Option<String>,
}
impl builtins::Command for FgCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
let mut stderr = context.stdout();
// Read interactive option before taking mutable borrow on jobs
let is_interactive = context.shell.options().interactive;
if let Some(job_spec) = &self.job_spec {
if let Some(job) = context.shell.jobs_mut().resolve_job_spec(job_spec) {
job.move_to_foreground()?;
writeln!(stderr, "{}", job.command_line)?;
let result = job.wait().await?;
if is_interactive {
sys::terminal::move_self_to_foreground()?;
}
if matches!(job.state, jobs::JobState::Stopped) {
// N.B. We use the '\r' to overwrite any ^Z output.
let formatted = job.to_string();
writeln!(context.stderr(), "\r{formatted}")?;
}
Ok(result)
} else {
writeln!(
stderr,
"{}: {}: no such job",
job_spec, context.command_name
)?;
Ok(ExecutionResult::general_error())
}
} else {
if let Some(job) = context.shell.jobs_mut().current_job_mut() {
job.move_to_foreground()?;
writeln!(stderr, "{}", job.command_line)?;
let result = job.wait().await?;
if is_interactive {
sys::terminal::move_self_to_foreground()?;
}
if matches!(job.state, jobs::JobState::Stopped) {
// N.B. We use the '\r' to overwrite any ^Z output.
let formatted = job.to_string();
writeln!(context.stderr(), "\r{formatted}")?;
}
Ok(result)
} else {
writeln!(stderr, "{}: no current job", context.command_name)?;
Ok(ExecutionResult::general_error())
}
}
}
}
+402
View File
@@ -0,0 +1,402 @@
use std::{collections::HashMap, io::Write};
use clap::Parser;
use brush_core::{ExecutionResult, builtins, env, variables};
/// Parse command options.
#[derive(Parser)]
pub(crate) struct GetOptsCommand {
/// Specification for options
options_string: String,
/// Name of variable to receive next option
variable_name: String,
/// Arguments to parse
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
}
// We track cross-call state in special variables. They are hidden from enumeration
// (e.g. `set`, `declare`) so they don't leak into scripts' environments.
const VAR_GETOPTS_NEXT_CHAR_INDEX: &str = "__GETOPTS_NEXT_CHAR";
const VAR_GETOPTS_LAST_OPTIND: &str = "__GETOPTS_LAST_OPTIND";
const DEFAULT_NEXT_CHAR_INDEX: usize = 1;
/// The result of processing one option from the argument list.
struct GetOptsResult {
/// The value to assign to the target variable (the option char, `?`, or `:`).
variable_value: String,
/// The value for OPTARG, if any (option's argument or, on error, the offending char).
optarg: Option<String>,
/// The new value for OPTIND after this call.
optind: usize,
/// Exit code: success (0) if an option was found, general error (1) when done.
exit_code: ExecutionResult,
}
/// Parsed representation of the optstring (e.g., `":a:bc"`).
struct OptionSpec {
/// Maps each option character to whether it requires an argument.
defs: HashMap<char, bool>,
/// True when the optstring has a leading `:`, suppressing error messages
/// and reporting errors via `?`/`:` in the variable and OPTARG instead.
silent_errors: bool,
}
/// Parses the optstring into an [`OptionSpec`]. A leading `:` enables silent error
/// mode. Each letter defines an option; a `:` immediately after a letter means it
/// takes an argument. Duplicate letters are ignored (first definition wins).
fn parse_option_spec(spec: &str) -> OptionSpec {
let mut defs = HashMap::<char, bool>::new();
let mut silent_errors = false;
let mut last_char = None;
for c in spec.chars() {
if c == ':' {
if let Some(last_char) = last_char {
defs.insert(last_char, true);
} else if defs.is_empty() {
// First character is ':' — request silent error reporting.
silent_errors = true;
}
continue;
}
if let std::collections::hash_map::Entry::Vacant(e) = defs.entry(c) {
e.insert(false);
last_char = Some(c);
} else {
// Duplicate option char; first definition wins.
// Clear last_char so a trailing colon doesn't modify the first definition.
last_char = None;
}
}
OptionSpec {
defs,
silent_errors,
}
}
impl builtins::Command for GetOptsCommand {
type Error = brush_core::Error;
/// Override the default [`builtins::Command::new`] function to handle clap's limitation related
/// to `--`. See [`builtins::parse_known`] for more information
/// TODO(command): we can safely remove this after the issue is resolved
fn new<I>(args: I) -> Result<Self, clap::Error>
where
I: IntoIterator<Item = String>,
{
let (mut this, rest_args) = brush_core::builtins::try_parse_known::<Self>(args)?;
if let Some(args) = rest_args {
this.args.extend(args);
}
Ok(this)
}
async fn execute<SE: brush_core::ShellExtensions>(
&self,
mut context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
// Validate the target variable name.
if !env::valid_variable_name(&self.variable_name) {
writeln!(
context.stderr(),
"{}: `{}': not a valid identifier",
context.command_name,
self.variable_name
)?;
return Ok(ExecutionResult::new(1));
}
let spec = parse_option_spec(&self.options_string);
// If unset or non-numeric, assume OPTIND is 1.
let next_index_signed = context
.shell
.env_str("OPTIND")
.and_then(|s| brush_core::int_utils::parse::<i32>(s.as_ref(), 10).ok())
.unwrap_or(1);
// Detect external OPTIND modifications (e.g., `OPTIND=1` to restart
// parsing). If the current OPTIND differs from what we last set, clear
// the internal char index so we don't resume mid-arg.
let last_optind = context
.shell
.env_str(VAR_GETOPTS_LAST_OPTIND)
.and_then(|s| s.parse::<i32>().ok());
if last_optind != Some(next_index_signed) {
context.shell.env_mut().unset(VAR_GETOPTS_NEXT_CHAR_INDEX)?;
}
#[allow(clippy::cast_sign_loss)] // .max(1) guarantees positive
let next_index = next_index_signed.max(1) as usize;
// Select the arguments to parse. If none were explicitly provided, we
// default to using the shell's current positional parameters.
// Clone positional params to avoid borrowing context immutably while we
// also need it mutably in parse_next_option.
let owned_args;
let args_to_parse = if !self.args.is_empty() {
&self.args
} else {
owned_args = context.shell.current_shell_args().to_vec();
&owned_args
};
let result = parse_next_option(&mut context, &spec, args_to_parse, next_index)?;
update_variables(&mut context, &self.variable_name, result)
}
}
/// Extracts the next option from `args_to_parse` starting at 1-based position
/// `next_index`. Handles combined flags (e.g., `-abc`), option arguments (both
/// `-pVALUE` and `-p VALUE` forms), and error reporting for unknown options or
/// missing arguments. Tracks position within combined flags via the
/// `__GETOPTS_NEXT_CHAR` shell variable.
fn parse_next_option<SE: brush_core::ShellExtensions>(
context: &mut brush_core::ExecutionContext<'_, SE>,
spec: &OptionSpec,
args_to_parse: &[String],
mut next_index: usize,
) -> Result<GetOptsResult, brush_core::Error> {
// See if there are any args left to parse.
if next_index > args_to_parse.len() {
return Ok(GetOptsResult {
variable_value: String::from("?"),
optarg: None,
// Normalize OPTIND to one past the last argument.
optind: args_to_parse.len() + 1,
exit_code: ExecutionResult::general_error(),
});
}
let arg = args_to_parse[next_index - 1].as_str();
// See if this is an option.
if !arg.starts_with('-') || arg == "--" || arg == "-" {
// Not an option. If it was "--", skip past it.
return Ok(GetOptsResult {
variable_value: String::from("?"),
optarg: None,
optind: if arg == "--" {
next_index + 1
} else {
next_index
},
exit_code: ExecutionResult::general_error(),
});
}
// Figure out how far into this option we are.
let mut next_char_index = context
.shell
.env_str(VAR_GETOPTS_NEXT_CHAR_INDEX)
.map_or(DEFAULT_NEXT_CHAR_INDEX, |s| {
s.parse().unwrap_or(DEFAULT_NEXT_CHAR_INDEX)
});
// Find the char. If the index is stale (exceeds this arg's length),
// reset to the default index, mirroring bash behavior.
let mut c = arg.chars().nth(next_char_index);
if c.is_none() {
next_char_index = DEFAULT_NEXT_CHAR_INDEX;
c = arg.chars().nth(next_char_index);
}
let Some(c) = c else {
// Arg is too short even at default index.
return Ok(GetOptsResult {
variable_value: String::from("?"),
optarg: None,
optind: next_index,
exit_code: ExecutionResult::general_error(),
});
};
let arg_char_count = arg.chars().count();
let mut is_last_char_in_option = next_char_index == arg_char_count - 1;
let mut variable_value;
let optarg;
// Look up the char in the option spec.
if let Some(takes_arg) = spec.defs.get(&c) {
variable_value = String::from(c);
if *takes_arg {
(variable_value, optarg, is_last_char_in_option, next_index) = resolve_option_argument(
context,
spec,
c,
arg,
args_to_parse,
next_char_index,
is_last_char_in_option,
next_index,
)?;
} else {
optarg = None;
}
} else {
(variable_value, optarg) = report_unknown_option(context, spec, c)?;
}
let optind = if is_last_char_in_option {
// We're done with this argument, so unset the internal char index variable
// and request an update to OPTIND.
context.shell.env_mut().unset(VAR_GETOPTS_NEXT_CHAR_INDEX)?;
next_index + 1
} else {
// We have more to go in this argument, so update the internal char index
// and request that OPTIND not be updated.
context.shell.env_mut().update_or_add(
VAR_GETOPTS_NEXT_CHAR_INDEX,
variables::ShellValueLiteral::Scalar((next_char_index + 1).to_string()),
|v| {
v.hide_from_enumeration();
Ok(())
},
brush_core::env::EnvironmentLookup::Anywhere,
brush_core::env::EnvironmentScope::Global,
)?;
next_index
};
Ok(GetOptsResult {
variable_value,
optarg,
optind,
exit_code: ExecutionResult::success(),
})
}
/// Resolves the argument for an option that takes a value. Returns the updated
/// `(variable_value, optarg, is_last_char, next_index)` tuple.
#[allow(clippy::too_many_arguments)]
fn resolve_option_argument<SE: brush_core::ShellExtensions>(
context: &brush_core::ExecutionContext<'_, SE>,
spec: &OptionSpec,
c: char,
arg: &str,
args_to_parse: &[String],
next_char_index: usize,
is_last_char_in_option: bool,
mut next_index: usize,
) -> Result<(String, Option<String>, bool, usize), brush_core::Error> {
// If this is the last character in the token, the argument value comes from
// the next token. Otherwise, the remainder of the current token is the value.
if is_last_char_in_option {
if next_index >= args_to_parse.len() {
// Missing required argument.
let (variable_value, optarg) = if spec.silent_errors {
(String::from(":"), Some(String::from(c)))
} else {
if is_opterr_enabled(context) {
writeln!(
context.stderr(),
"getopts: option requires an argument -- {c}"
)?;
}
(String::from("?"), None)
};
Ok((variable_value, optarg, true, next_index))
} else {
next_index += 1;
Ok((
String::from(c),
Some(args_to_parse[next_index - 1].clone()),
true,
next_index,
))
}
} else {
let optarg: String = arg.chars().skip(next_char_index + 1).collect();
Ok((String::from(c), Some(optarg), true, next_index))
}
}
/// Handles an unknown option character, reporting an error if appropriate.
fn report_unknown_option<SE: brush_core::ShellExtensions>(
context: &brush_core::ExecutionContext<'_, SE>,
spec: &OptionSpec,
c: char,
) -> Result<(String, Option<String>), brush_core::Error> {
if !spec.silent_errors && is_opterr_enabled(context) {
writeln!(context.stderr(), "getopts: illegal option -- {c}")?;
}
let optarg = if spec.silent_errors {
Some(String::from(c))
} else {
None
};
Ok((String::from("?"), optarg))
}
/// Writes the parsing result back into shell variables: the target variable,
/// OPTARG, OPTIND, and the internal `__GETOPTS_LAST_OPTIND` tracker.
fn update_variables<SE: brush_core::ShellExtensions>(
context: &mut brush_core::ExecutionContext<'_, SE>,
variable_name: &str,
result: GetOptsResult,
) -> Result<ExecutionResult, brush_core::Error> {
// Update variable value.
context.shell.env_mut().update_or_add(
variable_name,
variables::ShellValueLiteral::Scalar(result.variable_value),
|_| Ok(()),
brush_core::env::EnvironmentLookup::Anywhere,
brush_core::env::EnvironmentScope::Global,
)?;
// Update OPTARG
if let Some(optarg) = result.optarg {
context.shell.env_mut().update_or_add(
"OPTARG",
variables::ShellValueLiteral::Scalar(optarg),
|_| Ok(()),
brush_core::env::EnvironmentLookup::Anywhere,
brush_core::env::EnvironmentScope::Global,
)?;
} else {
context.shell.env_mut().unset("OPTARG")?;
}
// Update OPTIND and record it so we can detect external modifications.
let optind_str = result.optind.to_string();
context.shell.env_mut().update_or_add(
"OPTIND",
variables::ShellValueLiteral::Scalar(optind_str.clone()),
|_| Ok(()),
brush_core::env::EnvironmentLookup::Anywhere,
brush_core::env::EnvironmentScope::Global,
)?;
context.shell.env_mut().update_or_add(
VAR_GETOPTS_LAST_OPTIND,
variables::ShellValueLiteral::Scalar(optind_str),
|v| {
v.hide_from_enumeration();
Ok(())
},
brush_core::env::EnvironmentLookup::Anywhere,
brush_core::env::EnvironmentScope::Global,
)?;
Ok(result.exit_code)
}
/// Returns whether OPTERR is enabled (i.e., getopts should print error messages).
/// OPTERR defaults to 1; any nonzero value means errors are enabled.
fn is_opterr_enabled<SE: brush_core::ShellExtensions>(
context: &brush_core::ExecutionContext<'_, SE>,
) -> bool {
context
.shell
.env_str("OPTERR")
.is_none_or(|s| s.parse::<i64>().unwrap_or(1) != 0)
}
+109
View File
@@ -0,0 +1,109 @@
use clap::Parser;
use std::{io::Write, path::PathBuf};
use brush_core::{ExecutionResult, builtins};
#[derive(Parser)]
pub(crate) struct HashCommand {
/// Remove entries associated with the given names.
#[arg(short = 'd')]
remove: bool,
/// Display paths in a format usable for input.
#[arg(short = 'l')]
display_as_usable_input: bool,
/// The path to associate with the names.
#[arg(short = 'p', value_name = "PATH")]
path_to_use: Option<PathBuf>,
/// Remove all entries.
#[arg(short = 'r')]
remove_all: bool,
/// Display the paths associated with the names.
#[arg(short = 't')]
display_paths: bool,
/// Names to process.
names: Vec<String>,
}
impl builtins::Command for HashCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
let mut result = ExecutionResult::success();
if self.remove_all {
context.shell.program_location_cache_mut().reset();
} else if self.remove {
for name in &self.names {
if !context.shell.program_location_cache_mut().unset(name) {
writeln!(context.stderr(), "{name}: not found")?;
result = ExecutionResult::general_error();
}
}
} else if self.display_paths {
for name in &self.names {
if let Some(path) = context.shell.program_location_cache().get(name) {
if self.display_as_usable_input {
writeln!(
context.stdout(),
"builtin hash -p {} {name}",
path.to_string_lossy()
)?;
} else {
let mut prefix = String::new();
if self.names.len() > 1 {
prefix.push_str(name.as_str());
prefix.push('\t');
}
writeln!(
context.stdout(),
"{prefix}{}",
path.to_string_lossy().as_ref()
)?;
}
} else {
writeln!(context.stderr(), "{name}: not found")?;
result = ExecutionResult::general_error();
}
}
} else if let Some(path) = &self.path_to_use {
for name in &self.names {
context
.shell
.program_location_cache_mut()
.set(name, path.clone());
}
} else {
for name in &self.names {
// Remove from the cache if already hashed.
let _ = context.shell.program_location_cache_mut().unset(name);
// Names with slashes are accepted silently
if name.contains('/') {
continue;
}
// Hash the path
if context
.shell
.find_first_executable_in_path_using_cache(name)
.is_none()
{
writeln!(context.stderr(), "{name}: not found")?;
result = ExecutionResult::general_error();
}
}
}
Ok(result)
}
}
+148
View File
@@ -0,0 +1,148 @@
use brush_core::{ExecutionResult, builtins};
use clap::Parser;
use itertools::Itertools;
use std::io::Write;
/// Display command help.
#[derive(Parser)]
pub(crate) struct HelpCommand {
/// Display a short description for the commands.
#[arg(short = 'd')]
short_description: bool,
/// Display a man-style page of documentation for the commands.
#[arg(short = 'm')]
man_page_style: bool,
/// Display a short usage summary for the commands.
#[arg(short = 's')]
short_usage: bool,
/// Patterns of topics to display help for.
topic_patterns: Vec<String>,
}
impl builtins::Command for HelpCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
if self.topic_patterns.is_empty() {
Self::display_general_help(&context)?;
} else {
for topic_pattern in &self.topic_patterns {
self.display_help_for_topic_pattern(&context, topic_pattern)?;
}
}
Ok(ExecutionResult::success())
}
}
impl HelpCommand {
fn display_general_help(
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
) -> Result<(), brush_core::Error> {
const COLUMN_COUNT: usize = 3;
if let Some(display_str) = context.shell.product_display_str() {
writeln!(context.stdout(), "{display_str}\n")?;
}
writeln!(
context.stdout(),
"The following commands are implemented as shell built-ins:"
)?;
let builtins = get_builtins_sorted_by_name(context);
let items_per_column = builtins.len().div_ceil(COLUMN_COUNT);
for i in 0..items_per_column {
for j in 0..COLUMN_COUNT {
if let Some((name, builtin)) = builtins.get(i + j * items_per_column) {
let prefix = if builtin.disabled { "*" } else { " " };
write!(context.stdout(), " {prefix}{name:<20}")?; // adjust 20 to the desired
// column width
}
}
writeln!(context.stdout())?;
}
Ok(())
}
fn display_help_for_topic_pattern(
&self,
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
topic_pattern: &str,
) -> Result<(), brush_core::Error> {
let pattern = brush_core::patterns::Pattern::from(topic_pattern)
.set_extended_globbing(context.shell.options().extended_globbing)
.set_case_insensitive(context.shell.options().case_insensitive_pathname_expansion);
let mut found_count = 0;
for (builtin_name, builtin_registration) in get_builtins_sorted_by_name(context) {
if pattern.exactly_matches(builtin_name.as_str())? {
self.display_help_for_builtin(
context,
builtin_name.as_str(),
builtin_registration,
)?;
found_count += 1;
}
}
if found_count == 0 {
writeln!(context.stderr(), "No help topics match '{topic_pattern}'")?;
}
Ok(())
}
fn display_help_for_builtin<SE: brush_core::ShellExtensions>(
&self,
context: &brush_core::ExecutionContext<'_, SE>,
name: &str,
registration: &builtins::Registration<SE>,
) -> Result<(), brush_core::Error> {
let content_type = if self.short_description {
builtins::ContentType::ShortDescription
} else if self.man_page_style {
builtins::ContentType::ManPage
} else if self.short_usage {
builtins::ContentType::ShortUsage
} else {
builtins::ContentType::DetailedHelp
};
let Some(mut stdout) = context.try_fd(brush_core::openfiles::OpenFiles::STDOUT_FD) else {
// If there's no stdout, nothing to do.
return Ok(());
};
// For now, we assume colorized output if stdout is a terminal.
let options = builtins::ContentOptions {
colorized: stdout.is_terminal(),
};
let content = (registration.content_func)(name, content_type, &options)?;
write!(stdout, "{content}")?;
stdout.flush()?;
Ok(())
}
}
fn get_builtins_sorted_by_name<'a, SE: brush_core::ShellExtensions>(
context: &'a brush_core::ExecutionContext<'_, SE>,
) -> Vec<(&'a String, &'a builtins::Registration<SE>)> {
context
.shell
.builtins()
.iter()
.sorted_by_key(|(name, _)| *name)
.collect()
}
+246
View File
@@ -0,0 +1,246 @@
use brush_core::{ExecutionExitCode, ExecutionResult, builtins, error, history};
use clap::Parser;
use std::{io::Write, path::PathBuf};
/// Query or manipulate the shell's command history.
// TODO(history): Evaluate which of the options conflict with each other.
#[derive(Parser)]
#[expect(clippy::option_option)]
pub(crate) struct HistoryCommand {
/// Clears all history.
#[arg(short = 'c')]
clear_history: bool,
/// Deletes the history entry at the given offset. Positive offsets are relative to the
/// beginning of the history, while negative offsets are relative to the end of the history.
#[arg(short = 'd', value_name = "OFFSET")]
delete_offset: Option<i64>,
/// Appends the history from the current session to the history file.
#[arg(short = 'a', group = "anrw", num_args = 0..=1, value_name = "HIST_FILE")]
append_session_to_file: Option<Option<String>>,
/// Appends any remaining history from the history file to the current session.
#[arg(short = 'n', group = "anrw", num_args = 0..=1, value_name = "HIST_FILE")]
append_rest_of_file_to_session: Option<Option<String>>,
/// Appends the history from the history file to the current session.
#[arg(short = 'r', group = "anrw", num_args = 0..=1, value_name = "HIST_FILE")]
append_file_to_session: Option<Option<String>>,
/// Replaces the history file with the current session history.
#[arg(short = 'w', group = "anrw", num_args = 0..=1, value_name = "HIST_FILE")]
write_session_to_file: Option<Option<String>>,
/// History-expands positional arguments and displays them.
#[arg(short = 'p', num_args = 0.., value_name = "ARG")]
expand_args: Option<Vec<String>>,
/// Appends positional arguments as an entry in the current session.
#[arg(short = 's', num_args = 0.., value_name = "ARG")]
append_args_to_session: Option<Vec<String>>,
/// Arguments.
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
}
struct HistoryConfig {
default_history_file_path: Option<PathBuf>,
time_format: Option<String>,
}
impl builtins::Command for HistoryCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<ExecutionResult, Self::Error> {
// Retrieve the shell's history config while we still can.
let config = HistoryConfig {
default_history_file_path: context.shell.history_file_path(),
time_format: context.shell.history_time_format(),
};
let stdout = context.stdout();
let stderr = context.stderr();
if let Some(history) = context.shell.history_mut() {
self.execute_with_history(history, config, stdout, stderr)
} else {
Err(brush_core::ErrorKind::HistoryNotEnabled.into())
}
}
}
impl HistoryCommand {
#[expect(clippy::cast_possible_wrap)]
#[expect(clippy::cast_possible_truncation)]
#[expect(clippy::cast_sign_loss)]
fn execute_with_history(
&self,
history: &mut history::History,
config: HistoryConfig,
stdout: impl Write,
mut stderr: impl Write,
) -> Result<ExecutionResult, brush_core::Error> {
if self.clear_history {
history.clear()?;
}
if let Some(offset) = self.delete_offset {
if offset == 0 {
writeln!(stderr, "cannot delete history item at offset 0")?;
return Ok(ExecutionExitCode::InvalidUsage.into());
}
if offset > 0 {
// Convert to 0-based index.
let index = (offset - 1) as usize;
if !history.remove_nth_item(index) {
writeln!(stderr, "index past end of history")?;
return Ok(ExecutionExitCode::InvalidUsage.into());
}
} else {
let count = history.count() as i64;
let index = count + offset;
if index < 0 {
writeln!(stderr, "index before beginning of history")?;
return Ok(ExecutionExitCode::InvalidUsage.into());
}
let _ = history.remove_nth_item(index as usize);
}
return Ok(ExecutionResult::success());
}
if let Some(append_option) = &self.append_session_to_file {
if let Some(file_path) = get_effective_history_file_path(
config.default_history_file_path,
append_option.as_ref(),
) {
history.flush(
file_path,
true, /* append? */
true, /* unsaved items only */
config.time_format.is_some(), /* write timestamps? */
)?;
}
return Ok(ExecutionResult::success());
}
if self.append_rest_of_file_to_session.is_some() {
return error::unimp("history -n is not yet implemented");
}
if self.append_file_to_session.is_some() {
return error::unimp("history -r is not yet implemented");
}
if let Some(write_option) = &self.write_session_to_file {
if let Some(file_path) = get_effective_history_file_path(
config.default_history_file_path,
write_option.as_ref(),
) {
history.flush(
file_path,
false, /* append? */
false, /* unsaved items only? */
config.time_format.is_some(), /* write timestamps? */
)?;
}
return Ok(ExecutionResult::success());
}
if self.expand_args.is_some() {
return error::unimp("history -p is not yet implemented");
}
if let Some(args) = &self.append_args_to_session {
history.add(history::Item::new(args.join(" ")))?;
return Ok(ExecutionResult::success());
}
let max_entries: Option<usize> = if let Some(arg) = self.args.first() {
Some(brush_core::int_utils::parse(arg.as_str(), 10)?)
} else {
None
};
display_history(history, &config, max_entries, stdout, stderr)?;
Ok(ExecutionResult::success())
}
}
fn display_history(
history: &history::History,
config: &HistoryConfig,
max_entries: Option<usize>,
mut stdout: impl Write,
_stderr: impl Write,
) -> Result<(), brush_core::Error> {
let item_count = history.count();
let skip_count = item_count - max_entries.unwrap_or(item_count);
for (i, item) in history.iter().skip(skip_count).enumerate() {
let mut formatted_timestamp = String::new();
if let Some(timestamp) = item.timestamp {
let local_timestamp = timestamp.with_timezone(&chrono::Local);
if let Some(time_format) = &config.time_format {
let fmt_items = chrono::format::StrftimeItems::new(time_format);
formatted_timestamp = local_timestamp.format_with_items(fmt_items).to_string();
}
}
// Output format is something like:
// 1 echo hello world
std::writeln!(
stdout,
"{:>5} {formatted_timestamp}{}",
skip_count + i + 1,
item.command_line
)?;
}
Ok(())
}
fn get_effective_history_file_path(
default_history_file_path: Option<PathBuf>,
option: Option<&String>,
) -> Option<PathBuf> {
option.map_or_else(
|| default_history_file_path,
|file_path| Some(PathBuf::from(file_path)),
)
}
#[cfg(test)]
mod tests {
use super::*;
use anyhow::Result;
use pretty_assertions::{assert_eq, assert_matches};
#[test]
fn test_parse_dash_a() -> Result<()> {
let cmd = HistoryCommand::try_parse_from(["history", "5"])?;
assert_matches!(cmd.append_session_to_file, None);
let cmd = HistoryCommand::try_parse_from(["history", "-a"])?;
assert_matches!(cmd.append_session_to_file, Some(None));
let cmd = HistoryCommand::try_parse_from(["history", "-a", "token"])?;
assert_eq!(
cmd.append_session_to_file,
Some(Some(String::from("token")))
);
Ok(())
}
}
+83
View File
@@ -0,0 +1,83 @@
use clap::Parser;
use std::io::Write;
use brush_core::{ExecutionResult, builtins, error, jobs};
/// Manage jobs.
#[derive(Parser)]
pub(crate) struct JobsCommand {
/// Also show process IDs.
#[arg(short = 'l')]
also_show_pids: bool,
/// List only jobs that have changed status since the last notification.
#[arg(short = 'n')]
list_changed_only: bool,
/// Show only process IDs.
#[arg(short = 'p')]
show_pids_only: bool,
/// Show only running jobs.
#[arg(short = 'r')]
running_jobs_only: bool,
/// Show only stopped jobs.
#[arg(short = 's')]
stopped_jobs_only: bool,
/// Job specs to list.
// TODO(jobs): Add -x option
job_specs: Vec<String>,
}
impl builtins::Command for JobsCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
if self.also_show_pids {
return error::unimp("jobs -l");
}
if self.list_changed_only {
return error::unimp("jobs -n");
}
if self.job_specs.is_empty() {
for job in &context.shell.jobs().jobs {
self.display_job(&context, job)?;
}
} else {
return error::unimp("jobs with job specs");
}
Ok(ExecutionResult::success())
}
}
impl JobsCommand {
fn display_job(
&self,
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
job: &jobs::Job,
) -> Result<(), brush_core::Error> {
if self.running_jobs_only && !matches!(job.state, jobs::JobState::Running) {
return Ok(());
}
if self.stopped_jobs_only && !matches!(job.state, jobs::JobState::Stopped) {
return Ok(());
}
if self.show_pids_only {
if let Some(pid) = job.representative_pid() {
writeln!(context.stdout(), "{pid}")?;
}
} else {
writeln!(context.stdout(), "{job}")?;
}
Ok(())
}
}
+178
View File
@@ -0,0 +1,178 @@
use clap::Parser;
use std::io::Write;
use brush_core::traps::TrapSignal;
use brush_core::{ExecutionExitCode, ExecutionResult, builtins, sys};
/// Signal a job or process.
#[derive(Parser)]
pub(crate) struct KillCommand {
/// Name of the signal to send.
#[arg(short = 's', value_name = "SIG_NAME")]
signal_name: Option<String>,
/// Number of the signal to send.
#[arg(short = 'n', value_name = "SIG_NUM")]
signal_number: Option<usize>,
//
// TODO(kill): implement -sigspec syntax
/// List known signal names.
#[arg(short = 'l', short_alias = 'L')]
list_signals: bool,
// Interpretation of these depends on whether -l is present.
#[arg(allow_hyphen_values = true)]
args: Vec<String>,
}
impl builtins::Command for KillCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
// Default signal is SIGKILL.
let mut trap_signal = TrapSignal::Signal(nix::sys::signal::Signal::SIGKILL);
// Try parsing the signal name (if specified).
if let Some(signal_name) = &self.signal_name {
if let Ok(parsed_trap_signal) = TrapSignal::try_from(signal_name.as_str()) {
trap_signal = parsed_trap_signal;
} else {
writeln!(
context.stderr(),
"{}: invalid signal name: {}",
context.command_name,
signal_name
)?;
return Ok(ExecutionExitCode::InvalidUsage.into());
}
}
// Try parsing the signal number (if specified).
if let Some(signal_number) = &self.signal_number {
#[expect(clippy::cast_possible_truncation)]
#[expect(clippy::cast_possible_wrap)]
if let Ok(parsed_trap_signal) = TrapSignal::try_from(*signal_number as i32) {
trap_signal = parsed_trap_signal;
} else {
writeln!(
context.stderr(),
"{}: invalid signal number: {}",
context.command_name,
signal_number
)?;
return Ok(ExecutionExitCode::InvalidUsage.into());
}
}
// Look through the remaining args for a pid/job spec or a -sigspec style option.
let mut pid_or_job_spec = None;
for arg in &self.args {
// See if this is -sigspec syntax.
if let Some(possible_sigspec) = arg.strip_prefix("-") {
// See if this is -sigspec syntax.
if let Ok(parsed_trap_signal) = TrapSignal::try_from(possible_sigspec) {
trap_signal = parsed_trap_signal;
} else {
writeln!(
context.stderr(),
"{}: invalid signal name",
context.command_name
)?;
return Ok(ExecutionExitCode::InvalidUsage.into());
}
} else if pid_or_job_spec.is_none() {
pid_or_job_spec = Some(arg);
} else {
writeln!(
context.stderr(),
"{}: too many jobs or processes specified",
context.command_name
)?;
return Ok(ExecutionExitCode::InvalidUsage.into());
}
}
if self.list_signals {
return print_signals(&context, self.args.as_ref());
} else {
let Some(pid_or_job_spec) = pid_or_job_spec else {
writeln!(context.stderr(), "{}: invalid usage", context.command_name)?;
return Ok(ExecutionExitCode::InvalidUsage.into());
};
if pid_or_job_spec.starts_with('%') {
// It's a job spec.
if let Some(job) = context.shell.jobs_mut().resolve_job_spec(pid_or_job_spec) {
job.kill(trap_signal)?;
} else {
writeln!(
context.stderr(),
"{}: {}: no such job",
context.command_name,
pid_or_job_spec
)?;
return Ok(ExecutionResult::general_error());
}
} else {
let pid = brush_core::int_utils::parse(pid_or_job_spec.as_str(), 10)?;
// It's a pid.
sys::signal::kill_process(pid, trap_signal)?;
}
}
Ok(ExecutionResult::success())
}
}
fn print_signals(
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
signals: &[String],
) -> Result<ExecutionResult, brush_core::Error> {
let mut exit_code = ExecutionResult::success();
if !signals.is_empty() {
for s in signals {
// If the user gives us a code, we print the name; if they give a name, we print its
// code.
enum PrintSignal {
Name(&'static str),
Num(i32),
}
let signal = if let Ok(n) = s.parse::<i32>() {
// bash compatibility. `SIGHUP` -> `HUP`
TrapSignal::try_from(n).map(|s| {
PrintSignal::Name(s.as_str().strip_prefix("SIG").unwrap_or(s.as_str()))
})
} else {
TrapSignal::try_from(s.as_str()).map(|sig| {
i32::try_from(sig).map_or(PrintSignal::Name(sig.as_str()), PrintSignal::Num)
})
};
match signal {
Ok(PrintSignal::Num(n)) => {
writeln!(context.stdout(), "{n}")?;
}
Ok(PrintSignal::Name(s)) => {
writeln!(context.stdout(), "{s}")?;
}
Err(e) => {
writeln!(context.stderr(), "{e}")?;
exit_code = ExecutionResult::general_error();
}
}
}
} else {
return brush_core::traps::format_signals(
context.stdout(),
TrapSignal::iterator().filter(|s| !matches!(s, TrapSignal::Exit)),
)
.map(|()| ExecutionResult::success());
}
Ok(exit_code)
}
+41
View File
@@ -0,0 +1,41 @@
use clap::Parser;
use std::io::Write;
use brush_core::{ExecutionExitCode, ExecutionResult, arithmetic::Evaluatable, builtins};
/// Evaluate arithmetic expressions.
#[derive(Parser)]
pub(crate) struct LetCommand {
/// Arithmetic expressions to evaluate.
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
exprs: Vec<String>,
}
impl builtins::Command for LetCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
let mut result = ExecutionExitCode::InvalidUsage.into();
if self.exprs.is_empty() {
writeln!(context.stderr(), "missing expression")?;
return Ok(result);
}
for expr in &self.exprs {
let parsed = brush_parser::arithmetic::parse(expr.as_str())?;
let evaluated = parsed.eval(context.shell)?;
if evaluated == 0 {
result = ExecutionResult::general_error();
} else {
result = ExecutionResult::success();
}
}
Ok(result)
}
}
+157
View File
@@ -0,0 +1,157 @@
//! Standard builtins.
#[cfg(feature = "builtin.alias")]
mod alias;
#[cfg(feature = "builtin.bg")]
mod bg;
#[cfg(feature = "builtin.bind")]
mod bind;
#[cfg(feature = "builtin.break")]
mod break_;
#[cfg(feature = "builtin.builtin")]
mod builtin_;
#[cfg(feature = "builtin.caller")]
mod caller;
#[cfg(feature = "builtin.cd")]
mod cd;
#[cfg(feature = "builtin.colon")]
mod colon;
#[cfg(feature = "builtin.command")]
mod command;
#[cfg(any(
feature = "builtin.complete",
feature = "builtin.compgen",
feature = "builtin.compopt"
))]
mod complete;
#[cfg(feature = "builtin.continue")]
mod continue_;
#[cfg(feature = "builtin.declare")]
mod declare;
#[cfg(feature = "builtin.dirs")]
mod dirs;
#[cfg(feature = "builtin.dot")]
mod dot;
#[cfg(feature = "builtin.echo")]
mod echo;
#[cfg(feature = "builtin.enable")]
mod enable;
#[cfg(feature = "builtin.eval")]
mod eval;
#[cfg(all(feature = "builtin.exec", unix))]
mod exec;
#[cfg(feature = "builtin.exit")]
mod exit;
#[cfg(feature = "builtin.export")]
mod export;
#[cfg(feature = "builtin.false")]
mod false_;
#[cfg(feature = "builtin.fc")]
mod fc;
#[cfg(feature = "builtin.fg")]
mod fg;
#[cfg(feature = "builtin.getopts")]
mod getopts;
#[cfg(feature = "builtin.hash")]
mod hash;
#[cfg(feature = "builtin.help")]
mod help;
#[cfg(feature = "builtin.history")]
mod history;
#[cfg(feature = "builtin.jobs")]
mod jobs;
#[cfg(all(feature = "builtin.kill", unix))]
mod kill;
#[cfg(feature = "builtin.let")]
mod let_;
#[cfg(feature = "builtin.mapfile")]
mod mapfile;
#[cfg(feature = "builtin.popd")]
mod popd;
#[cfg(all(feature = "builtin.printf", any(unix, windows)))]
mod printf;
#[cfg(feature = "builtin.pushd")]
mod pushd;
#[cfg(feature = "builtin.pwd")]
mod pwd;
#[cfg(feature = "builtin.read")]
mod read;
#[cfg(feature = "builtin.return")]
mod return_;
#[cfg(feature = "builtin.set")]
mod set;
#[cfg(feature = "builtin.shift")]
mod shift;
#[cfg(feature = "builtin.shopt")]
mod shopt;
#[cfg(all(feature = "builtin.suspend", unix))]
mod suspend;
#[cfg(feature = "builtin.test")]
mod test;
#[cfg(feature = "builtin.times")]
mod times;
#[cfg(feature = "builtin.trap")]
mod trap;
#[cfg(feature = "builtin.true")]
mod true_;
#[cfg(feature = "builtin.type")]
mod type_;
#[cfg(all(feature = "builtin.ulimit", unix))]
mod ulimit;
#[cfg(all(feature = "builtin.umask", unix))]
mod umask;
#[cfg(feature = "builtin.unalias")]
mod unalias;
#[cfg(feature = "builtin.unset")]
mod unset;
#[cfg(feature = "builtin.wait")]
mod wait;
mod builder;
mod factory;
mod unimp;
pub use builder::ShellBuilderExt;
pub use factory::{BuiltinSet, default_builtins};
/// Macro to define a struct that represents a shell built-in flag argument that can be
/// enabled or disabled by specifying an option with a leading '+' or '-' character.
///
/// # Arguments
///
/// - `$struct_name` - The identifier to be used for the struct to define.
/// - `$flag_char` - The character to use as the flag.
/// - `$desc` - The string description of the flag.
#[macro_export]
macro_rules! minus_or_plus_flag_arg {
($struct_name:ident, $flag_char:literal, $desc:literal) => {
#[derive(clap::Parser)]
pub(crate) struct $struct_name {
#[arg(short = $flag_char, name = concat!(stringify!($struct_name), "_enable"), action = clap::ArgAction::SetTrue, help = $desc)]
_enable: bool,
#[arg(long = concat!("+", $flag_char), name = concat!(stringify!($struct_name), "_disable"), action = clap::ArgAction::SetTrue, hide = true)]
_disable: bool,
}
impl From<$struct_name> for Option<bool> {
fn from(value: $struct_name) -> Self {
value.to_bool()
}
}
impl $struct_name {
#[allow(dead_code, reason = "may not be used in all macro instantiations")]
pub const fn is_some(&self) -> bool {
self._enable || self._disable
}
pub const fn to_bool(&self) -> Option<bool> {
match (self._enable, self._disable) {
(true, false) => Some(true),
(false, true) => Some(false),
_ => None,
}
}
}
};
}
+200
View File
@@ -0,0 +1,200 @@
use std::io::{Read, Write};
use clap::Parser;
use brush_core::{ErrorKind, ExecutionExitCode, ExecutionResult, builtins, env, error, variables};
/// Read lines from standard input into an indexed array variable.
#[derive(Parser)]
pub(crate) struct MapFileCommand {
/// Delimiter to use (defaults to newline).
#[arg(short = 'd')]
delimiter: Option<String>,
/// Maximum number of entries to read (0 means no limit).
#[arg(short = 'n', default_value_t = 0)]
max_count: i64,
/// Index into array at which to start assignment.
#[arg(short = 'O', allow_hyphen_values = true)]
origin: Option<i64>,
/// Number of initial entries to skip.
#[arg(short = 's', default_value_t = 0, value_parser = clap::value_parser!(i64).range(0..))]
skip_count: i64,
/// Whether or not to remove the delimiter from each read line.
#[arg(short = 't')]
remove_delimiter: bool,
/// File descriptor to read from (defaults to stdin).
#[arg(short = 'u', default_value_t = 0)]
fd: brush_core::ShellFd,
/// Name of function to call for each group of lines.
#[arg(short = 'C')]
callback: Option<String>,
/// Number of lines to pass the callback for each group.
#[arg(short = 'c', default_value_t = 5000, value_parser = clap::value_parser!(i64).range(1..))]
callback_group_size: i64,
/// Name of array to read into.
#[arg(default_value = "MAPFILE")]
array_var_name: String,
}
impl builtins::Command for MapFileCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
if self.callback_group_size != 5000 || self.callback.is_some() {
return error::unimp("mapfile -C/-c is not yet implemented");
}
if let Some(origin) = self.origin {
if origin < 0 {
writeln!(
context.stderr(),
"{}: {origin}: invalid array origin",
context.command_name
)?;
return Ok(ExecutionExitCode::GeneralError.into());
}
}
if let Some((_, var)) = context.shell.env().get(&self.array_var_name) {
if matches!(
var.value(),
variables::ShellValue::AssociativeArray(_)
| variables::ShellValue::Unset(
variables::ShellValueUnsetType::AssociativeArray
)
) {
writeln!(
context.stderr(),
"{}: {}: not an indexed array",
context.command_name,
self.array_var_name
)?;
return Ok(ExecutionExitCode::GeneralError.into());
}
}
let input_file = context
.try_fd(self.fd)
.ok_or_else(|| ErrorKind::BadFileDescriptor(self.fd))?;
// Read!
let results = self.read_entries(input_file)?;
if let Some(origin) = self.origin {
// -O: preserve existing array, assign at offset.
for (elem_idx, (_key, value)) in results.0.into_iter().enumerate() {
// If the user is getting to wraparounds in *bash*, they got bigger problems.
#[allow(clippy::cast_possible_wrap)]
let elem_idx = elem_idx as i64;
context.shell.env_mut().update_or_add_array_element(
&self.array_var_name,
(elem_idx + origin).to_string(),
value,
|_| Ok(()),
env::EnvironmentLookup::Anywhere,
env::EnvironmentScope::Global,
)?;
}
} else {
// No -O: replace the entire variable (clears existing).
context.shell.env_mut().update_or_add(
&self.array_var_name,
variables::ShellValueLiteral::Array(results),
|_| Ok(()),
env::EnvironmentLookup::Anywhere,
env::EnvironmentScope::Global,
)?;
}
Ok(ExecutionResult::success())
}
}
impl MapFileCommand {
fn read_entries(
&self,
mut input_file: brush_core::openfiles::OpenFile,
) -> Result<variables::ArrayLiteral, brush_core::Error> {
let _term_mode = setup_terminal_settings(&input_file)?;
let mut entries = vec![];
let mut read_count = 0;
let max_count = self.max_count.try_into()?;
let delimiter = match &self.delimiter {
Some(d) if d.is_empty() => b'\0',
Some(d) => d.as_bytes().first().copied().unwrap_or(b'\n'),
None => b'\n',
};
let mut buf = [0u8; 1];
while max_count == 0 || entries.len() < max_count {
let mut line = vec![];
let mut saw_delimiter = false;
loop {
match input_file.read(&mut buf) {
Ok(0) => break, // End of input
Ok(1) if buf[0] == b'\x03' => break, // Ctrl+C
Ok(1) if buf[0] == b'\x04' && line.is_empty() => break, // Ctrl+D
Ok(1) => {
let byte = buf[0];
line.push(byte);
if byte == delimiter {
saw_delimiter = true;
break;
}
}
Ok(_) => unreachable!("input can only be 0, 1, or error"),
Err(e) => return Err(e.into()),
}
}
if line.is_empty() && !saw_delimiter {
break;
}
if read_count < self.skip_count {
read_count += 1;
continue;
}
if self.remove_delimiter && line.ends_with(&[delimiter]) {
line.pop();
}
let line_str = String::from_utf8_lossy(&line).to_string();
entries.push((None, line_str));
}
Ok(variables::ArrayLiteral(entries))
}
}
fn setup_terminal_settings(
file: &brush_core::openfiles::OpenFile,
) -> Result<Option<brush_core::terminal::AutoModeGuard>, brush_core::Error> {
let mode = brush_core::terminal::AutoModeGuard::new(file.to_owned()).ok();
if let Some(mode) = &mode {
let config = brush_core::terminal::Settings::builder()
.line_input(false)
.interrupt_signals(false)
.build();
mode.apply_settings(&config)?;
}
Ok(mode)
}
+36
View File
@@ -0,0 +1,36 @@
use clap::Parser;
use brush_core::{ExecutionResult, builtins};
/// Pop a path from the current directory stack.
#[derive(Parser)]
pub(crate) struct PopdCommand {
/// Pop the path without changing the current working directory.
#[clap(short = 'n')]
no_directory_change: bool,
//
// TODO(popd): implement +N and -N
}
impl builtins::Command for PopdCommand {
type Error = crate::dirs::DirError;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
if let Some(popped) = context.shell.directory_stack_mut().pop() {
if !self.no_directory_change {
context.shell.set_working_dir(&popped)?;
}
// Display dirs.
let dirs_cmd = crate::dirs::DirsCommand::default();
dirs_cmd.execute(context).await?;
Ok(ExecutionResult::success())
} else {
Err(crate::dirs::DirError::DirStackEmpty)
}
}
}
+187
View File
@@ -0,0 +1,187 @@
use clap::Parser;
use std::{ffi::OsString, io::Write, ops::ControlFlow};
use uucore::format;
use brush_core::{Error, ErrorKind, ExecutionResult, builtins, escape, expansion};
/// Format a string.
#[derive(Parser)]
#[clap(disable_help_flag = true, disable_version_flag = true)]
pub(crate) struct PrintfCommand {
/// If specified, the output of the command is assigned to this variable.
#[arg(short = 'v')]
output_variable: Option<String>,
/// Format string + arguments to the format string.
#[arg(trailing_var_arg = true, required = true, allow_hyphen_values = true)]
format_and_args: Vec<String>,
}
impl builtins::Command for PrintfCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<ExecutionResult, Self::Error> {
if let Some(variable_name) = &self.output_variable {
// Format to a u8 vector.
let mut result: Vec<u8> = vec![];
format(self.format_and_args.as_slice(), &mut result)?;
// Convert to a string.
let result_str = String::from_utf8(result).map_err(|_| {
brush_core::ErrorKind::PrintfInvalidUsage("invalid UTF-8 output".into())
})?;
// Assign to the selected variable.
expansion::assign_to_named_parameter(
context.shell,
&context.params,
variable_name,
result_str,
)
.await?;
} else {
format(self.format_and_args.as_slice(), context.stdout())?;
context.stdout().flush()?;
}
Ok(ExecutionResult::success())
}
}
fn format(format_and_args: &[String], writer: impl Write) -> Result<(), brush_core::Error> {
match format_and_args {
// Special-case invocation of printf with %q-based format string from bash-completion.
// It has hard-coded expectation of backslash-style escaping instead of quoting.
[fmt, arg] if fmt == "%q" => format_special_case_for_percent_q(None, arg, writer),
[fmt, arg] if fmt == "~%q" => format_special_case_for_percent_q(Some("~"), arg, writer),
// Handle format string with arguments using uucore
[fmt, args @ ..] => format_via_uucore(fmt, args.iter(), writer),
// Handle case with no format string (we shouldn't be able to get here since clap will
// fail parsing when the format string is missing)
[] => Err(ErrorKind::PrintfInvalidUsage("missing operand".into()).into()),
}
}
fn format_special_case_for_percent_q(
prefix: Option<&str>,
arg: &str,
mut writer: impl Write,
) -> Result<(), brush_core::Error> {
let mut result = escape::quote_if_needed(arg, escape::QuoteMode::BackslashEscape).to_string();
if let Some(prefix) = prefix {
result.insert_str(0, prefix);
}
write!(writer, "{result}")?;
Ok(())
}
fn format_via_uucore(
format_string: &str,
args: impl Iterator<Item = impl Into<OsString>>,
mut writer: impl Write,
) -> Result<(), brush_core::Error> {
// Convert string arguments to FormatArgument::Unparsed
let format_args: Vec<_> = args
.map(|s| format::FormatArgument::Unparsed(s.into()))
.collect();
// Parse format string once.
let format_items = parse_format_string(format_string)?;
// Wrap the format arguments.
let mut format_args_wrapper = format::FormatArguments::new(&format_args);
// Keep going until we've exhausted all format arguments. Also make sure to run at least once
// even if there's no format arguments.
while format_args.is_empty() || !format_args_wrapper.is_exhausted() {
// Process all format items, in order. We'll bail when we're told to stop.
for item in &format_items {
let control_flow = item
.write(&mut writer, &mut format_args_wrapper)
.map_err(|e| match e {
// Propagate I/O errors directly so they can be handled appropriately
format::FormatError::IoError(io_err) => Error::from(io_err),
// Wrap other format errors
other => Error::from(ErrorKind::PrintfInvalidUsage(std::format!(
"printf formatting error: {other}"
))),
})?;
if control_flow == ControlFlow::Break(()) {
break;
}
}
// Start next batch if not exhausted
if !format_args_wrapper.is_exhausted() {
format_args_wrapper.start_next_batch();
}
if format_args.is_empty() {
break;
}
}
Ok(())
}
fn parse_format_string(
format_string: &str,
) -> Result<Vec<format::FormatItem<format::EscapedChar>>, brush_core::Error> {
let format_items: Result<Vec<_>, _> =
format::parse_spec_and_escape(format_string.as_bytes()).collect();
// Observe any errors we encountered along the way.
let format_items = format_items
.map_err(|e| ErrorKind::PrintfInvalidUsage(format!("printf parsing error: {e}")))?;
Ok(format_items)
}
#[cfg(test)]
#[expect(clippy::panic_in_result_fn)]
mod tests {
use super::*;
use anyhow::Result;
fn sprintf_via_uucore(
format_string: &str,
args: impl Iterator<Item = impl Into<OsString>>,
) -> Result<String> {
let mut result = vec![];
format_via_uucore(format_string, args, &mut result)?;
Ok(String::from_utf8(result)?)
}
#[test]
fn test_basic_sprintf() -> Result<()> {
assert_eq!(sprintf_via_uucore("%s", std::iter::once(&"xyz"))?, "xyz");
assert_eq!(sprintf_via_uucore(r"%d\n", std::iter::once(&"1"))?, "1\n");
Ok(())
}
#[test]
fn test_sprintf_without_args() -> Result<()> {
let empty: [&str; 0] = [];
assert_eq!(sprintf_via_uucore("xyz", empty.iter())?, "xyz");
assert_eq!(sprintf_via_uucore("%s|", empty.iter())?, "|");
Ok(())
}
#[test]
fn test_sprintf_with_cycles() -> Result<()> {
assert_eq!(sprintf_via_uucore("%s|", ["x", "y"].iter())?, "x|y|");
Ok(())
}
}
+45
View File
@@ -0,0 +1,45 @@
use clap::Parser;
use brush_core::{ExecutionResult, builtins};
/// Push a path onto the current directory stack.
#[derive(Parser)]
pub(crate) struct PushdCommand {
/// Push the path without changing the current working directory.
#[clap(short = 'n')]
no_directory_change: bool,
/// Directory to push on the directory stack.
dir: String,
//
// TODO(pushd): implement +N and -N
}
impl builtins::Command for PushdCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
if self.no_directory_change {
context
.shell
.directory_stack_mut()
.push(std::path::PathBuf::from(&self.dir));
} else {
let prev_working_dir = context.shell.working_dir().to_path_buf();
let dir = std::path::Path::new(&self.dir);
context.shell.set_working_dir(dir)?;
context.shell.directory_stack_mut().push(prev_working_dir);
}
// Display dirs.
let dirs_cmd = crate::dirs::DirsCommand::default();
dirs_cmd.execute(context).await?;
Ok(ExecutionResult::success())
}
}
+40
View File
@@ -0,0 +1,40 @@
use brush_core::{ExecutionResult, builtins};
use clap::Parser;
use std::{borrow::Cow, io::Write, path::Path};
/// Display the current working directory.
#[derive(Parser)]
pub(crate) struct PwdCommand {
/// Print the physical directory without any symlinks.
#[arg(short = 'P', overrides_with = "allow_symlinks")]
physical: bool,
/// Print $PWD if it names the current working directory.
#[arg(short = 'L', overrides_with = "physical")]
allow_symlinks: bool,
}
impl builtins::Command for PwdCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
let mut cwd: Cow<'_, Path> = context.shell.working_dir().into();
let should_canonicalize = self.physical
|| context
.shell
.options()
.do_not_resolve_symlinks_when_changing_dir;
if should_canonicalize {
cwd = cwd.canonicalize()?.into();
}
writeln!(context.stdout(), "{}", cwd.to_string_lossy())?;
Ok(ExecutionResult::success())
}
}
+840
View File
@@ -0,0 +1,840 @@
use clap::Parser;
use itertools::Itertools;
use std::collections::VecDeque;
use std::time::{Duration, Instant};
use brush_core::{ErrorKind, builtins, env, error, variables};
use std::io::{Read, Write};
/// Exit code returned when `read` times out.
/// This is 128 + SIGALRM (14) = 142, matching bash behavior.
const TIMEOUT_EXIT_CODE: u8 = 142;
/// ASCII control character for Ctrl+C (ETX - End of Text).
const CTRL_C: char = '\x03';
/// ASCII control character for Ctrl+D (EOT - End of Transmission).
const CTRL_D: char = '\x04';
/// Backslash character used for escape processing.
const BACKSLASH: char = '\\';
/// Default line delimiter (newline).
const DEFAULT_DELIMITER: char = '\n';
/// NUL character used as delimiter when `-d ''` is specified.
const NUL_DELIMITER: char = '\0';
/// Parse standard input.
#[derive(Parser)]
pub(crate) struct ReadCommand {
/// Optionally, name of an array variable to receive read words
/// of input.
#[clap(short = 'a', value_name = "VAR_NAME")]
array_variable: Option<String>,
/// Optionally, a delimiter to use other than a newline character.
#[clap(short = 'd')]
delimiter: Option<String>,
/// Use readline-like input.
#[clap(short = 'e')]
use_readline: bool,
/// Provide text to use as initial input for readline.
#[clap(short = 'i', value_name = "STR")]
initial_text: Option<String>,
/// Read only the first N characters or until a specified
/// delimiter is reached, whichever happens first.
#[clap(short = 'n', value_name = "COUNT")]
return_after_n_chars: Option<usize>,
/// Read exactly N characters, ignoring any specified delimiter.
#[clap(short = 'N', value_name = "COUNT")]
return_after_n_chars_no_delimiter: Option<usize>,
/// Prompt to display before reading.
#[clap(short = 'p')]
prompt: Option<String>,
/// Read input in raw mode; no escape sequences.
#[clap(short = 'r')]
raw_mode: bool,
/// Do not echo input.
#[clap(short = 's')]
silent: bool,
/// Specify timeout in seconds; fail if the timeout elapses before
/// input is completed.
#[clap(short = 't', value_name = "SECONDS", allow_hyphen_values = true)]
timeout_in_seconds: Option<f64>,
/// File descriptor to read from instead of stdin.
#[clap(short = 'u', name = "FD")]
fd_num_to_read: Option<u8>,
/// Optionally, names of variables to receive read input.
variable_names: Vec<String>,
}
impl builtins::Command for ReadCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
if self.use_readline {
return error::unimp("read -e");
}
if self.initial_text.is_some() {
return error::unimp("read -i");
}
// Validate timeout value if provided.
if let Some(result) = self.validate_timeout(&context)? {
return Ok(result);
}
// Find the input stream to use.
let fd_num = self.fd_num_to_read.map_or(
brush_core::openfiles::OpenFiles::STDIN_FD,
brush_core::ShellFd::from,
);
// Retrieve the file.
let input_stream = context
.try_fd(fd_num)
.ok_or_else(|| ErrorKind::BadFileDescriptor(fd_num))?;
// Retrieve effective value of IFS for splitting.
// We convert to owned String to release the borrow before the mutable borrow
// needed for variable assignment.
let ifs = context.shell.ifs().into_owned();
// Convert timeout to Duration.
let timeout = self.timeout_in_seconds.map(Duration::from_secs_f64);
// Perform the read operation (potentially with timeout).
let read_result = self.read_line(input_stream, context.stderr(), timeout)?;
// Determine whether to skip IFS splitting (for -N option).
let skip_ifs_splitting = self.return_after_n_chars_no_delimiter.is_some();
// Extract the input line and determine exit code based on result.
let (input_line, result) = match &read_result {
ReadResult::Line(line) => (Some(line.clone()), brush_core::ExecutionResult::success()),
ReadResult::Eof(Some(line)) => (
Some(line.clone()),
brush_core::ExecutionResult::general_error(),
),
ReadResult::Eof(None) | ReadResult::Interrupted | ReadResult::InputNotReady => {
(None, brush_core::ExecutionResult::general_error())
}
ReadResult::TimedOut(partial) => (
partial.clone(),
brush_core::ExecutionResult::new(TIMEOUT_EXIT_CODE),
),
ReadResult::InputReady => (None, brush_core::ExecutionResult::success()),
};
// Assign input to variables based on options.
assign_input_to_variables(
context.shell,
input_line.as_deref(),
&ifs,
skip_ifs_splitting,
self.array_variable.as_deref(),
&self.variable_names,
)?;
Ok(result)
}
}
/// Assigns read input to shell variables based on the specified options.
///
/// This handles three modes:
/// - Array mode (`-a`): Split input by IFS and assign to array elements
/// - Named variables: Split input by IFS and assign to each variable, with remainder to last
/// - Default (`REPLY`): Assign entire input line to the `REPLY` variable
fn assign_input_to_variables(
shell: &mut brush_core::Shell<impl brush_core::ShellExtensions>,
input_line: Option<&str>,
ifs: &str,
skip_ifs_splitting: bool,
array_variable: Option<&str>,
variable_names: &[String],
) -> Result<(), brush_core::Error> {
if let Some(array_variable) = array_variable {
let literal_fields = build_array_fields(input_line, ifs, skip_ifs_splitting);
shell.env_mut().update_or_add(
array_variable,
variables::ShellValueLiteral::Array(variables::ArrayLiteral(literal_fields)),
|_| Ok(()),
env::EnvironmentLookup::Anywhere,
env::EnvironmentScope::Global,
)?;
} else if !variable_names.is_empty() {
assign_to_named_variables(shell, input_line, ifs, skip_ifs_splitting, variable_names)?;
} else {
shell.env_mut().update_or_add(
"REPLY",
variables::ShellValueLiteral::Scalar(input_line.unwrap_or_default().to_owned()),
|_| Ok(()),
env::EnvironmentLookup::Anywhere,
env::EnvironmentScope::Global,
)?;
}
Ok(())
}
/// Assigns split fields to named variables.
///
/// Fields are assigned one per variable, with any remaining fields joined by space
/// and assigned to the last variable. If there are more variables than fields,
/// the extra variables are set to empty strings.
fn assign_to_named_variables(
shell: &mut brush_core::Shell<impl brush_core::ShellExtensions>,
input_line: Option<&str>,
ifs: &str,
skip_ifs_splitting: bool,
variable_names: &[String],
) -> Result<(), brush_core::Error> {
let mut fields =
build_variable_fields(input_line, ifs, skip_ifs_splitting, variable_names.len());
for (i, name) in variable_names.iter().enumerate() {
let is_last = i == variable_names.len() - 1;
let value = if fields.is_empty() {
String::new()
} else if is_last {
// Last variable gets all remaining fields joined by space.
std::mem::take(&mut fields).into_iter().join(" ")
} else {
fields.pop_front().unwrap_or_default()
};
shell.env_mut().update_or_add(
name,
variables::ShellValueLiteral::Scalar(value),
|_| Ok(()),
env::EnvironmentLookup::Anywhere,
env::EnvironmentScope::Global,
)?;
if is_last {
break;
}
}
Ok(())
}
/// Builds array field values from input, optionally splitting by IFS.
fn build_array_fields(
input_line: Option<&str>,
ifs: &str,
skip_ifs_splitting: bool,
) -> Vec<(Option<String>, String)> {
match input_line {
Some(line) if skip_ifs_splitting => {
// With -N, don't split - put entire input as single element.
vec![(None, line.to_string())]
}
Some(line) => {
let fields: VecDeque<_> = split_line_by_ifs(ifs, line, None /* max_fields */);
fields.into_iter().map(|f| (None, f)).collect()
}
None => vec![],
}
}
/// Builds field values from input for assignment to named variables.
fn build_variable_fields(
input_line: Option<&str>,
ifs: &str,
skip_ifs_splitting: bool,
num_variables: usize,
) -> VecDeque<String> {
match input_line {
Some(line) if skip_ifs_splitting => {
// With -N, don't split - put entire input in first variable.
let mut fields = VecDeque::new();
fields.push_back(line.to_string());
fields
}
Some(line) => split_line_by_ifs(ifs, line, Some(num_variables)),
None => VecDeque::new(),
}
}
/// Result of a `read` operation.
///
/// This enum clearly represents all possible outcomes of `read_line()`,
/// making the contract with callers explicit.
enum ReadResult {
/// Successfully read a complete line (delimiter or char limit reached).
Line(String),
/// Reached end of input. Contains any partial content read before EOF.
Eof(Option<String>),
/// Input was interrupted (e.g., Ctrl+C). No content is returned.
Interrupted,
/// The operation timed out. Contains any partial content read before timeout.
TimedOut(Option<String>),
/// For `-t 0`: input is immediately available (exit 0).
InputReady,
/// For `-t 0`: no input immediately available (exit 1).
InputNotReady,
}
/// Helper struct that encapsulates the state for reading input character by character.
///
/// This separates the concerns of character-level I/O with timeout handling from the
/// higher-level logic of line building and escape processing.
struct InputReader {
/// The input source.
input: brush_core::openfiles::OpenFile,
/// Optional deadline for timeout.
deadline: Option<Instant>,
/// Single-byte read buffer.
///
/// TODO(utf-8): This only handles ASCII correctly. Multi-byte UTF-8 characters
/// will be read as separate bytes and incorrectly interpreted. To fix this,
/// we would need to buffer up to 4 bytes and decode incrementally using
/// `std::str::from_utf8`. Note that bash's `-n` counts bytes, not Unicode
/// codepoints, so the fix needs to preserve that behavior.
buffer: [u8; 1],
/// Terminal mode guard - kept alive for RAII cleanup on drop.
/// The guard restores original terminal settings when dropped, even though
/// we don't access the field directly after construction.
///
/// The leading underscore suppresses the "unused field" warning while making
/// it explicit this field exists solely for its `Drop` implementation.
_term_mode: Option<brush_core::terminal::AutoModeGuard>,
}
/// Events that can occur when reading input.
enum InputEvent {
/// A regular character was read.
Char(char),
/// End of file was reached.
Eof,
/// The read operation timed out.
Timeout,
/// Ctrl+C was pressed.
CtrlC,
/// Ctrl+D was pressed.
CtrlD,
}
impl InputReader {
/// Creates a new input reader with optional timeout.
fn new(
input: brush_core::openfiles::OpenFile,
timeout: Option<Duration>,
term_mode: Option<brush_core::terminal::AutoModeGuard>,
) -> Self {
Self {
input,
deadline: timeout.map(|t| Instant::now() + t),
buffer: [0; 1],
_term_mode: term_mode,
}
}
/// Checks if input is immediately available (for `-t 0`). Returns `false` if an error
/// occurs while checking for available input.
fn check_input_available(&self) -> bool {
brush_core::sys::poll::poll_for_input(&self.input, Duration::ZERO).unwrap_or(false)
}
/// Reads the next input event, handling timeout and control characters.
fn read_event(&mut self) -> Result<InputEvent, brush_core::Error> {
// Check timeout before attempting read.
if let Some(deadline) = self.deadline {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Ok(InputEvent::Timeout);
}
// Poll for input with remaining timeout.
match brush_core::sys::poll::poll_for_input(&self.input, remaining) {
Ok(true) => { /* Data available, proceed. */ }
Ok(false) => return Ok(InputEvent::Timeout),
Err(e) => return Err(e.into()),
}
}
let n = self.input.read(&mut self.buffer)?;
if n == 0 {
return Ok(InputEvent::Eof);
}
let ch = self.buffer[0] as char;
// Map control characters to events.
Ok(match ch {
CTRL_C => InputEvent::CtrlC,
CTRL_D => InputEvent::CtrlD,
_ => InputEvent::Char(ch),
})
}
}
/// Configuration for line reading behavior.
struct LineReaderConfig {
/// Character that terminates input (None for -N mode).
delimiter: Option<char>,
/// Maximum characters to read (for -n or -N).
char_limit: Option<usize>,
/// Whether to process backslash escapes (false for -r mode).
process_escapes: bool,
}
/// Reads a complete line of input using the given reader and configuration.
///
/// Returns a `ReadResult` indicating success, EOF, timeout, or interruption.
///
/// Note on character counting for `-n` limit:
/// Bash counts OUTPUT characters (after escape processing) toward the limit.
/// For example, with `-n 3` and input `a\bc` (4 bytes):
/// - Bash processes: 'a' (output 1), '\b' → 'b' (output 2), 'c' (output 3) → "abc"
/// - The backslash is consumed but doesn't count toward the limit
fn read_line_with_reader(
reader: &mut InputReader,
config: &LineReaderConfig,
) -> Result<ReadResult, brush_core::Error> {
let mut line = String::new();
let mut pending_backslash = false;
loop {
let event = reader.read_event()?;
match event {
InputEvent::Eof => {
// Bash discards pending backslash on EOF.
return Ok(ReadResult::Eof(if line.is_empty() {
None
} else {
Some(line)
}));
}
InputEvent::Timeout => {
// Include pending backslash on timeout (different from EOF).
if pending_backslash {
line.push(BACKSLASH);
}
return Ok(ReadResult::TimedOut(if line.is_empty() {
None
} else {
Some(line)
}));
}
InputEvent::CtrlC => {
return Ok(ReadResult::Interrupted);
}
InputEvent::CtrlD => {
// At line start = EOF, mid-input = flush current input.
// Bash discards pending backslash here too.
return Ok(if line.is_empty() && !pending_backslash {
ReadResult::Eof(None)
} else {
ReadResult::Line(line)
});
}
InputEvent::Char(ch) => {
// Handle backslash escape processing (when enabled).
if config.process_escapes {
if pending_backslash {
pending_backslash = false;
// Backslash-delimiter is line continuation.
if let Some(delim) = config.delimiter
&& ch == delim
{
continue; // Line continuation.
}
// For other chars, add char literally (backslash consumed).
line.push(ch);
// Check character limit (based on output length).
if let Some(limit) = config.char_limit
&& line.len() >= limit
{
return Ok(ReadResult::Line(line));
}
continue;
}
if ch == BACKSLASH {
pending_backslash = true;
continue;
}
}
// Check for delimiter.
if let Some(delim) = config.delimiter
&& ch == delim
{
return Ok(ReadResult::Line(line));
}
// Ignore non-whitespace control characters.
if ch.is_ascii_control() && !ch.is_ascii_whitespace() {
continue;
}
line.push(ch);
// Check character limit (based on output length).
if let Some(limit) = config.char_limit
&& line.len() >= limit
{
return Ok(ReadResult::Line(line));
}
}
}
}
}
impl ReadCommand {
/// Reads a line of input, optionally with a timeout.
///
/// Handles backslash escape processing:
/// - Without `-r`: backslash-newline is line continuation, other backslashes escape the next
/// char
/// - With `-r`: backslash is treated as a literal character
fn read_line(
&self,
input_file: brush_core::openfiles::OpenFile,
mut stderr_file: impl std::io::Write,
timeout: Option<Duration>,
) -> Result<ReadResult, brush_core::Error> {
let term_mode = self.setup_terminal_settings(&input_file)?;
// Display prompt on stderr, but only if input is from a terminal (per bash behavior).
if let Some(prompt) = &self.prompt {
if input_file.is_terminal() {
write!(stderr_file, "{prompt}")?;
stderr_file.flush()?;
}
}
// Determine delimiter based on options.
let delimiter = if self.return_after_n_chars_no_delimiter.is_some() {
None
} else if let Some(delimiter_str) = &self.delimiter {
if delimiter_str.is_empty() {
Some(NUL_DELIMITER)
} else {
delimiter_str.chars().next()
}
} else {
Some(DEFAULT_DELIMITER)
};
let char_limit = self
.return_after_n_chars_no_delimiter
.or(self.return_after_n_chars);
// Create the input reader.
let mut reader = InputReader::new(input_file, timeout, term_mode);
// Handle -t 0 special case: just check if input is available without reading.
if timeout == Some(Duration::ZERO) {
return Ok(if reader.check_input_available() {
ReadResult::InputReady
} else {
ReadResult::InputNotReady
});
}
// Configure and perform the read.
let config = LineReaderConfig {
delimiter,
char_limit,
process_escapes: !self.raw_mode,
};
read_line_with_reader(&mut reader, &config)
}
fn setup_terminal_settings(
&self,
file: &brush_core::openfiles::OpenFile,
) -> Result<Option<brush_core::terminal::AutoModeGuard>, brush_core::Error> {
let mode = brush_core::terminal::AutoModeGuard::new(file.to_owned()).ok();
if let Some(mode) = &mode {
let config = brush_core::terminal::Settings::builder()
.line_input(false)
.interrupt_signals(false)
.echo_input(!self.silent)
.build();
mode.apply_settings(&config)?;
}
Ok(mode)
}
/// Validates the timeout value and returns an error result if invalid.
///
/// Returns `Ok(Some(result))` if the timeout is invalid (caller should return early),
/// `Ok(None)` if the timeout is valid or not specified.
///
/// TODO(read): Bash uses $TMOUT as a default timeout for `read` when -t is not specified.
fn validate_timeout(
&self,
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
) -> Result<Option<brush_core::ExecutionResult>, brush_core::Error> {
if let Some(timeout) = self.timeout_in_seconds {
if timeout < 0.0 {
writeln!(
context.stderr(),
"{}: -t: invalid timeout specification",
context.command_name
)?;
return Ok(Some(brush_core::ExecutionResult::general_error()));
}
}
Ok(None)
}
}
/// Splits a line by IFS (Internal Field Separator) according to shell rules.
///
/// Shell IFS splitting has special rules:
/// - Whitespace IFS chars (space, tab, newline) are "IFS whitespace"
/// - Leading/trailing IFS whitespace is trimmed from the input
/// - Consecutive IFS whitespace chars act as a single delimiter
/// - Non-whitespace IFS chars each act as individual delimiters
/// - Trailing non-whitespace delimiter does NOT create an empty final field
///
/// # Arguments
/// * `ifs` - The IFS string (typically " \t\n")
/// * `line` - The input line to split
/// * `max_fields` - Optional limit on number of fields (for `read var1 var2`)
fn split_line_by_ifs(ifs: &str, line: &str, max_fields: Option<usize>) -> VecDeque<String> {
let ifs_chars: Vec<char> = ifs.chars().collect();
// Helper to check if a char is IFS whitespace (space, tab, or newline AND in IFS).
let is_ifs_whitespace =
|c: char| -> bool { (c == ' ' || c == '\t' || c == '\n') && ifs_chars.contains(&c) };
// Trim leading/trailing IFS whitespace from the input.
let trimmed_line = line.trim_matches(&is_ifs_whitespace);
if trimmed_line.is_empty() {
return VecDeque::new();
}
let max_fields = max_fields.unwrap_or(usize::MAX);
// State machine for splitting:
// - `consuming_whitespace_run`: Currently skipping consecutive IFS whitespace
// - `prev_was_non_ws_delim`: Previous char was a non-whitespace delimiter
// - `collecting_remainder`: We've hit max_fields, collect everything into last field
let mut fields = VecDeque::new();
let mut current_field = String::new();
let mut consuming_whitespace_run = false;
let mut prev_was_non_ws_delim = false;
let mut collecting_remainder = false;
for c in trimmed_line.chars() {
// Skip consecutive IFS whitespace (they act as single delimiter).
if consuming_whitespace_run && is_ifs_whitespace(c) {
continue;
}
consuming_whitespace_run = false;
let is_delimiter = ifs_chars.contains(&c);
let at_field_limit = fields.len() + 1 >= max_fields;
if !at_field_limit && is_delimiter {
// Normal case: delimiter ends current field, start new one.
fields.push_back(std::mem::take(&mut current_field));
consuming_whitespace_run = is_ifs_whitespace(c);
prev_was_non_ws_delim = !consuming_whitespace_run;
} else if at_field_limit && !collecting_remainder && is_delimiter {
// At field limit but haven't started last field content yet.
// Skip leading IFS whitespace for the final field.
if is_ifs_whitespace(c) {
consuming_whitespace_run = true;
} else {
// Non-whitespace delimiters at boundary: include in remainder.
// e.g., "x::y" with IFS=":" and 2 vars gives ["x", ":y"]
collecting_remainder = true;
current_field.push(c);
}
} else {
// Regular character: add to current field.
collecting_remainder = at_field_limit;
current_field.push(c);
prev_was_non_ws_delim = false;
}
}
// Finalize: push last field unless it's empty AND we ended with non-ws delimiter.
// e.g., "a,b,c," with IFS="," gives ["a", "b", "c"], not ["a", "b", "c", ""].
if !current_field.is_empty() || !prev_was_non_ws_delim {
fields.push_back(current_field);
}
fields
}
#[cfg(test)]
mod tests {
use itertools::assert_equal;
use super::*;
// ==================== split_line_by_ifs tests ====================
#[test]
fn test_split_line_by_ifs_basic() {
let result = split_line_by_ifs(",", "a,b,c", None);
assert_equal(result, VecDeque::from(vec!["a", "b", "c"]));
}
#[test]
fn test_split_line_by_ifs_leading_or_trailing_space() {
let result = split_line_by_ifs(" ", " a b c ", None);
assert_equal(result, VecDeque::from(vec!["a", "b", "c"]));
}
#[test]
fn test_split_line_by_ifs_extra_interior_space() {
let result = split_line_by_ifs(" ", "a b c", None);
assert_equal(result, VecDeque::from(vec!["a", "b", "c"]));
}
#[test]
fn test_split_line_by_ifs_leading_non_space_delimiter() {
let result = split_line_by_ifs(",", ",a,b,c", None);
assert_equal(result, VecDeque::from(vec!["", "a", "b", "c"]));
}
#[test]
fn test_split_line_by_ifs_trailing_non_space_delimiter() {
// Bash does NOT include empty trailing field when input ends with non-ws delimiter.
let result = split_line_by_ifs(",", "a,b,c,", None);
assert_equal(result, VecDeque::from(vec!["a", "b", "c"]));
}
#[test]
fn test_split_line_by_ifs_max_fields() {
// With max_fields=2, remainder goes into second field.
let result = split_line_by_ifs(" ", "a b c d", Some(2));
assert_equal(result, VecDeque::from(vec!["a", "b c d"]));
}
#[test]
fn test_split_line_by_ifs_max_fields_with_non_ws_delimiter() {
// With max_fields and non-whitespace delimiter.
let result = split_line_by_ifs(",", "a,b,c,d", Some(2));
assert_equal(result, VecDeque::from(vec!["a", "b,c,d"]));
}
#[test]
fn test_split_line_by_ifs_consecutive_delimiters_at_boundary() {
// Consecutive non-whitespace delimiters at field boundary should be preserved.
// e.g., "x::y" with IFS=":" and 2 vars gives ["x", ":y"]
let result = split_line_by_ifs(":", "x::y", Some(2));
assert_equal(result, VecDeque::from(vec!["x", ":y"]));
// Triple delimiter at boundary.
let result = split_line_by_ifs(":", "x:::y", Some(2));
assert_equal(result, VecDeque::from(vec!["x", "::y"]));
// Delimiter in middle of remainder is also preserved.
let result = split_line_by_ifs(":", "x:y:z:w", Some(2));
assert_equal(result, VecDeque::from(vec!["x", "y:z:w"]));
}
#[test]
fn test_split_line_by_ifs_mixed_delimiters() {
// Mixed whitespace and non-whitespace in IFS.
let result = split_line_by_ifs(": ", "a:b c:d", None);
assert_equal(result, VecDeque::from(vec!["a", "b", "c", "d"]));
}
#[test]
fn test_split_line_by_ifs_empty_input() {
let result = split_line_by_ifs(" ", "", None);
assert_equal(result, VecDeque::<String>::new());
}
#[test]
fn test_split_line_by_ifs_whitespace_only() {
let result = split_line_by_ifs(" ", " ", None);
assert_equal(result, VecDeque::<String>::new());
}
#[test]
fn test_split_line_by_ifs_consecutive_non_ws_delimiters() {
// Consecutive non-whitespace delimiters create empty fields.
let result = split_line_by_ifs(",", "a,,b", None);
assert_equal(result, VecDeque::from(vec!["a", "", "b"]));
}
// ==================== build_array_fields tests ====================
#[test]
fn test_build_array_fields_basic() {
let result = build_array_fields(Some("a b c"), " ", false);
assert_eq!(
result,
vec![
(None, "a".to_string()),
(None, "b".to_string()),
(None, "c".to_string())
]
);
}
#[test]
fn test_build_array_fields_skip_splitting() {
// With -N option, entire input goes as single element.
let result = build_array_fields(Some("a b c"), " ", true);
assert_eq!(result, vec![(None, "a b c".to_string())]);
}
#[test]
fn test_build_array_fields_none_input() {
let result = build_array_fields(None, " ", false);
assert!(result.is_empty());
}
// ==================== build_variable_fields tests ====================
#[test]
fn test_build_variable_fields_basic() {
let result = build_variable_fields(Some("a b c"), " ", false, 3);
assert_equal(result, VecDeque::from(vec!["a", "b", "c"]));
}
#[test]
fn test_build_variable_fields_fewer_vars_than_fields() {
// Last variable gets remainder.
let result = build_variable_fields(Some("a b c d"), " ", false, 2);
assert_equal(result, VecDeque::from(vec!["a", "b c d"]));
}
#[test]
fn test_build_variable_fields_skip_splitting() {
// With -N option, entire input goes to first variable.
let result = build_variable_fields(Some("a b c"), " ", true, 3);
assert_equal(result, VecDeque::from(vec!["a b c"]));
}
#[test]
fn test_build_variable_fields_none_input() {
let result = build_variable_fields(None, " ", false, 3);
assert!(result.is_empty());
}
}
+40
View File
@@ -0,0 +1,40 @@
use clap::Parser;
use std::io::Write;
use brush_core::{ExecutionControlFlow, ExecutionExitCode, ExecutionResult, builtins};
/// Return from the current function.
#[derive(Parser)]
pub(crate) struct ReturnCommand {
/// The exit code to return.
code: Option<i32>,
}
impl builtins::Command for ReturnCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
#[expect(clippy::cast_sign_loss)]
let code_8bit = if let Some(code_32bit) = &self.code {
(code_32bit & 0xFF) as u8
} else {
context.shell.last_exit_status()
};
if context.shell.in_function() || context.shell.in_sourced_script() {
let mut result = ExecutionResult::new(code_8bit);
result.next_control_flow = ExecutionControlFlow::ReturnFromFunctionOrScript;
Ok(result)
} else {
let _ = writeln!(
context.stderr(),
"return: can only be used in a function or sourced script"
);
Ok(ExecutionExitCode::InvalidUsage.into())
}
}
}
+449
View File
@@ -0,0 +1,449 @@
use std::collections::HashMap;
use std::io::Write;
use clap::Parser;
use itertools::Itertools;
use brush_core::{ExecutionExitCode, ExecutionResult, builtins, variables};
crate::minus_or_plus_flag_arg!(
ExportVariablesOnModification,
'a',
"Export variables on modification"
);
crate::minus_or_plus_flag_arg!(
NotifyJobTerminationImmediately,
'b',
"Notify job termination immediately"
);
crate::minus_or_plus_flag_arg!(
ExitOnNonzeroCommandExit,
'e',
"Exit on nonzero command exit"
);
crate::minus_or_plus_flag_arg!(DisableFilenameGlobbing, 'f', "Disable filename globbing");
crate::minus_or_plus_flag_arg!(RememberCommandLocations, 'h', "Remember command locations");
crate::minus_or_plus_flag_arg!(
PlaceAllAssignmentArgsInCommandEnv,
'k',
"Place all assignment args in command environment"
);
crate::minus_or_plus_flag_arg!(EnableJobControl, 'm', "Enable job control");
crate::minus_or_plus_flag_arg!(DoNotExecuteCommands, 'n', "Do not execute commands");
crate::minus_or_plus_flag_arg!(RealEffectiveUidMismatch, 'p', "Real effective UID mismatch");
crate::minus_or_plus_flag_arg!(ExitAfterOneCommand, 't', "Exit after one command");
crate::minus_or_plus_flag_arg!(
TreatUnsetVariablesAsError,
'u',
"Treat unset variables as error"
);
crate::minus_or_plus_flag_arg!(PrintShellInputLines, 'v', "Print shell input lines");
crate::minus_or_plus_flag_arg!(
PrintCommandsAndArguments,
'x',
"Print commands and arguments"
);
crate::minus_or_plus_flag_arg!(PerformBraceExpansion, 'B', "Perform brace expansion");
crate::minus_or_plus_flag_arg!(
DisallowOverwritingRegularFilesViaOutputRedirection,
'C',
"Disallow overwriting regular files via output redirection"
);
crate::minus_or_plus_flag_arg!(
ShellFunctionsInheritErrTrap,
'E',
"Shell functions inherit ERR trap"
);
crate::minus_or_plus_flag_arg!(
EnableBangStyleHistorySubstitution,
'H',
"Enable bang style history substitution"
);
crate::minus_or_plus_flag_arg!(
DoNotResolveSymlinksWhenChangingDir,
'P',
"Do not resolve symlinks when changing dir"
);
crate::minus_or_plus_flag_arg!(
ShellFunctionsInheritDebugAndReturnTraps,
'T',
"Shell functions inherit DEBUG and RETURN traps"
);
#[derive(clap::Parser)]
pub(crate) struct SetOption {
#[arg(short = 'o', name = "setopt_enable", num_args=0..=1, value_name = "OPT")]
enable: Option<Vec<String>>,
#[arg(long = concat!("+o"), name = "setopt_disable", hide = true, num_args=0..=1)]
disable: Option<Vec<String>>,
}
/// Manage set-based shell options.
#[derive(Parser)]
#[clap(disable_help_flag = true)]
pub(crate) struct SetCommand {
/// Display help for this command.
#[clap(long, action = clap::ArgAction::HelpLong)]
help: Option<bool>,
#[clap(flatten)]
export_variables_on_modification: ExportVariablesOnModification,
#[clap(flatten)]
notify_job_termination_immediately: NotifyJobTerminationImmediately,
#[clap(flatten)]
exit_on_nonzero_command_exit: ExitOnNonzeroCommandExit,
#[clap(flatten)]
disable_filename_globbing: DisableFilenameGlobbing,
#[clap(flatten)]
remember_command_locations: RememberCommandLocations,
#[clap(flatten)]
place_all_assignment_args_in_command_env: PlaceAllAssignmentArgsInCommandEnv,
#[clap(flatten)]
enable_job_control: EnableJobControl,
#[clap(flatten)]
do_not_execute_commands: DoNotExecuteCommands,
#[clap(flatten)]
real_effective_uid_mismatch: RealEffectiveUidMismatch,
#[clap(flatten)]
exit_after_one_command: ExitAfterOneCommand,
#[clap(flatten)]
treat_unset_variables_as_error: TreatUnsetVariablesAsError,
#[clap(flatten)]
print_shell_input_lines: PrintShellInputLines,
#[clap(flatten)]
print_commands_and_arguments: PrintCommandsAndArguments,
#[clap(flatten)]
perform_brace_expansion: PerformBraceExpansion,
#[clap(flatten)]
disallow_overwriting_regular_files_via_output_redirection:
DisallowOverwritingRegularFilesViaOutputRedirection,
#[clap(flatten)]
shell_functions_inherit_err_trap: ShellFunctionsInheritErrTrap,
#[clap(flatten)]
enable_bang_style_history_substitution: EnableBangStyleHistorySubstitution,
#[clap(flatten)]
do_not_resolve_symlinks_when_changing_dir: DoNotResolveSymlinksWhenChangingDir,
#[clap(flatten)]
shell_functions_inherit_debug_and_return_traps: ShellFunctionsInheritDebugAndReturnTraps,
#[clap(flatten)]
set_option: SetOption,
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
positional_args: Vec<String>,
}
impl builtins::Command for SetCommand {
fn takes_plus_options() -> bool {
true
}
/// Override the default [`builtins::Command::new`] function to handle clap's limitation related
/// to `--`. See [`builtins::parse_known`] for more information
/// TODO(set): we can safely remove this after the issue is resolved
fn new<I>(args: I) -> Result<Self, clap::Error>
where
I: IntoIterator<Item = String>,
{
//
// TODO(set): This is getting pretty messy; we need to see how to avoid this -- handling
// from leaking into too many commands' custom parsing.
//
// Apply the same workaround from the default implementation of Command::new to handle '+'
// args.
let mut updated_args = vec![];
let mut now_parsing_positional_args = false;
let mut next_arg_is_option_value = false;
for (i, arg) in args.into_iter().enumerate() {
if now_parsing_positional_args || next_arg_is_option_value {
updated_args.push(arg);
next_arg_is_option_value = false;
continue;
}
if arg == "-" || arg == "--" || (i > 0 && !arg.starts_with(['-', '+'])) {
now_parsing_positional_args = true;
}
if let Some(plus_options) = arg.strip_prefix("+") {
next_arg_is_option_value = plus_options.ends_with('o');
for c in plus_options.chars() {
updated_args.push(format!("--+{c}"));
}
} else {
next_arg_is_option_value = arg.starts_with('-') && arg.ends_with('o');
updated_args.push(arg);
}
}
let (mut this, rest_args) = brush_core::builtins::try_parse_known::<Self>(updated_args)?;
if let Some(args) = rest_args {
this.positional_args.extend(args);
}
Ok(this)
}
type Error = brush_core::Error;
#[expect(clippy::too_many_lines)]
#[allow(clippy::useless_let_if_seq)]
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<ExecutionResult, Self::Error> {
let mut result = ExecutionResult::success();
let mut saw_option = false;
if let Some(value) = self.print_commands_and_arguments.to_bool() {
context.shell.options_mut().print_commands_and_arguments = value;
saw_option = true;
}
if let Some(value) = self.export_variables_on_modification.to_bool() {
context.shell.options_mut().export_variables_on_modification = value;
saw_option = true;
}
if let Some(value) = self.notify_job_termination_immediately.to_bool() {
context
.shell
.options_mut()
.notify_job_termination_immediately = value;
saw_option = true;
}
if let Some(value) = self.exit_on_nonzero_command_exit.to_bool() {
context.shell.options_mut().exit_on_nonzero_command_exit = value;
saw_option = true;
}
if let Some(value) = self.disable_filename_globbing.to_bool() {
context.shell.options_mut().disable_filename_globbing = value;
saw_option = true;
}
if let Some(value) = self.remember_command_locations.to_bool() {
context.shell.options_mut().remember_command_locations = value;
saw_option = true;
}
if let Some(value) = self.place_all_assignment_args_in_command_env.to_bool() {
context
.shell
.options_mut()
.place_all_assignment_args_in_command_env = value;
saw_option = true;
}
if let Some(value) = self.enable_job_control.to_bool() {
context.shell.options_mut().enable_job_control = value;
saw_option = true;
}
if let Some(value) = self.do_not_execute_commands.to_bool() {
context.shell.options_mut().do_not_execute_commands = value;
saw_option = true;
}
if let Some(value) = self.real_effective_uid_mismatch.to_bool() {
context.shell.options_mut().real_effective_uid_mismatch = value;
saw_option = true;
}
if let Some(value) = self.exit_after_one_command.to_bool() {
context.shell.options_mut().exit_after_one_command = value;
saw_option = true;
}
if let Some(value) = self.treat_unset_variables_as_error.to_bool() {
context.shell.options_mut().treat_unset_variables_as_error = value;
saw_option = true;
}
if let Some(value) = self.print_shell_input_lines.to_bool() {
context.shell.options_mut().print_shell_input_lines = value;
saw_option = true;
}
if let Some(value) = self.print_commands_and_arguments.to_bool() {
context.shell.options_mut().print_commands_and_arguments = value;
saw_option = true;
}
if let Some(value) = self.perform_brace_expansion.to_bool() {
context.shell.options_mut().perform_brace_expansion = value;
saw_option = true;
}
if let Some(value) = self
.disallow_overwriting_regular_files_via_output_redirection
.to_bool()
{
context
.shell
.options_mut()
.disallow_overwriting_regular_files_via_output_redirection = value;
saw_option = true;
}
if let Some(value) = self.shell_functions_inherit_err_trap.to_bool() {
context.shell.options_mut().shell_functions_inherit_err_trap = value;
saw_option = true;
}
if let Some(value) = self.enable_bang_style_history_substitution.to_bool() {
context
.shell
.options_mut()
.enable_bang_style_history_substitution = value;
saw_option = true;
}
if let Some(value) = self.do_not_resolve_symlinks_when_changing_dir.to_bool() {
context
.shell
.options_mut()
.do_not_resolve_symlinks_when_changing_dir = value;
saw_option = true;
}
if let Some(value) = self
.shell_functions_inherit_debug_and_return_traps
.to_bool()
{
context
.shell
.options_mut()
.shell_functions_inherit_debug_and_return_traps = value;
saw_option = true;
}
let mut named_options: HashMap<String, bool> = HashMap::new();
if let Some(option_names) = &self.set_option.disable {
saw_option = true;
if option_names.is_empty() {
for option in brush_core::namedoptions::options(
brush_core::namedoptions::ShellOptionKind::SetO,
)
.iter()
.sorted_by_key(|option| option.name)
{
let option_value = option.definition.get(context.shell.options());
let option_value_str = if option_value { "-o" } else { "+o" };
writeln!(context.stdout(), "set {option_value_str} {}", option.name)?;
}
} else {
for option_name in option_names {
named_options.insert(option_name.to_owned(), false);
}
}
}
if let Some(option_names) = &self.set_option.enable {
saw_option = true;
if option_names.is_empty() {
for option in brush_core::namedoptions::options(
brush_core::namedoptions::ShellOptionKind::SetO,
)
.iter()
.sorted_by_key(|option| option.name)
{
let option_value = option.definition.get(context.shell.options());
let option_value_str = if option_value { "on" } else { "off" };
writeln!(context.stdout(), "{:15}\t{option_value_str}", option.name)?;
}
} else {
for option_name in option_names {
named_options.insert(option_name.to_owned(), true);
}
}
}
for (option_name, value) in named_options {
if let Some(option_def) =
brush_core::namedoptions::options(brush_core::namedoptions::ShellOptionKind::SetO)
.get(option_name.as_str())
{
option_def.set(context.shell.options_mut(), value);
} else {
result = ExecutionExitCode::InvalidUsage.into();
}
}
let args = context.shell.current_shell_args_mut();
let skip = match self.positional_args.first() {
Some(x) if x == "-" => {
if self.positional_args.len() > 1 {
args.clear();
}
1
}
Some(x) if x == "--" => {
args.clear();
1
}
Some(_) => {
args.clear();
0
}
None => 0,
};
for arg in self.positional_args.iter().skip(skip) {
args.push(arg.to_owned());
}
saw_option = saw_option || !self.positional_args.is_empty();
// If we *still* haven't seen any options, then we need to display all variables and
// functions.
if !saw_option {
display_all(&context)?;
}
Ok(result)
}
}
fn display_all(
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
) -> Result<(), brush_core::Error> {
// Display variables.
for (name, var) in context.shell.env().iter().sorted_by_key(|v| v.0) {
if !var.is_enumerable() {
continue;
}
// TODO(set): For now, skip all dynamic variables. The current behavior
// of bash is not quite clear. We've empirically found that some
// special variables don't get displayed until they're observed
// at least once.
if matches!(var.value(), variables::ShellValue::Dynamic { .. }) {
continue;
}
// Skip variables that have been declared but are unset.
if !var.value().is_set() {
continue;
}
writeln!(
context.stdout(),
"{name}={}",
var.value()
.format(variables::FormatStyle::Basic, context.shell)?,
)?;
}
// Display functions... unless we're in posix compliance mode.
if !context.shell.options().posix_mode {
for (_name, registration) in context.shell.funcs().iter().sorted_by_key(|v| v.0) {
writeln!(context.stdout(), "{}", registration.definition())?;
}
}
Ok(())
}
+38
View File
@@ -0,0 +1,38 @@
use clap::Parser;
use brush_core::{ExecutionExitCode, ExecutionResult, builtins};
/// Shift positional arguments.
#[derive(Parser)]
pub(crate) struct ShiftCommand {
/// Number of positions to shift the arguments by (defaults to 1).
n: Option<i32>,
}
impl builtins::Command for ShiftCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
let n = self.n.unwrap_or(1);
if n < 0 {
return Ok(ExecutionExitCode::InvalidUsage.into());
}
#[expect(clippy::cast_sign_loss)]
let n = n as usize;
let args = context.shell.current_shell_args_mut();
if n > args.len() {
return Ok(ExecutionExitCode::InvalidUsage.into());
}
args.drain(0..n);
Ok(ExecutionResult::success())
}
}
+153
View File
@@ -0,0 +1,153 @@
use clap::Parser;
use itertools::Itertools;
use std::io::Write;
use brush_core::{ExecutionExitCode, ExecutionResult, builtins};
/// Manage shopt-style options.
#[derive(Parser)]
pub(crate) struct ShoptCommand {
/// Manage set -o options.
#[arg(short = 'o')]
set_o_names_only: bool,
/// Print options' current values.
#[arg(short = 'p')]
print: bool,
/// Suppress typical output.
#[arg(short = 'q')]
quiet: bool,
/// Set the specified options.
#[arg(short = 's')]
set: bool,
/// Unset the specified options.
#[arg(short = 'u')]
unset: bool,
/// Names of options to operate on.
options: Vec<String>,
}
impl builtins::Command for ShoptCommand {
type Error = brush_core::Error;
#[allow(clippy::too_many_lines)]
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
if self.set && self.unset {
writeln!(
context.stderr(),
"cannot set and unset shell options simultaneously"
)?;
return Ok(ExecutionExitCode::InvalidUsage.into());
}
if self.options.is_empty() {
if self.quiet {
return Ok(ExecutionResult::success());
}
// Enumerate all options of the selected type.
let options = if self.set_o_names_only {
brush_core::namedoptions::options(brush_core::namedoptions::ShellOptionKind::SetO)
.iter()
.sorted_by_key(|opt| opt.name)
} else {
brush_core::namedoptions::options(brush_core::namedoptions::ShellOptionKind::Shopt)
.iter()
.sorted_by_key(|opt| opt.name)
};
for option in options {
let option_value = option.definition.get(context.shell.options());
if self.set && !option_value {
continue;
}
if self.unset && option_value {
continue;
}
if self.print {
if self.set_o_names_only {
let option_value_str = if option_value { "-o" } else { "+o" };
writeln!(context.stdout(), "set {option_value_str} {}", option.name)?;
} else {
let option_value_str = if option_value { "-s" } else { "-u" };
writeln!(context.stdout(), "shopt {option_value_str} {}", option.name)?;
}
} else {
let option_value_str = if option_value { "on" } else { "off" };
writeln!(context.stdout(), "{:20}\t{option_value_str}", option.name)?;
}
}
Ok(ExecutionResult::success())
} else {
let mut return_value = ExecutionResult::success();
// Enumerate only the specified options.
for option_name in &self.options {
let option_definition = if self.set_o_names_only {
brush_core::namedoptions::options(
brush_core::namedoptions::ShellOptionKind::SetO,
)
.get(option_name.as_str())
} else {
brush_core::namedoptions::options(
brush_core::namedoptions::ShellOptionKind::Shopt,
)
.get(option_name.as_str())
};
if let Some(option_definition) = option_definition {
if self.set {
option_definition.set(context.shell.options_mut(), true);
} else if self.unset {
option_definition.set(context.shell.options_mut(), false);
} else {
let option_value = option_definition.get(context.shell.options());
if !option_value {
return_value = ExecutionResult::general_error();
}
if !self.quiet {
if self.print {
if self.set_o_names_only {
let option_value_str = if option_value { "-o" } else { "+o" };
writeln!(
context.stdout(),
"set {option_value_str} {option_name}"
)?;
} else {
let option_value_str = if option_value { "-s" } else { "-u" };
writeln!(
context.stdout(),
"shopt {option_value_str} {option_name}"
)?;
}
} else {
let option_value_str = if option_value { "on" } else { "off" };
writeln!(context.stdout(), "{option_name:20}\t{option_value_str}")?;
}
}
}
} else {
writeln!(
context.stderr(),
"{}: {}: invalid shell option name",
context.command_name,
option_name
)?;
return_value = ExecutionResult::general_error();
}
}
Ok(return_value)
}
}
}
+34
View File
@@ -0,0 +1,34 @@
use clap::Parser;
use std::io::Write;
use brush_core::{ExecutionExitCode, ExecutionResult, builtins};
/// Suspend the shell.
#[derive(Parser)]
pub(crate) struct SuspendCommand {
/// Force suspend login shells.
#[arg(short = 'f')]
force: bool,
}
impl builtins::Command for SuspendCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<ExecutionResult, Self::Error> {
if context.shell.options().login_shell && !self.force {
writeln!(context.stderr(), "login shell cannot be suspended")?;
return Ok(ExecutionExitCode::InvalidUsage.into());
}
#[expect(clippy::cast_possible_wrap)]
brush_core::sys::signal::kill_process(
std::process::id() as i32,
brush_core::traps::TrapSignal::Signal(nix::sys::signal::SIGSTOP),
)?;
Ok(ExecutionResult::success())
}
}
+67
View File
@@ -0,0 +1,67 @@
use clap::Parser;
use std::io::Write;
use brush_core::{
ErrorKind, ExecutionExitCode, ExecutionParameters, ExecutionResult, Shell, builtins, tests,
};
/// Evaluate test expression.
#[derive(Parser)]
#[clap(disable_help_flag = true, disable_version_flag = true)]
pub(crate) struct TestCommand {
#[clap(allow_hyphen_values = true)]
args: Vec<String>,
}
impl builtins::Command for TestCommand {
type Error = brush_core::Error;
/// Override the default [`builtins::Command::new`] function to handle clap's limitation related
/// to `--`. See [`builtins::parse_known`] for more information
/// TODO(test): we can safely remove this after the issue is resolved
fn new<I>(args: I) -> Result<Self, clap::Error>
where
I: IntoIterator<Item = String>,
{
let (mut this, rest_args) = brush_core::builtins::try_parse_known::<Self>(args)?;
if let Some(args) = rest_args {
this.args.extend(args);
}
Ok(this)
}
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
let mut args = self.args.as_slice();
if context.command_name == "[" {
match args.last() {
Some(s) if s == "]" => (),
None | Some(_) => {
writeln!(context.stderr(), "[: missing ']'")?;
return Ok(ExecutionExitCode::InvalidUsage.into());
}
}
args = &args[0..args.len() - 1];
}
if execute_test(context.shell, &context.params, args)? {
Ok(ExecutionResult::success())
} else {
Ok(ExecutionResult::general_error())
}
}
}
fn execute_test(
shell: &mut Shell<impl brush_core::ShellExtensions>,
params: &ExecutionParameters,
args: &[String],
) -> Result<bool, brush_core::Error> {
let test_command =
brush_parser::test_command::parse(args).map_err(ErrorKind::TestCommandParseError)?;
tests::eval_expr(&test_command, shell, params)
}
+36
View File
@@ -0,0 +1,36 @@
use clap::Parser;
use std::io::Write;
use brush_core::{ExecutionResult, builtins, timing};
/// Report on usage time.
#[derive(Parser)]
pub(crate) struct TimesCommand {}
impl builtins::Command for TimesCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<ExecutionResult, Self::Error> {
let (self_user, self_system) = brush_core::sys::resource::get_self_user_and_system_time()?;
writeln!(
context.stdout(),
"{} {}",
timing::format_duration_non_posixly(&self_user),
timing::format_duration_non_posixly(&self_system),
)?;
let (children_user, children_system) =
brush_core::sys::resource::get_children_user_and_system_time()?;
writeln!(
context.stdout(),
"{} {}",
timing::format_duration_non_posixly(&children_user),
timing::format_duration_non_posixly(&children_system),
)?;
Ok(ExecutionResult::success())
}
}
+116
View File
@@ -0,0 +1,116 @@
use clap::Parser;
use std::io::Write;
use brush_core::traps::TrapSignal;
use brush_core::{ExecutionResult, builtins};
/// Manage signal traps.
#[derive(Parser)]
pub(crate) struct TrapCommand {
/// List all signal names.
#[arg(short = 'l')]
list_signals: bool,
/// Print registered trap commands.
#[arg(short = 'p')]
print_trap_commands: bool,
args: Vec<String>,
}
impl builtins::Command for TrapCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
mut context: brush_core::ExecutionContext<'_, SE>,
) -> Result<ExecutionResult, Self::Error> {
if self.list_signals {
brush_core::traps::format_signals(context.stdout(), TrapSignal::iterator())
.map(|()| ExecutionResult::success())
} else if self.print_trap_commands || self.args.is_empty() {
if !self.args.is_empty() {
for signal_type in &self.args {
Self::display_handlers_for(&context, signal_type.parse()?)?;
}
} else {
Self::display_all_handlers(&context)?;
}
Ok(ExecutionResult::success())
} else if self.args.len() == 1 {
// When only a single argument is given, it is assumed to be a signal name
// and an indication to remove the handlers for that signal.
let signal = self.args[0].as_str();
Self::remove_all_handlers(&mut context, signal.parse()?);
Ok(ExecutionResult::success())
} else if self.args[0] == "-" {
// "-" as the first argument indicates that the remaining
// arguments are signal names and we need to remove the handlers for them.
for signal in &self.args[1..] {
Self::remove_all_handlers(&mut context, signal.parse()?);
}
Ok(ExecutionResult::success())
} else {
let handler = &self.args[0];
let mut signal_types = vec![];
for signal in &self.args[1..] {
signal_types.push(signal.parse()?);
}
Self::register_handler(&mut context, signal_types, handler.as_str());
Ok(ExecutionResult::success())
}
}
}
impl TrapCommand {
fn display_all_handlers(
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
) -> Result<(), brush_core::Error> {
for (signal, _) in context.shell.traps().iter_handlers() {
Self::display_handlers_for(context, signal)?;
}
Ok(())
}
fn display_handlers_for(
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
signal_type: TrapSignal,
) -> Result<(), brush_core::Error> {
if let Some(handler) = context.shell.traps().get_handler(signal_type) {
writeln!(
context.stdout(),
"trap -- '{}' {signal_type}",
&handler.command
)?;
}
Ok(())
}
fn remove_all_handlers(
context: &mut brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
signal: TrapSignal,
) {
context.shell.traps_mut().remove_handlers(signal);
}
fn register_handler(
context: &mut brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
signals: Vec<TrapSignal>,
handler: &str,
) {
// Our new source context is relative to the current position.
// TODO(source-info): Provide the location of the specific token that makes up
// `self.args[0]`.
let source_info = context.shell.call_stack().current_pos_as_source_info();
for signal in signals {
context.shell.traps_mut().register_handler(
signal,
handler.to_owned(),
source_info.clone(),
);
}
}
}
+26
View File
@@ -0,0 +1,26 @@
use brush_core::{ExecutionResult, builtins, error};
/// No-op command. Same with :.
pub(crate) struct TrueCommand {}
impl builtins::SimpleCommand for TrueCommand {
fn get_content(
_name: &str,
content_type: builtins::ContentType,
_options: &builtins::ContentOptions,
) -> Result<String, brush_core::Error> {
match content_type {
builtins::ContentType::DetailedHelp => Ok("Returns a successful exit status.".into()),
builtins::ContentType::ShortUsage => Ok("true".into()),
builtins::ContentType::ShortDescription => Ok("true - success".into()),
builtins::ContentType::ManPage => error::unimp("man page not yet implemented"),
}
}
fn execute<SE: brush_core::ShellExtensions, I: Iterator<Item = S>, S: AsRef<str>>(
_context: brush_core::ExecutionContext<'_, SE>,
_args: I,
) -> Result<ExecutionResult, brush_core::Error> {
Ok(ExecutionResult::success())
}
}
+220
View File
@@ -0,0 +1,220 @@
use std::io::Write;
use std::path::{Path, PathBuf};
use clap::Parser;
use brush_core::sys::{self, fs::PathExt};
use brush_core::{ExecutionResult, Shell, builtins, parser::ast};
/// Inspect the type of a named shell item.
#[derive(Parser)]
pub(crate) struct TypeCommand {
/// Display all locations of the specified name, not just the first.
#[arg(short = 'a')]
all_locations: bool,
/// Don't consider functions when resolving the name.
#[arg(short = 'f')]
suppress_func_lookup: bool,
/// Force searching by file path, even if the name is an alias, built-in
/// command, or shell function.
#[arg(short = 'P')]
force_path_search: bool,
/// Show file path only.
#[arg(short = 'p')]
show_path_only: bool,
/// Only display the type of the specified name.
#[arg(short = 't')]
type_only: bool,
/// Names to search for.
names: Vec<String>,
}
enum ResolvedType<'a> {
Alias(String),
Keyword,
Function(&'a ast::FunctionDefinition),
Builtin,
File { path: PathBuf, hashed: bool },
}
impl builtins::Command for TypeCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
let mut result = ExecutionResult::success();
for name in &self.names {
let resolved_types = self.resolve_types(context.shell, name);
if resolved_types.is_empty() {
if !self.type_only && !self.force_path_search && !self.show_path_only {
writeln!(context.stderr(), "type: {name} not found")?;
}
result = ExecutionResult::general_error();
continue;
}
for resolved_type in resolved_types {
if self.show_path_only && !matches!(resolved_type, ResolvedType::File { .. }) {
// Do nothing.
} else if self.type_only {
match resolved_type {
ResolvedType::Alias(_) => {
writeln!(context.stdout(), "alias")?;
}
ResolvedType::Keyword => {
writeln!(context.stdout(), "keyword")?;
}
ResolvedType::Function(_) => {
writeln!(context.stdout(), "function")?;
}
ResolvedType::Builtin => {
writeln!(context.stdout(), "builtin")?;
}
ResolvedType::File { path, .. } => {
if self.show_path_only || self.force_path_search {
writeln!(context.stdout(), "{}", path.to_string_lossy())?;
} else {
writeln!(context.stdout(), "file")?;
}
}
}
} else {
match resolved_type {
ResolvedType::Alias(target) => {
writeln!(context.stdout(), "{name} is aliased to `{target}'")?;
}
ResolvedType::Keyword => {
writeln!(context.stdout(), "{name} is a shell keyword")?;
}
ResolvedType::Function(def) => {
writeln!(context.stdout(), "{name} is a function")?;
writeln!(context.stdout(), "{def}")?;
}
ResolvedType::Builtin => {
writeln!(context.stdout(), "{name} is a shell builtin")?;
}
ResolvedType::File { path, hashed } => {
if hashed && self.all_locations && !self.force_path_search {
// Do nothing. When we're displaying all locations, then
// we don't show hashed paths.
} else if self.show_path_only || self.force_path_search {
writeln!(context.stdout(), "{}", path.to_string_lossy())?;
} else if hashed {
writeln!(
context.stdout(),
"{name} is hashed ({path})",
name = name,
path = path.to_string_lossy()
)?;
} else {
writeln!(
context.stdout(),
"{name} is {path}",
name = name,
path = path.to_string_lossy()
)?;
}
}
}
}
// If we only want the first, then break after the first.
if !self.all_locations {
break;
}
}
}
Ok(result)
}
}
impl TypeCommand {
fn resolve_types<'a, SE: brush_core::ShellExtensions>(
&self,
shell: &'a Shell<SE>,
name: &str,
) -> Vec<ResolvedType<'a>> {
let mut types = vec![];
if !self.force_path_search {
// Check for aliases.
if let Some(a) = shell.aliases().get(name) {
types.push(ResolvedType::Alias(a.clone()));
if !self.all_locations {
return types;
}
}
// Check for keywords.
if shell.is_keyword(name) {
types.push(ResolvedType::Keyword);
if !self.all_locations {
return types;
}
}
// Check for functions.
if !self.suppress_func_lookup {
if let Some(registration) = shell.funcs().get(name) {
types.push(ResolvedType::Function(registration.definition()));
if !self.all_locations {
return types;
}
}
}
// Check for builtins.
if shell.builtins().get(name).is_some_and(|b| !b.disabled) {
types.push(ResolvedType::Builtin);
if !self.all_locations {
return types;
}
}
}
// Look in path.
if sys::fs::contains_path_separator(name) {
if shell.absolute_path(Path::new(name)).executable() {
types.push(ResolvedType::File {
path: PathBuf::from(name),
hashed: false,
});
if !self.all_locations {
return types;
}
}
} else {
if let Some(path) = shell.program_location_cache().get(name) {
types.push(ResolvedType::File { path, hashed: true });
if !self.all_locations {
return types;
}
}
for item in shell.find_executables_in_path(name) {
types.push(ResolvedType::File {
path: item,
hashed: false,
});
if !self.all_locations {
return types;
}
}
}
types
}
}
+504
View File
@@ -0,0 +1,504 @@
use clap::{
Parser,
builder::{IntoResettable, StyledStr},
};
use std::{
io::{self, ErrorKind, Write},
str::FromStr,
};
use brush_core::{ExecutionResult, builtins};
#[derive(Clone, Copy)]
enum Unit {
Block,
Bytes,
HalfKBytes,
KBytes,
Micros,
Number,
Seconds,
}
impl Unit {
const fn scale(self) -> u64 {
match self {
Self::Block | Self::HalfKBytes => 512,
Self::KBytes => 1024,
_ => 1,
}
}
}
#[derive(Clone, Copy)]
enum Virtual {
Pipe,
VMem,
}
impl Virtual {
fn get(self) -> std::io::Result<(u64, u64)> {
match self {
Self::Pipe => {
let lim = nix::unistd::PathconfVar::PIPE_BUF as u64 * 512;
Ok((lim, lim))
}
Self::VMem => rlimit::Resource::AS
.get()
.or_else(|_| rlimit::Resource::VMEM.get()),
}
}
fn set(self, soft: u64, hard: u64) -> std::io::Result<()> {
match self {
Self::Pipe => Err(std::io::Error::from(ErrorKind::Unsupported)),
Self::VMem => rlimit::Resource::AS
.set(soft, hard)
.or_else(|_| rlimit::Resource::VMEM.set(soft, hard)),
}
}
const fn is_supported(self) -> bool {
match self {
Self::Pipe => true,
Self::VMem => {
rlimit::Resource::AS.is_supported() || rlimit::Resource::VMEM.is_supported()
}
}
}
}
#[derive(Clone, Copy)]
enum Resource {
Phy(rlimit::Resource),
Virt(Virtual),
}
impl Resource {
fn get(self) -> std::io::Result<(u64, u64)> {
match self {
Self::Phy(res) => res.get(),
Self::Virt(res) => res.get(),
}
}
fn set(self, soft: u64, hard: u64) -> std::io::Result<()> {
match self {
Self::Phy(res) => res.set(soft, hard),
Self::Virt(res) => res.set(soft, hard),
}
}
const fn is_supported(self) -> bool {
match self {
Self::Phy(res) => res.is_supported(),
Self::Virt(res) => res.is_supported(),
}
}
}
#[derive(Clone, Copy)]
struct ResourceDescription {
resource: Resource,
help: &'static str,
description: &'static str,
short: char,
unit: Unit,
}
impl ResourceDescription {
const SBSIZE: Self = Self {
resource: Resource::Phy(rlimit::Resource::SBSIZE),
help: "the socket buffer size",
description: "socket buffer size",
short: 'b',
unit: Unit::Bytes,
};
const CORE: Self = Self {
resource: Resource::Phy(rlimit::Resource::CORE),
help: "the maximum size of core files created",
description: "core file size",
short: 'c',
unit: Unit::Block,
};
const DATA: Self = Self {
resource: Resource::Phy(rlimit::Resource::DATA),
help: "the maximum size of a process's data segment",
description: "data seg size",
short: 'd',
unit: Unit::KBytes,
};
const NICE: Self = Self {
resource: Resource::Phy(rlimit::Resource::NICE),
help: "the maximum scheduling priority (`nice`)",
description: "scheduling priority",
short: 'e',
unit: Unit::Number,
};
const FSIZE: Self = Self {
resource: Resource::Phy(rlimit::Resource::FSIZE),
help: "the maximum size of files written by the shell and its children",
description: "file size",
short: 'f',
unit: Unit::Block,
};
const SIGPENDING: Self = Self {
resource: Resource::Phy(rlimit::Resource::SIGPENDING),
help: "the maximum number of pending signals",
description: "pending signals",
short: 'i',
unit: Unit::Number,
};
const MEMLOCK: Self = Self {
resource: Resource::Phy(rlimit::Resource::MEMLOCK),
help: "the maximum size a process may lock into memory",
description: "max locked memory",
short: 'l',
unit: Unit::KBytes,
};
const KQUEUES: Self = Self {
resource: Resource::Phy(rlimit::Resource::KQUEUES),
help: "the maximum number of kqueues allocated for this process",
description: "max kqueues",
short: 'k',
unit: Unit::Number,
};
const RSS: Self = Self {
resource: Resource::Phy(rlimit::Resource::RSS),
help: "the maximum resident set size",
description: "max memory size",
short: 'm',
unit: Unit::KBytes,
};
const LOCKS: Self = Self {
resource: Resource::Phy(rlimit::Resource::LOCKS),
help: "the maximum number of file locks",
description: "file locks",
short: 'x',
unit: Unit::Number,
};
const NOFILE: Self = Self {
resource: Resource::Phy(rlimit::Resource::NOFILE),
help: "the maximum number of open file descriptors",
description: "open files",
short: 'n',
unit: Unit::Number,
};
const MSGQUEUE: Self = Self {
resource: Resource::Phy(rlimit::Resource::MSGQUEUE),
help: "the maximum number of bytes in POSIX message queues",
description: "POSIX message queues",
short: 'q',
unit: Unit::Bytes,
};
const PIPE: Self = Self {
resource: Resource::Virt(Virtual::Pipe),
help: "the pipe buffer size",
description: "pipe size",
short: 'p',
unit: Unit::HalfKBytes,
};
const RTPRIO: Self = Self {
resource: Resource::Phy(rlimit::Resource::RTPRIO),
help: "the maximum real-time scheduling priority",
description: "real-time priority",
short: 'r',
unit: Unit::Number,
};
const RTTIME: Self = Self {
resource: Resource::Phy(rlimit::Resource::RTTIME),
help: "the maximum real-time scheduling priority",
description: "real-time non-blocking time",
short: 'R',
unit: Unit::Micros,
};
const STACK: Self = Self {
resource: Resource::Phy(rlimit::Resource::STACK),
help: "the maximum stack size",
description: "stack size",
short: 's',
unit: Unit::KBytes,
};
const CPU: Self = Self {
resource: Resource::Phy(rlimit::Resource::CPU),
help: "the maximum amount of cpu time in seconds",
description: "cpu time",
short: 't',
unit: Unit::Seconds,
};
const NPROC: Self = Self {
resource: Resource::Phy(rlimit::Resource::NPROC),
help: "the maximum number of user processes",
description: "max user processes",
short: 'u',
unit: Unit::Number,
};
const VMEM: Self = Self {
resource: Resource::Virt(Virtual::VMem),
help: "the size of virtual memory",
description: "virtual memory",
short: 'v',
unit: Unit::KBytes,
};
const THREADS: Self = Self {
resource: Resource::Phy(rlimit::Resource::THREADS),
help: "the maximum number of threads",
description: "number of threads",
short: 'T',
unit: Unit::Number,
};
const NPTS: Self = Self {
resource: Resource::Phy(rlimit::Resource::NPTS),
help: "the maximum number of pseudoterminals",
description: "number of pseudoterminals",
short: 'P',
unit: Unit::Number,
};
fn get(&self, hard: bool) -> std::io::Result<String> {
let (soft_limit, hard_limit) = self.resource.get()?;
let val = if hard { hard_limit } else { soft_limit };
if val == rlimit::INFINITY {
Ok("unlimited".into())
} else {
Ok(format!("{}", val / self.unit.scale()))
}
}
fn set(&self, set_hard: bool, value: LimitValue) -> std::io::Result<()> {
let (soft, hard) = self.resource.get()?;
let value = match value {
LimitValue::Soft => soft,
LimitValue::Hard => hard,
LimitValue::Unlimited => rlimit::INFINITY,
LimitValue::Value(v) => v * self.unit.scale(),
LimitValue::Unset => return Ok(()),
};
if set_hard {
self.resource.set(soft, value)
} else {
self.resource.set(value, hard)
}
}
/// Print either soft or hard limit
fn print(
&self,
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
hard: bool,
) -> io::Result<()> {
if !self.resource.is_supported() {
return Ok(());
}
let unit = match self.unit {
Unit::Block => format!("(block, -{})", self.short),
Unit::Bytes => format!("(bytes, -{})", self.short),
Unit::HalfKBytes => format!("(512 bytes, -{})", self.short),
Unit::KBytes => format!("(kbytes, -{})", self.short),
Unit::Micros => format!("(microseconds, -{})", self.short),
Unit::Number => format!("(-{})", self.short),
Unit::Seconds => format!("(seconds, -{})", self.short),
};
let resource = self.get(hard).unwrap_or_else(|e| format!("{e}"));
writeln!(
context.stdout(),
"{:<26}{:>16} {}",
self.description,
unit,
resource
)
}
/// Provide the matching help String
fn help(&self) -> String {
format!(
"{} {}",
self.help,
if self.resource.is_supported() {
"(supported)"
} else {
"(unsupported)"
}
)
}
}
impl IntoResettable<StyledStr> for ResourceDescription {
fn into_resettable(self) -> clap::builder::Resettable<StyledStr> {
clap::builder::Resettable::Value(self.help().into())
}
}
#[derive(Debug, Clone, Copy)]
enum LimitValue {
Unset,
Unlimited,
Soft,
Hard,
Value(u64),
}
impl FromStr for LimitValue {
type Err = <u64 as FromStr>::Err;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let v = match s {
"" => Self::Unset,
"unlimited" => Self::Unlimited,
"soft" => Self::Soft,
"hard" => Self::Hard,
_ => Self::Value(s.parse()?),
};
Ok(v)
}
}
/// Modify shell resource limits.
///
/// Provides control over the resources available to the shell and processes
/// it creates, on systems that allow such control.
#[derive(Parser, Debug)]
pub(crate) struct ULimitCommand {
/// use the `soft` resource limit
#[arg(short = 'S')]
soft: bool,
/// use the `hard` resource limit
#[arg(short = 'H')]
hard: bool,
/// all current limits are reported
#[arg(short = 'a')]
all: bool,
/// the maximum socket buffer size
#[arg(short = 'b', default_missing_value = "", num_args(0..=1), help = ResourceDescription::SBSIZE)]
sbsize: Option<LimitValue>,
/// the maximum size of core files created
#[arg(short = 'c', default_missing_value = "", num_args(0..=1), help = ResourceDescription::CORE)]
core: Option<LimitValue>,
/// the maximum size of a process's data segment
#[arg(short = 'd', default_missing_value = "", num_args(0..=1), help = ResourceDescription::DATA)]
data: Option<LimitValue>,
/// the maximum scheduling priority (`nice`)
#[arg(short = 'e', default_missing_value = "", num_args(0..=1), help = ResourceDescription::NICE)]
nice: Option<LimitValue>,
/// the maximum size of files written by the shell and its children
#[arg(short = 'f', default_missing_value = "", num_args(0..=1), help = ResourceDescription::FSIZE)]
file_size: Option<LimitValue>,
/// the maximum number of pending signals
#[arg(short = 'i', default_missing_value = "", num_args(0..=1), help = ResourceDescription::SIGPENDING)]
sigpending: Option<LimitValue>,
/// the maximum size a process may lock into memory
#[arg(short = 'l', default_missing_value = "", num_args(0..=1), help = ResourceDescription::MEMLOCK)]
memlock: Option<LimitValue>,
/// the maximum number of kqueues allocated for this process
#[arg(short = 'k', default_missing_value = "", num_args(0..=1), help = ResourceDescription::KQUEUES)]
kqueues: Option<LimitValue>,
/// the maximum resident set size
#[arg(short = 'm', default_missing_value = "", num_args(0..=1), help = ResourceDescription::RSS)]
rss: Option<LimitValue>,
/// the maximum number of open file descriptors
#[arg(short = 'n', default_missing_value = "", num_args(0..=1), help = ResourceDescription::NOFILE)]
file_open: Option<LimitValue>,
/// the pipe buffer size
#[arg(short = 'p', default_missing_value = "", num_args(0..=1), help = ResourceDescription::PIPE)]
pipe: Option<LimitValue>,
/// the maximum number of bytes in POSIX message queues
#[arg(short = 'q', default_missing_value = "", num_args(0..=1), help = ResourceDescription::MSGQUEUE)]
msgqueue: Option<LimitValue>,
/// the maximum real-time scheduling priority
#[arg(short = 'r', default_missing_value = "", num_args(0..=1), help = ResourceDescription::RTPRIO)]
rtprio: Option<LimitValue>,
/// the maximum stack size
#[arg(short = 's', default_missing_value = "", num_args(0..=1), help = ResourceDescription::STACK)]
stack: Option<LimitValue>,
/// the maximum amount of cpu time in seconds
#[arg(short = 't', default_missing_value = "", num_args(0..=1), help = ResourceDescription::CPU)]
cpu: Option<LimitValue>,
/// the size of virtual memory
#[arg(short = 'u', default_missing_value = "", num_args(0..=1), help = ResourceDescription::NPROC)]
nproc: Option<LimitValue>,
/// the size of virtual memory
#[arg(short = 'v', default_missing_value = "", num_args(0..=1), help = ResourceDescription::VMEM)]
vmem: Option<LimitValue>,
/// the maximum number of file locks
#[arg(short = 'x', default_missing_value = "", num_args(0..=1), help = ResourceDescription::LOCKS)]
file_lock: Option<LimitValue>,
/// the maximum number of pseudoterminals
#[arg(short = 'P', default_missing_value = "", num_args(0..=1), help = ResourceDescription::NPTS)]
npts: Option<LimitValue>,
/// real-time non-blocking time
#[arg(short = 'R', default_missing_value = "", num_args(0..=1), help = ResourceDescription::RTTIME)]
rttime: Option<LimitValue>,
/// the maximum number of threads
#[arg(short = 'T', default_missing_value = "", num_args(0..=1), help = ResourceDescription::THREADS)]
threads: Option<LimitValue>,
/// argument for the implicit limit (`-f`)
limit: Option<LimitValue>,
}
impl builtins::Command for ULimitCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
let exit_code = ExecutionResult::success();
let mut resources_to_set = Vec::new();
let mut resources_to_get = Vec::new();
let mut set_or_get = |val, descr| {
match val {
Some(LimitValue::Unset) => resources_to_get.push(descr),
Some(v) => resources_to_set.push((descr, v)),
None => {}
}
if self.all {
resources_to_get.push(descr);
}
};
set_or_get(self.sbsize, ResourceDescription::SBSIZE);
set_or_get(self.core, ResourceDescription::CORE);
set_or_get(self.data, ResourceDescription::DATA);
set_or_get(self.file_size, ResourceDescription::FSIZE);
set_or_get(self.sigpending, ResourceDescription::SIGPENDING);
set_or_get(self.kqueues, ResourceDescription::KQUEUES);
set_or_get(self.memlock, ResourceDescription::MEMLOCK);
set_or_get(self.rss, ResourceDescription::RSS);
set_or_get(self.file_lock, ResourceDescription::LOCKS);
set_or_get(self.file_open, ResourceDescription::NOFILE);
set_or_get(self.pipe, ResourceDescription::PIPE);
set_or_get(self.npts, ResourceDescription::NPTS);
set_or_get(self.nice, ResourceDescription::NICE);
set_or_get(self.msgqueue, ResourceDescription::MSGQUEUE);
set_or_get(self.rtprio, ResourceDescription::RTPRIO);
set_or_get(self.rttime, ResourceDescription::RTTIME);
set_or_get(self.stack, ResourceDescription::STACK);
set_or_get(self.threads, ResourceDescription::THREADS);
set_or_get(self.cpu, ResourceDescription::CPU);
set_or_get(self.nproc, ResourceDescription::NPROC);
set_or_get(self.vmem, ResourceDescription::VMEM);
if resources_to_set.is_empty() {
if resources_to_get.is_empty() {
if let Some(fsize) = self.limit {
resources_to_set.push((ResourceDescription::FSIZE, fsize));
} else {
resources_to_get.push(ResourceDescription::FSIZE);
}
}
}
for (resource, value) in resources_to_set {
resource.set(self.hard, value)?;
}
if resources_to_get.len() == 1 {
writeln!(context.stdout(), "{}", resources_to_get[0].get(self.hard)?)?;
} else {
for resource in resources_to_get {
resource.print(&context, self.hard)?;
}
}
Ok(exit_code)
}
}
+97
View File
@@ -0,0 +1,97 @@
use brush_core::{ErrorKind, ExecutionResult, builtins};
use cfg_if::cfg_if;
use clap::Parser;
#[cfg(not(any(target_os = "linux", target_os = "android")))]
use nix::sys::stat::Mode;
use std::io::Write;
/// Manage the process umask.
#[derive(Parser)]
pub(crate) struct UmaskCommand {
/// If MODE is omitted, output in a form that may be reused as input.
#[arg(short = 'p')]
print_roundtrippable: bool,
/// Makes the output symbolic; otherwise an octal number is given.
#[arg(short = 'S')]
symbolic_output: bool,
/// Mode mask.
mode: Option<String>,
}
impl builtins::Command for UmaskCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
if let Some(mode) = &self.mode {
if mode.starts_with(|c: char| c.is_digit(8)) {
let parsed = brush_core::int_utils::parse(mode.as_str(), 8)?;
set_umask(parsed)?;
} else {
return brush_core::error::unimp("umask setting mode from symbolic value");
}
} else {
let umask = get_umask()?;
let formatted = if self.symbolic_output {
let u = symbolic_mask_from_bits((!umask & 0o700) >> 6);
let g = symbolic_mask_from_bits((!umask & 0o070) >> 3);
let o = symbolic_mask_from_bits(!umask & 0o007);
std::format!("u={u},g={g},o={o}")
} else {
std::format!("{umask:04o}")
};
if self.print_roundtrippable {
writeln!(context.stdout(), "umask {formatted}")?;
} else {
writeln!(context.stdout(), "{formatted}")?;
}
}
Ok(ExecutionResult::success())
}
}
cfg_if! {
if #[cfg(any(target_os = "linux", target_os = "android"))] {
fn get_umask() -> Result<u32, brush_core::Error> {
let umask = procfs::process::Process::myself().ok().and_then(|me| me.status().ok()).and_then(|status| status.umask);
umask.ok_or_else(|| brush_core::ErrorKind::InvalidUmask.into())
}
} else {
#[expect(clippy::unnecessary_wraps)]
fn get_umask() -> Result<u32, brush_core::Error> {
let u = nix::sys::stat::umask(Mode::empty());
nix::sys::stat::umask(u);
Ok(u32::from(u.bits()))
}
}
}
fn set_umask(value: nix::sys::stat::mode_t) -> Result<(), brush_core::Error> {
// value of mode_t can be platform dependent
let mode = nix::sys::stat::Mode::from_bits(value).ok_or_else(|| ErrorKind::InvalidUmask)?;
nix::sys::stat::umask(mode);
Ok(())
}
fn symbolic_mask_from_bits(bits: u32) -> String {
let mut result = String::new();
if (bits & 0b100) != 0 {
result.push('r');
}
if (bits & 0b010) != 0 {
result.push('w');
}
if (bits & 0b001) != 0 {
result.push('x');
}
result
}
+44
View File
@@ -0,0 +1,44 @@
use clap::Parser;
use std::io::Write;
use brush_core::{ExecutionResult, builtins};
/// Unset a shell alias.
#[derive(Parser)]
pub(crate) struct UnaliasCommand {
/// Remove all aliases.
#[arg(short = 'a')]
remove_all: bool,
/// Names of aliases to operate on.
aliases: Vec<String>,
}
impl builtins::Command for UnaliasCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
let mut exit_code = ExecutionResult::success();
if self.remove_all {
context.shell.aliases_mut().clear();
} else {
for alias in &self.aliases {
if context.shell.aliases_mut().remove(alias).is_none() {
writeln!(
context.stderr(),
"{}: {}: not found",
context.command_name,
alias
)?;
exit_code = ExecutionResult::general_error();
}
}
}
Ok(exit_code)
}
}
+35
View File
@@ -0,0 +1,35 @@
use brush_core::{ExecutionExitCode, builtins, trace_categories};
use clap::Parser;
/// (UNIMPLEMENTED COMMAND)
#[derive(Parser)]
pub(crate) struct UnimplementedCommand {
#[clap(allow_hyphen_values = true)]
args: Vec<String>,
#[clap(skip)]
declarations: Vec<brush_core::CommandArg>,
}
impl builtins::Command for UnimplementedCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
tracing::warn!(target: trace_categories::UNIMPLEMENTED,
"unimplemented built-in: {} {}",
context.command_name,
self.args.join(" ")
);
Ok(ExecutionExitCode::Unimplemented.into())
}
}
impl builtins::DeclarationCommand for UnimplementedCommand {
fn set_declarations(&mut self, declarations: Vec<brush_core::CommandArg>) {
self.declarations = declarations;
}
}
+125
View File
@@ -0,0 +1,125 @@
use std::borrow::Cow;
use clap::Parser;
use brush_core::{ExecutionResult, Shell, ShellValue, builtins, variables::ShellValueUnsetType};
/// Unset a variable.
#[derive(Parser)]
pub(crate) struct UnsetCommand {
#[clap(flatten)]
name_interpretation: UnsetNameInterpretation,
/// Names of variables to unset.
names: Vec<String>,
}
#[derive(Parser)]
#[clap(group = clap::ArgGroup::new("name-interpretation").multiple(false).required(false))]
pub(crate) struct UnsetNameInterpretation {
/// Treat each name as a shell function.
#[arg(short = 'f', group = "name-interpretation")]
shell_functions: bool,
/// Treat each name as a shell variable.
#[arg(short = 'v', group = "name-interpretation")]
shell_variables: bool,
/// Treat each name as a name reference.
#[arg(short = 'n', group = "name-interpretation")]
name_references: bool,
}
impl UnsetNameInterpretation {
pub const fn unspecified(&self) -> bool {
!self.shell_functions && !self.shell_variables && !self.name_references
}
}
impl builtins::Command for UnsetCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
//
// TODO(nameref): implement nameref
//
if self.name_interpretation.name_references {
return brush_core::error::unimp("unset: name references are not yet implemented");
}
let unspecified = self.name_interpretation.unspecified();
#[expect(clippy::needless_continue)]
for name in &self.names {
if unspecified || self.name_interpretation.shell_variables {
// Try to parse the name as a parameter. If we can't, don't bail; it may not be a
// valid variable name/parameter but could still be a function name.
if let Ok(parameter) =
brush_parser::word::parse_parameter(name, &context.shell.parser_options())
{
let result = match parameter {
brush_parser::word::Parameter::Positional(_) => continue,
brush_parser::word::Parameter::Special(_) => continue,
brush_parser::word::Parameter::Named(name) => {
context.shell.env_mut().unset(name.as_str())?.is_some()
}
brush_parser::word::Parameter::NamedWithIndex { name, index } => {
unset_array_index(context.shell, name.as_str(), index.as_str())?
}
brush_parser::word::Parameter::NamedWithAllIndices {
name: _,
concatenate: _,
} => continue,
};
if result {
continue;
}
}
}
// TODO(unset): Deal with readonly functions
if unspecified || self.name_interpretation.shell_functions {
if context.shell.undefine_func(name) {
continue;
}
}
}
Ok(ExecutionResult::success())
}
}
fn unset_array_index(
shell: &mut Shell<impl brush_core::ShellExtensions>,
name: &str,
index: &str,
) -> Result<bool, brush_core::Error> {
// First check to see if it's an associative array.
let is_assoc_array = if let Some((_, var)) = shell.env().get(name) {
matches!(
var.value(),
ShellValue::AssociativeArray(_)
| ShellValue::Unset(ShellValueUnsetType::AssociativeArray)
)
} else {
false
};
// Compute which index we should actually use. For indexed arrays, we need to evaluate
// the index string as an arithmetic expression first.
let index_to_use: Cow<'_, str> = if is_assoc_array {
index.into()
} else {
// First evaluate the index expression.
let index_as_expr = brush_parser::arithmetic::parse(index)?;
let evaluated_index = shell.eval_arithmetic(&index_as_expr)?;
evaluated_index.to_string().into()
};
// Now we can try to unset, and return the result.
shell.env_mut().unset_index(name, index_to_use.as_ref())
}
+79
View File
@@ -0,0 +1,79 @@
use clap::Parser;
use std::io::Write;
use brush_core::{ExecutionExitCode, ExecutionResult, builtins, error};
/// Wait for jobs to terminate.
#[derive(Parser)]
pub(crate) struct WaitCommand {
/// Wait for specified job to terminate (instead of change status).
#[arg(short = 'f')]
wait_for_terminate: bool,
/// Wait for a single job to change status; if jobs are specified, waits for
/// the first to change status, and otherwise waits for the next change.
#[arg(short = 'n')]
wait_for_first_or_next: bool,
/// Name of variable to receive the job ID of the job whose status is indicated.
#[arg(short = 'p', value_name = "VAR_NAME")]
variable_to_receive_id: Option<String>,
/// Process IDs or job specs to wait for.
ids: Vec<String>,
}
impl builtins::Command for WaitCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<ExecutionResult, Self::Error> {
if self.wait_for_terminate {
return error::unimp("wait -f");
}
if self.wait_for_first_or_next {
return error::unimp("wait -n");
}
if self.variable_to_receive_id.is_some() {
return error::unimp("wait -p");
}
let mut result = ExecutionResult::success();
if !self.ids.is_empty() {
for id in &self.ids {
if id.starts_with('%') {
// It's a job spec.
if let Some(job) = context.shell.jobs_mut().resolve_job_spec(id) {
job.wait().await?;
} else {
writeln!(
context.stderr(),
"{}: no such job: {}",
context.command_name,
id
)?;
result = ExecutionExitCode::GeneralError.into();
}
} else {
// It's a process ID.
return error::unimp("wait with process IDs");
}
}
} else {
// Wait for all jobs.
let jobs = context.shell.jobs_mut().wait_all().await?;
if context.shell.options().enable_job_control {
for job in jobs {
writeln!(context.stdout(), "{job}")?;
}
}
}
Ok(result)
}
}
+89
View File
@@ -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.17.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.1", features = ["io-util", "macros", "rt", "sync"] }
[target.'cfg(any(unix, windows))'.dependencies]
hostname = "0.4.2"
tokio = { version = "1.50.0", 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
+71
View File
@@ -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(())
}
+152
View File
@@ -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(())
}
+423
View File
@@ -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
}
+89
View File
@@ -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())
}
}
}
+570
View File
@@ -0,0 +1,570 @@
//! 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 {
// N.B. clap doesn't support named options like '+x'. To work around this, we
// establish a pattern of renaming them.
let mut updated_args = vec![];
for arg in args {
if let Some(plus_options) = arg.strip_prefix("+") {
for c in plus_options.chars() {
updated_args.push(format!("--+{c}"));
}
} else {
updated_args.push(arg);
}
}
Self::try_parse_from(updated_args)
}
}
/// Returns whether or not the command takes options with a leading '+' or '-' character.
fn takes_plus_options() -> bool {
false
}
/// Executes the built-in command in the provided context.
///
/// # Arguments
///
/// * `context` - The context in which the command is being executed.
// NOTE: we use desugared async here because we need a Send marker
fn execute<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)
}

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