diff --git a/.gitignore b/.gitignore index 84483ac12e..364b8ecf03 100644 --- a/.gitignore +++ b/.gitignore @@ -74,4 +74,3 @@ sources/x86_64-unknown-redox/ sources/*.tar.gz Packages/*.pkgar .slim/deepwork/ -local/recipes/shells/brush/source diff --git a/local/recipes/shells/brush/source/.cargo/config.toml b/local/recipes/shells/brush/source/.cargo/config.toml new file mode 100644 index 0000000000..6e8d95a159 --- /dev/null +++ b/local/recipes/shells/brush/source/.cargo/config.toml @@ -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"] diff --git a/local/recipes/shells/brush/source/.cliffignore b/local/recipes/shells/brush/source/.cliffignore new file mode 100644 index 0000000000..fafa76fa46 --- /dev/null +++ b/local/recipes/shells/brush/source/.cliffignore @@ -0,0 +1,9 @@ +# Commits not to include in changelog + +a8ba7982d9ca3746cd6f6e3c26783e9d1d765466 + +448e60c51cb721bd6991c00bc39e926c7a1ffdac +6db7ffa0d3ccfbf1e757d5a4eb096fd51d65cc19 +5400c8267db15ed56b5758a6fc35667678a5c856 +87ad64f9ae2044d1aa70effd50244491a765c33e +6567ede8f0a9be622176dc5e840e5b93344aeb15 diff --git a/local/recipes/shells/brush/source/.config/nextest.toml b/local/recipes/shells/brush/source/.config/nextest.toml new file mode 100644 index 0000000000..34500a2aef --- /dev/null +++ b/local/recipes/shells/brush/source/.config/nextest.toml @@ -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" diff --git a/local/recipes/shells/brush/source/.github/ISSUE_TEMPLATE/compatibility-bug.yml b/local/recipes/shells/brush/source/.github/ISSUE_TEMPLATE/compatibility-bug.yml new file mode 100644 index 0000000000..e0fd2132ae --- /dev/null +++ b/local/recipes/shells/brush/source/.github/ISSUE_TEMPLATE/compatibility-bug.yml @@ -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 diff --git a/local/recipes/shells/brush/source/.github/ISSUE_TEMPLATE/config.yml b/local/recipes/shells/brush/source/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..83e764702d --- /dev/null +++ b/local/recipes/shells/brush/source/.github/ISSUE_TEMPLATE/config.yml @@ -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. diff --git a/local/recipes/shells/brush/source/.github/ISSUE_TEMPLATE/feature-request.yml b/local/recipes/shells/brush/source/.github/ISSUE_TEMPLATE/feature-request.yml new file mode 100644 index 0000000000..7a3283fadf --- /dev/null +++ b/local/recipes/shells/brush/source/.github/ISSUE_TEMPLATE/feature-request.yml @@ -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 diff --git a/local/recipes/shells/brush/source/.github/ISSUE_TEMPLATE/other.yml b/local/recipes/shells/brush/source/.github/ISSUE_TEMPLATE/other.yml new file mode 100644 index 0000000000..cf0010277f --- /dev/null +++ b/local/recipes/shells/brush/source/.github/ISSUE_TEMPLATE/other.yml @@ -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 diff --git a/local/recipes/shells/brush/source/.github/copilot-instructions.md b/local/recipes/shells/brush/source/.github/copilot-instructions.md new file mode 100644 index 0000000000..2fc777fe66 --- /dev/null +++ b/local/recipes/shells/brush/source/.github/copilot-instructions.md @@ -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 + cargo test --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//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. diff --git a/local/recipes/shells/brush/source/.github/dependabot.yml b/local/recipes/shells/brush/source/.github/dependabot.yml new file mode 100644 index 0000000000..20f5531396 --- /dev/null +++ b/local/recipes/shells/brush/source/.github/dependabot.yml @@ -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: + - "*" diff --git a/local/recipes/shells/brush/source/.github/workflows/cd.yaml b/local/recipes/shells/brush/source/.github/workflows/cd.yaml new file mode 100644 index 0000000000..28a5b19073 --- /dev/null +++ b/local/recipes/shells/brush/source/.github/workflows/cd.yaml @@ -0,0 +1,203 @@ +# +# Based on https://github.com/release-plz/release-plz/blob/a5043c478d46d051c00e4fbc85036ac22510f07e/.github/workflows/cd.yml +# + +name: CD # Continuous Deployment +run-name: CD${{ github.event_name == 'release' && ' (release)' || ' (dry run)' }} + +on: + release: + types: [published] + + # Manual triggers don't actually publish but dry-run the builds. + workflow_dispatch: null + + # Run on PR in dry-run mode to make sure this workflow is still generally + # working. + pull_request: + branches: ["main"] + + # Run on pushes into `main` as a way to have "nightly"-ish binaries. + push: + branches: ["main"] + +# Only allow one run of the workflow per branch / PR at a time. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + CARGO_INCREMENTAL: 0 + CARGO_NET_GIT_FETCH_WITH_CLI: true + CARGO_NET_RETRY: 10 + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + RUSTFLAGS: -D warnings + RUSTUP_MAX_RETRIES: 10 + +defaults: + run: + shell: bash + +permissions: {} + +jobs: + upload-docs: + name: "Generate and upload documentation" + + permissions: + contents: write + id-token: write + attestations: write + + if: github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request' || github.event_name == 'push' || (github.event_name == 'release' && github.repository_owner == 'reubeno' && startsWith(github.event.release.tag_name, 'brush-shell-v')) + runs-on: ubuntu-24.04 + timeout-minutes: 30 + + steps: + - name: "Checkout repository" + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: "Install Rust toolchain" + uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + with: + toolchain: stable + + - name: "Enable cargo cache" + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + + - name: "Generate documentation distribution" + run: cargo xtask gen docs dist --out brush-docs.tar.gz + + - name: "Upload documentation to release" + if: github.event_name == 'release' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + gh release upload ${GITHUB_RELEASE_TAG} \ + brush-docs.tar.gz \ + brush-docs.tar.gz.sha256 \ + brush-docs.tar.gz.sha512 + + - name: "Generate artifact attestation" + if: github.event_name == 'release' + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-path: "brush-docs.tar.gz*" + + - name: "Upload documentation artifact (dry-run)" + if: github.event_name != 'release' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: brush-docs + path: | + brush-docs.tar.gz + brush-docs.tar.gz.sha256 + brush-docs.tar.gz.sha512 + + upload-assets: + name: ${{ matrix.target }} + + permissions: + contents: write + id-token: write + attestations: write + + if: github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request' || github.event_name == 'push' || (github.event_name == 'release' && github.repository_owner == 'reubeno' && startsWith(github.event.release.tag_name, 'brush-shell-v')) + runs-on: ${{ matrix.os }} + strategy: + # Run all jobs to completion regardless of errors. + # This is useful because sometimes we fail to compile for a certain target. + fail-fast: false + matrix: + include: + - target: aarch64-pc-windows-msvc + os: windows-2025 + force_dry_run: true + - target: x86_64-unknown-linux-gnu + os: ubuntu-22.04 + force_dry_run: false + - target: x86_64-apple-darwin + os: macos-15-intel + force_dry_run: false + - target: x86_64-pc-windows-msvc + os: windows-2025 + force_dry_run: true + - target: x86_64-unknown-linux-musl + os: ubuntu-22.04 + force_dry_run: false + - target: aarch64-unknown-linux-gnu + os: ubuntu-22.04 + force_dry_run: false + - target: aarch64-unknown-linux-musl + os: ubuntu-22.04 + force_dry_run: false + - target: aarch64-apple-darwin + os: macos-14 + force_dry_run: false + timeout-minutes: 60 + + steps: + - name: "Checkout repository" + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: "Install Rust toolchain" + uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 + + - name: "Setup cross-compiling toolchain" + if: startsWith(matrix.os, 'ubuntu') && !contains(matrix.target, '-musl') + uses: taiki-e/setup-cross-toolchain-action@3d9770ce98eb7dbcf378563182a5e8031165f75b # v1.41.0 + with: + target: ${{ matrix.target }} + + - name: "Install musl cross tools" + if: contains(matrix.target, '-musl') + uses: taiki-e/install-action@43aecc8d72668fbcfe75c31400bc4f890f1c5853 # v2.83.2 + with: + tool: cross + + - name: "Workaround: clean cache" + run: cargo clean + + - name: "Install cargo-about" + run: cargo install cargo-about --locked --features cli + + - name: "Generate license notices" + working-directory: brush-shell + run: cargo about generate -o ../THIRD_PARTY_LICENSES.html about.hbs + + - name: "Update build flags" + if: endsWith(matrix.target, 'windows-msvc') + run: echo "RUSTFLAGS=${RUSTFLAGS} -C target-feature=+crt-static" >> "${GITHUB_ENV}" + + - name: "Build and upload binaries to release" + uses: taiki-e/upload-rust-binary-action@f0d45ae91ee7b8ee928de7a9d04d893a08bcbec6 # v1.30.2 + id: upload-release + with: + dry-run: ${{ github.event_name != 'release' || matrix.force_dry_run }} + bin: brush + locked: true + target: ${{ matrix.target }} + tar: unix + zip: windows + checksum: sha256,sha512 + token: ${{ secrets.GITHUB_TOKEN }} + include: "LICENSE,THIRD_PARTY_LICENSES.html" + + - name: "Generate artifact attestation" + if: github.event_name == 'release' && !matrix.force_dry_run + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-path: "${{ steps.upload-release.outputs.archive }}.*" + + - name: "Upload artifacts (dry-run)" + if: github.event_name != 'release' || matrix.force_dry_run + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: brush-${{ matrix.target }} + path: "${{ steps.upload-release.outputs.archive }}.*" diff --git a/local/recipes/shells/brush/source/.github/workflows/ci-reports.yaml b/local/recipes/shells/brush/source/.github/workflows/ci-reports.yaml new file mode 100644 index 0000000000..0c5d10ed0f --- /dev/null +++ b/local/recipes/shells/brush/source/.github/workflows/ci-reports.yaml @@ -0,0 +1,138 @@ +name: "PR Reports" +on: + # We intentionally run this workflow in the context of the target of a PR; we are careful + # an intentional about how we handle the data coming from the PR. + workflow_run: # zizmor: ignore[dangerous-triggers] + workflows: ["CI"] + types: + - completed + +permissions: {} + +jobs: + report: + name: "Report" + + permissions: + actions: read + checks: write + contents: read + pull-requests: write + + runs-on: ubuntu-24.04 + + # We only run this job if the workflow run that completed was triggered by a pull request. + if: github.event.workflow_run.event == 'pull_request' + + steps: + - name: Extract PR number and create event file + id: get-pr + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const fs = require('fs'); + + // Find the workflow run that triggered this job. + const workflowRun = await github.rest.actions.getWorkflowRun({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: context.payload.workflow_run.id, + }); + + // Extract the source commit info. + const { head_branch, head_sha, head_repository } = workflowRun.data; + const head_repo_owner = head_repository.owner.login; + core.setOutput('head_branch', head_branch); + core.setOutput('head_sha', head_sha); + core.setOutput('head_repo', head_repository.full_name); + + // Try to find all PRs from that source. + const { data: prs } = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + head: `${head_repo_owner}:${head_branch}`, + per_page: 20 + }); + + // Filter PRs to find the one that targets the main branch. + // NOTE: This only supports a target branch of 'main'. + const filtered_prs = prs.filter(pr => pr.base.ref === 'main'); + + if (filtered_prs.length > 0) { + const pr = filtered_prs[0]; + core.setOutput('pr_number', pr.number); + + // Write out a mostly-stubbed event file. + const eventData = { + pull_request: { + head: { + sha: head_sha, + repo: { + full_name: head_repository.full_name + } + } + } + }; + + fs.writeFileSync('event-file.json', JSON.stringify(eventData), 'utf-8'); + } else { + core.setOutput('pr_number', ''); + } + + - name: Download code coverage reports + if: steps.get-pr.outputs.pr_number != '' + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: codecov-reports* + merge-multiple: true + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ github.event.workflow_run.id }} + path: reports/ + + - name: Download performance reports + if: steps.get-pr.outputs.pr_number != '' + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: perf-reports* + merge-multiple: true + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ github.event.workflow_run.id }} + path: reports/ + + - name: Download test results + if: steps.get-pr.outputs.pr_number != '' + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: test-reports* + merge-multiple: true + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ github.event.workflow_run.id }} + path: reports/ + + - name: Show published reports + continue-on-error: true + if: steps.get-pr.outputs.pr_number != '' + run: | + ls -lR reports/ + + - name: "Publish test results" + uses: EnricoMi/publish-unit-test-result-action@d0a4676d0e0b938bc201470d88276b7c74c712b3 # v2.24.0 + if: steps.get-pr.outputs.pr_number != '' + continue-on-error: true + with: + commit: ${{ github.event.workflow_run.head_sha }} + event_file: event-file.json + event_name: ${{ github.event.workflow_run.event }} + files: reports/test-results-*.xml + + - name: "Publish available .md reports to PR" + uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5 + if: steps.get-pr.outputs.pr_number != '' + with: + path: reports/*.md + number: ${{ steps.get-pr.outputs.pr_number }} diff --git a/local/recipes/shells/brush/source/.github/workflows/ci.yaml b/local/recipes/shells/brush/source/.github/workflows/ci.yaml new file mode 100644 index 0000000000..6a21407f8d --- /dev/null +++ b/local/recipes/shells/brush/source/.github/workflows/ci.yaml @@ -0,0 +1,764 @@ +name: "CI" +on: + pull_request: + paths-ignore: + - "release-plz.toml" + push: + paths-ignore: + - "docs/**" + - "**.md" + - "LICENSE" + - "release-plz.toml" + branches: + - main + +env: + RUST_BACKTRACE: 1 + CARGO_TERM_COLOR: always + CLICOLOR: 1 + CLICOLOR_FORCE: 1 + +permissions: + actions: read + contents: read + +# Only allow one run of the workflow per branch / PR at a time. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + # Build and upload release binaries for all relevant architectures. + build: + strategy: + fail-fast: false + matrix: + include: + # Build for x86_64/linux target on native host. + # N.B. We intentionally pin to Ubuntu 22.04 for now to increase + # the range of distros that will be able to run the produced binaries. + # Newer release of Ubuntu upgrade glibc to a point not yet supported + # by some latest stable versions of distros. + - host: "ubuntu-22.04" + target: "" + cross_command: "" + cross_tool_to_install: "" + os: "linux" + arch: "x86_64" + binary_name: "brush" + extra_build_args: "" + # Build for x86_64/linux using musl as target C runtime. + # We don't bother pinning to an ealier version of Ubuntu because + # the produced binary will statically link the C runtime anyhow. + - host: "ubuntu-24.04" + target: "x86_64-unknown-linux-musl" + cross_command: "cross" + cross_tool_to_install: "cross" + os: "linux-musl" + arch: "x86_64" + binary_name: "brush" + extra_build_args: "" + # Build for aarch64/macos target on native host. + - host: "macos-latest" + target: "" + cross_command: "" + cross_tool_to_install: "" + os: "macos" + arch: "aarch64" + required_tools: "" + binary_name: "brush" + extra_build_args: "" + # Build for aarch64/linux target on x86_64/Linux host. + - host: "ubuntu-24.04" + target: "aarch64-unknown-linux-gnu" + cross_command: "cross" + cross_tool_to_install: "cross" + os: "linux" + arch: "aarch64" + required_tools: "gcc-aarch64-linux-gnu" + binary_name: "brush" + extra_build_args: "" + # Build for wasm32 target on x86_64/linux host. + - host: "ubuntu-24.04" + target: "wasm32-unknown-unknown" + cross_command: "cross" + cross_tool_to_install: "cross" + os: "unknown" + arch: "wasm32" + required_tools: "" + binary_name: "brush.wasm" + extra_build_args: "--no-default-features --features minimal" + # Build for WASI-0.2 target on x86_64/linux host. + - host: "ubuntu-24.04" + target: "wasm32-wasip2" + cross_command: "cross" + cross_tool_to_install: "cross" + os: "wasi-0.2" + arch: "wasm32" + required_tools: "" + binary_name: "brush.wasm" + extra_build_args: "--no-default-features --features minimal" + # Build for x86_64/windows target on x86_64/linux host. + - host: "ubuntu-24.04" + target: "x86_64-pc-windows-gnu" + cross_command: "cross" + cross_tool_to_install: "cross" + os: "windows" + arch: "x86_64" + required_tools: "" + binary_name: "brush.exe" + extra_build_args: "" + # Build for x86_64/android target on x86_64/linux host. + - host: "ubuntu-24.04" + target: "x86_64-linux-android" + cross_command: "cargo ndk" + cross_tool_to_install: "cargo-ndk" + os: "android" + arch: "x86_64" + required_tools: "" + binary_name: "brush" + extra_build_args: "" + # x86_64/FreeBSD target on x86_64/linux host, via cross. Compile-check + # only (check_only): cross's curated sysroot omits some base-system + # libraries (libgeom/libkvm/...) that `sysinfo` links against, so the + # final link of a real binary fails. Checking still validates that the + # shell compiles for the target; producing a binary is a TODO (would + # need a full sysroot with those libraries present). + - host: "ubuntu-24.04" + target: "x86_64-unknown-freebsd" + cross_command: "cross" + cross_tool_to_install: "cross" + os: "freebsd" + arch: "x86_64" + required_tools: "" + binary_name: "brush" + extra_build_args: "" + check_only: true + # x86_64/NetBSD target on x86_64/linux host, via cross. Compile-check + # only for the same reason as FreeBSD above. + - host: "ubuntu-24.04" + target: "x86_64-unknown-netbsd" + cross_command: "cross" + cross_tool_to_install: "cross" + os: "netbsd" + arch: "x86_64" + required_tools: "" + binary_name: "brush" + extra_build_args: "" + check_only: true + # OpenBSD is tier-3 with no prebuilt std, so even compile-checking it + # requires building std from source (-Zbuild-std, hence nightly + + # rust-src + no rustup target). panic_abort is built explicitly because + # our release profile sets panic = "abort" (plain -Zbuild-std only + # builds std + panic_unwind). Compile-check only, like the other BSDs; + # check links nothing, so no C cross-compiler/sysroot/linker is needed + # (none of the workspace's C-compiling deps are in this default graph). + - host: "ubuntu-24.04" + target: "x86_64-unknown-openbsd" + cross_command: "cargo" + cross_tool_to_install: "" + os: "openbsd" + arch: "x86_64" + required_tools: "" + binary_name: "brush" + extra_build_args: "-Zbuild-std=std,panic_abort" + toolchain: "nightly" + components: "rust-src" + # std is built from source, so there's no prebuilt target to install. + skip_rustup_target: true + check_only: true + + name: "${{ matrix.check_only && 'Check' || 'Build' }} (${{ matrix.arch }}/${{ matrix.os }})" + runs-on: ${{ matrix.host }} + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Set up rust toolchain + uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + with: + toolchain: ${{ matrix.toolchain || 'stable' }} + targets: ${{ !matrix.skip_rustup_target && matrix.target || '' }} + components: ${{ matrix.components || '' }} + + - name: Enable cargo cache + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + with: + key: "${{ matrix.target }}" + + - name: Install additional prerequisite tools + if: ${{ matrix.required_tools != '' }} + env: + REQUIRED_TOOLS: ${{ matrix.required_tools }} + run: sudo apt-get update -y && sudo apt-get install -y ${REQUIRED_TOOLS} + + - name: Install cross-compilation toolchain + if: ${{ matrix.cross_tool_to_install != '' }} + uses: taiki-e/install-action@43aecc8d72668fbcfe75c31400bc4f890f1c5853 # v2.83.2 + with: + tool: ${{ matrix.cross_tool_to_install }} + + - name: "Build (native)" + if: ${{ matrix.target == '' }} + env: + EXTRA_BUILD_ARGS: ${{ matrix.extra_build_args }} + run: cargo build --release --all-targets ${EXTRA_BUILD_ARGS} + + - name: "${{ matrix.check_only && 'Check' || 'Build' }} (cross)" + if: ${{ matrix.target != '' }} + env: + CROSS_COMMAND: ${{ matrix.cross_command }} + CARGO_SUBCOMMAND: ${{ matrix.check_only && 'check' || 'build' }} + TARGET: ${{ matrix.target }} + EXTRA_BUILD_ARGS: ${{ matrix.extra_build_args }} + CARGO_NDK_PLATFORM: 24 + run: ${CROSS_COMMAND} ${CARGO_SUBCOMMAND} --release --target=${TARGET} ${EXTRA_BUILD_ARGS} + + - name: "Upload binaries" + # check_only targets don't produce a binary to upload. + if: ${{ !matrix.check_only }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: binaries-${{ matrix.arch }}-${{ matrix.os }} + path: target/${{ matrix.target }}/release/${{ matrix.binary_name }} + + - name: "Upload integration test binaries" + if: ${{ matrix.target == '' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: integration-tests-${{ matrix.arch }}-${{ matrix.os }} + path: | + target/${{ matrix.target }}/release/deps/brush_*_tests-* + !**/*.d + + # Test functional correctness + test: + strategy: + fail-fast: false + matrix: + include: + - host: "ubuntu-24.04" + variant: "linux-x86_64" + artifact_suffix: "linux-x86_64" + name_suffix: "(linux/x86_64)" + homebrew_supported: true + coverage: true + coverage_min_percent: 70 + + - host: "ubuntu-24.04-arm" + variant: "linux-aarch64" + artifact_suffix: "linux-aarch64" + name_suffix: "(linux/aarch64)" + homebrew_supported: false + coverage: true + coverage_min_percent: 70 + + - host: "macos-latest" + variant: "macos" + artifact_suffix: "-macos" + name_suffix: "(macOS)" + homebrew_supported: true + coverage: true + coverage_min_percent: 70 + + - host: "windows-2025" + variant: "windows" + artifact_suffix: "-windows" + name_suffix: "(windows)" + homebrew_supported: false + coverage: true + coverage_min_percent: 20 + + - host: "ubuntu-24.04" + variant: "wasi" + artifact_suffix: "-wasi" + name_suffix: "(wasm32/wasi-0.2)" + homebrew_supported: false + coverage: false + extra_rust_targets: "wasm32-wasip2" + wasi_runtime: "wasmtime" + xtask_test_args: "--wasi" + + name: "Test ${{ matrix.name_suffix }}" + runs-on: ${{ matrix.host }} + defaults: + run: + shell: bash + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Set up rust toolchain + uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + with: + toolchain: stable + targets: ${{ matrix.extra_rust_targets || '' }} + components: llvm-tools-preview + + - name: Enable cargo cache + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + with: + # Needed to make sure cargo-deny is correctly cached. + cache-all-crates: true + + - name: Install cargo-nextest + uses: taiki-e/install-action@43aecc8d72668fbcfe75c31400bc4f890f1c5853 # v2.83.2 + with: + tool: cargo-nextest + + - name: Install cargo-llvm-cov + if: ${{ matrix.coverage }} + uses: taiki-e/install-action@43aecc8d72668fbcfe75c31400bc4f890f1c5853 # v2.83.2 + with: + tool: cargo-llvm-cov + + - name: Install WASI runtime + if: ${{ matrix.wasi_runtime }} + uses: taiki-e/install-action@43aecc8d72668fbcfe75c31400bc4f890f1c5853 # v2.83.2 + with: + tool: ${{ matrix.wasi_runtime }} + + - name: Set up Homebrew + if: ${{ matrix.homebrew_supported }} + id: set-up-homebrew + # We ignore the stale-action-refs check because this action does not have any releases or tags. + # We're forced to pick a specific commit. + uses: Homebrew/actions/setup-homebrew@df4b09108a1de9d6f995fe68f302b3f68bd6d2ef # zizmor: ignore[stale-action-refs] + with: + stable: true + + - name: "Install recent bash for tests" + if: ${{ matrix.homebrew_supported }} + run: | + set -x + + # Install it + brew install bash + + # Find the path + BASH_PATH="$(brew --prefix bash)/bin/bash" + + # Log what we're using + echo "Using bash from: ${BASH_PATH}" + echo "bash version:" + ${BASH_PATH} --version + echo "BASH_PATH=${BASH_PATH}">>$GITHUB_ENV + + - name: "Download recent bash-completion sources for tests" + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + repository: "scop/bash-completion" + ref: "2.17.0" + path: "bash-completion" + + - name: "Setup bash-completion" + run: echo "BASH_COMPLETION_PATH=${GITHUB_WORKSPACE}/bash-completion/bash_completion">>$GITHUB_ENV + + - name: Test + env: + VARIANT: ${{ matrix.variant }} + run: | + args="${{ matrix.xtask_test_args || '' }}" + args="${args} --results-output ./test-results-${VARIANT}.xml" + if [[ "${{ matrix.coverage }}" == "true" ]]; then + args="${args} --coverage --coverage-output ./codecov-${VARIANT}.xml" + fi + cargo xtask test integration ${args} + + - name: "Upload test results" + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: always() + with: + name: test-reports${{ matrix.artifact_suffix }} + path: test-results-*.xml + + - name: "Generate code coverage report" + uses: clearlyip/code-coverage-report-action@110af9d4ec87b6706f182fd90e3102af9e5203bf # v7.0.0 + if: ${{ always() && matrix.coverage }} + id: "code_coverage_report" + with: + artifact_download_workflow_names: "CI" + artifact_name: coverage-%name%${{ matrix.artifact_suffix }} + filename: codecov-${{ matrix.variant }}.xml + overall_coverage_fail_threshold: ${{ matrix.coverage_min_percent }} + only_list_changed_files: ${{ github.event_name == 'pull_request' }} + fail_on_negative_difference: true + negative_difference_by: "overall" + negative_difference_threshold: 5 + + - name: "Upload code coverage report" + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: always() + with: + name: codecov-reports${{ matrix.artifact_suffix }} + path: code-coverage-results.md + + # Static analysis of the code. + check: + name: "Source code checks" + runs-on: ${{ matrix.os }} + + strategy: + matrix: + # Test latest stable as well as MSRV. + rust-version: ["stable", "1.88.0"] + # Run checks on Linux, Windows, and macOS. + os: ["ubuntu-24.04", "windows-2025", "macos-latest"] + + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Set up rust toolchain (${{ matrix.rust-version }}) + uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + with: + toolchain: ${{ matrix.rust-version }} + components: clippy, rustfmt + + - name: Enable cargo cache + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + with: + # Needed to make sure cargo-deny is correctly cached. + cache-all-crates: true + + - name: Format check + run: cargo xtask check fmt + + - name: Check + run: cargo xtask check build + + - name: Install cargo-deny + if: runner.os == 'Linux' + uses: taiki-e/install-action@43aecc8d72668fbcfe75c31400bc4f890f1c5853 # v2.83.2 + with: + tool: cargo-deny + + # NOTE: Only run on Linux. cargo-deny checks (advisories, licenses, bans, + # sources) are platform-independent, so running on one OS is sufficient. + # Also works around a cargo-deny 0.19.x bug where its advisory DB parser + # panics on Windows due to CRLF line endings (hardcoded LF-only matching). + - name: Deny check + if: runner.os == 'Linux' + run: cargo xtask check deps + + - name: Clippy check + if: matrix.rust-version == 'stable' + run: cargo xtask check lint + + # Check for unneeded dependencies. + check-deps: + name: "Check for unneeded dependencies" + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Set up nightly rust toolchain + uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + with: + toolchain: nightly + + - name: Install cargo-udeps + uses: taiki-e/install-action@43aecc8d72668fbcfe75c31400bc4f890f1c5853 # v2.83.2 + with: + tool: cargo-udeps + + - name: Check for unused dependencies + run: cargo xtask check unused-deps + + # Check that generated schemas are up-to-date + check-schemas: + name: "Check generated schemas" + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Set up rust toolchain + uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + with: + toolchain: stable + + - name: Enable cargo cache + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + + - name: Check generated schemas + run: cargo xtask check schemas + + # Analyze public API surface + analyze-public-api: + if: github.event_name == 'pull_request' + name: "Analyze public API" + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Set up nightly rust toolchain + uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + with: + toolchain: nightly + + - name: Install cargo-public-api + uses: taiki-e/install-action@43aecc8d72668fbcfe75c31400bc4f890f1c5853 # v2.83.2 + with: + tool: cargo-public-api + + - name: Set up branches + run: | + set -euo pipefail + git fetch origin main + + - name: Compare public APIs with main + run: cargo xtask analyze public-api --base origin/main --output-dir reports + + - name: Upload test report + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: test-reports-public-api + path: reports/*.md + + # Performance analysis of the code. + benchmark: + if: github.event_name == 'pull_request' + name: "Benchmarks" + runs-on: ubuntu-24.04 + steps: + - name: Checkout PR sources + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + path: pr + + - name: Checkout main sources + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + path: main + ref: main + + - name: Set up rust toolchain + uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + with: + toolchain: stable + + - name: Enable cargo cache + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + with: + workspaces: | + ./pr + ./main + + - name: Performance analysis on PR + run: cargo xtask analyze bench --output benchmarks.txt + working-directory: pr + + - name: Performance analysis on main + run: cargo bench --workspace --benches -- --output-format bencher | tee benchmarks.txt + working-directory: main + + - name: Compare benchmark results + run: | + ./pr/scripts/compare-benchmark-results.py -b main/benchmarks.txt -t pr/benchmarks.txt >benchmark-results.md + + - name: Upload performance results + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: perf-reports + path: | + pr/benchmarks.txt + main/benchmarks.txt + benchmark-results.md + + # Run bash-completion test suite + bash-completion-tests: + name: "External tests / bash-completion test suite" + runs-on: ubuntu-latest + needs: build + steps: + - name: Checkout brush + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + path: "brush" + + - name: Checkout bash-completion + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + repository: "scop/bash-completion" + ref: "2.16.0" + path: "bash-completion" + + - name: Download prebuilt brush binaries + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: "binaries-x86_64-linux" + path: "binaries" + + - name: Setup downloads + run: | + chmod +x binaries/* + ls -l binaries + + - name: Install prerequisites for running tests + run: | + set -x + sudo apt-get update -y + sudo apt-get install -y python3 + python3 -m pip install --user pytest pytest-xdist pytest-md-report pytest-json-report + + - name: "Run test suite (brush)" + working-directory: brush + run: | + cargo xtask test \ + --brush-path $GITHUB_WORKSPACE/binaries/brush \ + external bash-completion \ + --bash-completion-path $GITHUB_WORKSPACE/bash-completion \ + --output $GITHUB_WORKSPACE/test-results-bash-completion.json \ + --summary-output $GITHUB_WORKSPACE/test-results-bash-completion.md || true + + - name: Upload test report + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: test-reports-bash-completion + path: | + test-results-bash-completion.md + + # Test release binary on a variety of OS platforms. + os-tests: + strategy: + fail-fast: false + matrix: + include: + # N.B. We don't include Ubuntu because it's already covered by the initial test job. + - container: "archlinux:latest" + description: "Arch Linux/latest" + prereqs_command: "pacman -Sy --noconfirm bash-completion iputils grep less sed util-linux which" + + - container: "mcr.microsoft.com/azurelinux-beta/base/core:4.0" + description: "Azure Linux/4.0" + prereqs_command: "dnf install -y bash-completion ca-certificates git iputils grep less sed util-linux which" + + - container: "debian:testing" + description: "Debian/testing" + prereqs_command: "apt-get update -y && apt-get install -y bash-completion bsdmainutils iputils-ping grep less sed which" + + - container: "fedora:latest" + description: "Fedora/latest" + prereqs_command: "dnf install -y bash-completion iputils grep less sed util-linux which" + + - container: "nixos/nix:latest" + description: "NixOS/latest" + prereqs_command: "nix --extra-experimental-features nix-command --extra-experimental-features flakes profile install nixpkgs#gnused nixpkgs#hexdump nixpkgs#bash-completion" + + # NOTE: We use Tumbleweed to make sure we get a recent enough version of bash. + - container: "opensuse/tumbleweed:latest" + description: "openSUSE Tumbleweed/latest" + prereqs_command: "zypper install -y bash-completion git which" + + name: "OS target tests (${{ matrix.description }})" + runs-on: ubuntu-24.04 + container: ${{ matrix.container }} + needs: build + env: + # Reference: https://github.com/Azure/azure-cli/issues/29835 + GNUPGHOME: /root/.gnupg + steps: + # Install prerequisites *first*; may need some of them for checkout itself. + - name: Install prerequisites + if: ${{ matrix.prereqs_command != '' }} + env: + PREREQS_COMMAND: ${{ matrix.prereqs_command }} + run: bash -c "${PREREQS_COMMAND}" + + # Workaround path issues on NixOS + - name: "Apply workarounds: NixOS" + if: ${{ matrix.container == 'nixos/nix:latest' }} + run: | + set -x + + # Allow use of experimental features to avoid nix from complaining. + export NIX_CONFIG="extra-experimental-features = nix-command flakes" + + # The nixos/nix image ships no /etc/os-release (unlike a full NixOS install + # and every other container we test against). Provide one so the test harness + # can identify the OS, which it relies on for tests' `incompatible_os` tags. + printf 'NAME=NixOS\nID=nixos\nPRETTY_NAME="NixOS (nix container)"\n' > /etc/os-release + + # Hacks to get the mounted nodejs by github actions work as it's dynamically linked + # https://github.com/actions/checkout/issues/334#issuecomment-716068696 + nix build --no-link --max-jobs 2 --cores 0 'nixpkgs#stdenv.cc.cc.lib' 'nixpkgs#glibc' + echo "LD_LIBRARY_PATH=$(nix path-info 'nixpkgs#stdenv.cc.cc.lib')/lib" >> "$GITHUB_ENV" + ln -s "$(nix path-info 'nixpkgs#glibc' --recursive | grep glibc | grep -v bin)/lib64" /lib64 + + # Provide alternate path for bash-completion + echo "BASH_COMPLETION_PATH=$(nix path-info 'nixpkgs#bash-completion')/share/bash-completion/bash_completion">>"$GITHUB_ENV" + + # Forcibly add system paths + echo "BRUSH_TEST_PATH_VAR=$PATH">>"$GITHUB_ENV" + + # Checkout sources for YAML-based test cases + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + path: sources + + - name: Download prebuilt brush binaries + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: binaries-x86_64-linux + path: binaries + + - name: Download integration test binaries + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: integration-tests-x86_64-linux + path: binaries + + - name: Setup downloads + run: | + # N.B. Can't use -o pipefail because it's not supported on Debian. + set -eux + chmod +x binaries/* + ls -l -R sources/brush-shell/tests + ls -l binaries + + - name: Run tests + shell: bash + run: | + export BRUSH_PATH=$PWD/binaries/brush + export BRUSH_VERBOSE=true + + result=0 + for test_name in binaries/*tests*; do + if [[ ${test_name} == *compat* ]]; then + export BRUSH_TEST_CASES=$PWD/sources/brush-shell/tests/cases/compat + elif [[ ${test_name} == *integration* ]]; then + export BRUSH_TEST_CASES=$PWD/sources/brush-shell/tests/cases/brush + elif [[ ${test_name} == *interactive* || ${test_name} == *version* ]]; then + # TODO(tests): Re-enable these tests. + echo "WARNING: skipping test: ${test_name}" + continue + fi + + echo "Running test: ${test_name}" + chmod +x ${test_name} + ${test_name} || result=$? + done + + exit ${result} diff --git a/local/recipes/shells/brush/source/.github/workflows/codeql.yml b/local/recipes/shells/brush/source/.github/workflows/codeql.yml new file mode 100644 index 0000000000..731977286d --- /dev/null +++ b/local/recipes/shells/brush/source/.github/workflows/codeql.yml @@ -0,0 +1,69 @@ +name: "CodeQL Advanced" + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + schedule: + - cron: "35 1 * * 3" + +permissions: {} + +# Only allow one run of the workflow per branch / PR at a time. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + permissions: + # required for all workflows + security-events: write + # required to fetch internal or private CodeQL packs + packages: read + + strategy: + fail-fast: false + matrix: + include: + - language: actions + build-mode: none + - language: python + build-mode: none + - language: rust + build-mode: none + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + queries: security-extended,security-and-quality + + # - name: Set up rust toolchain + # if: ${{ matrix.language == 'rust' }} + # uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # v1 + # with: + # toolchain: stable + + # - name: Enable cargo cache + # if: ${{ matrix.language == 'rust' }} + # uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0 + + # - name: Build Rust code + # if: ${{ matrix.language == 'rust' }} + # run: cargo build --all-targets --all-features + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + with: + category: "/language:${{matrix.language}}" diff --git a/local/recipes/shells/brush/source/.github/workflows/devcontainer.yaml b/local/recipes/shells/brush/source/.github/workflows/devcontainer.yaml new file mode 100644 index 0000000000..83181fd972 --- /dev/null +++ b/local/recipes/shells/brush/source/.github/workflows/devcontainer.yaml @@ -0,0 +1,72 @@ +name: "Devcontainer" +on: + push: + branches: + - main + pull_request: + paths: + - ".devcontainer/**" + +permissions: {} + +# Only allow one run of the workflow per branch / PR at a time. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + build: + name: "Build devcontainer" + runs-on: ubuntu-24.04 + permissions: + contents: read + packages: read + steps: + - name: Checkout sources + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Login to GitHub Container Registry + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Pre-build dev container image + uses: devcontainers/ci@513af61f4de4f75d37e4438f184ba4358f0fc1ca # v0.3.1900000450 + with: + imageName: ghcr.io/reubeno/brush/devcontainer + imageTag: latest + cacheFrom: ghcr.io/reubeno/brush/devcontainer + push: never + + build_and_publish: + name: "Build and publish devcontainer" + runs-on: ubuntu-24.04 + permissions: + contents: read + packages: write + steps: + - name: Checkout sources + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Login to GitHub Container Registry + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Pre-build dev container image + uses: devcontainers/ci@513af61f4de4f75d37e4438f184ba4358f0fc1ca # v0.3.1900000450 + with: + imageName: ghcr.io/reubeno/brush/devcontainer + imageTag: latest + cacheFrom: ghcr.io/reubeno/brush/devcontainer + push: filter + refFilterForPush: refs/heads/main + eventFilterForPush: push diff --git a/local/recipes/shells/brush/source/.github/workflows/docs.yaml b/local/recipes/shells/brush/source/.github/workflows/docs.yaml new file mode 100644 index 0000000000..a9b0e24e25 --- /dev/null +++ b/local/recipes/shells/brush/source/.github/workflows/docs.yaml @@ -0,0 +1,77 @@ +name: "Docs" +on: + workflow_dispatch: + pull_request: + push: + branches: + - main + +env: + RUST_BACKTRACE: 1 + CARGO_TERM_COLOR: always + CLICOLOR: 1 + CLICOLOR_FORCE: 1 + +permissions: + contents: read + +# Only allow one run of the workflow per branch / PR at a time. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + # Check links in docs + check-links: + name: "Check links" + runs-on: ubuntu-24.04 + steps: + - name: Checkout sources + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Check repo-local links + uses: lycheeverse/lychee-action@e7477775783ea5526144ba13e8db5eec57747ce8 # v2.9.0 + with: + args: ". --offline --verbose --no-progress" + fail: true + + # Generate docs content + generate-docs: + name: "Generate usage docs" + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Set up rust toolchain + uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + with: + toolchain: stable + + - name: Enable cargo cache + uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + + - name: Create dirs + run: mkdir -p ./md ./man + + - name: Build markdown info + run: cargo xtask gen docs markdown --out ./md/brush.md + + - name: Upload markdown docs + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: docs-markdown + path: md + + - name: Build man pages + run: cargo xtask gen docs man --output-dir ./man + + - name: Upload man pages + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: docs-man + path: man diff --git a/local/recipes/shells/brush/source/.github/workflows/spelling.yaml b/local/recipes/shells/brush/source/.github/workflows/spelling.yaml new file mode 100644 index 0000000000..769a970cdb --- /dev/null +++ b/local/recipes/shells/brush/source/.github/workflows/spelling.yaml @@ -0,0 +1,29 @@ +name: Spelling +on: [pull_request] + +env: + RUST_BACKTRACE: 1 + CARGO_TERM_COLOR: always + CLICOLOR: 1 + CLICOLOR_FORCE: 1 + +permissions: + contents: read + +# Only allow one run of the workflow per branch / PR at a time. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + spelling: + name: spell-check + runs-on: ubuntu-latest + steps: + - name: Checkout brush + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Spell check repo + uses: crate-ci/typos@bee27e3a4fd1ea2111cf90ab89cd076c870fce14 # v1.48.0 diff --git a/local/recipes/shells/brush/source/.github/workflows/workflow-checks.yaml b/local/recipes/shells/brush/source/.github/workflows/workflow-checks.yaml new file mode 100644 index 0000000000..50df109374 --- /dev/null +++ b/local/recipes/shells/brush/source/.github/workflows/workflow-checks.yaml @@ -0,0 +1,46 @@ +# +# This workflow was based on https://docs.zizmor.sh/usage/#use-in-github-actions +# + +name: GitHub Actions Security Analysis with zizmor 🌈 + +on: + push: + branches: ["main"] + pull_request: + branches: ["**"] + +permissions: {} + +# Only allow one run of the workflow per branch / PR at a time. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + zizmor: + name: zizmor latest via PyPI + runs-on: ubuntu-latest + permissions: + security-events: write # needed for SARIF uploads + contents: read # only needed for private repos + actions: read # only needed for private repos + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Install the latest version of uv + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + + - name: Run zizmor 🌈 + run: uvx zizmor --format=sarif . > results.sarif + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload SARIF file + uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 + with: + sarif_file: results.sarif + category: zizmor diff --git a/local/recipes/shells/brush/source/.gitignore b/local/recipes/shells/brush/source/.gitignore new file mode 100644 index 0000000000..3823322d45 --- /dev/null +++ b/local/recipes/shells/brush/source/.gitignore @@ -0,0 +1,2 @@ +target/ +mutants.out*/ diff --git a/local/recipes/shells/brush/source/.lycheeignore b/local/recipes/shells/brush/source/.lycheeignore new file mode 100644 index 0000000000..61cad0becc --- /dev/null +++ b/local/recipes/shells/brush/source/.lycheeignore @@ -0,0 +1,2 @@ +https://github.com/reubeno/brush/* +target/* diff --git a/local/recipes/shells/brush/source/AGENTS.md b/local/recipes/shells/brush/source/AGENTS.md new file mode 100644 index 0000000000..9df94322f1 --- /dev/null +++ b/local/recipes/shells/brush/source/AGENTS.md @@ -0,0 +1,332 @@ +# Agent Development Guide for `brush` + +This guide helps AI agents work efficiently on the `brush` codebase by providing essential context about architecture, patterns, and development workflows. + +## 1. Architecture Overview & Navigation + +### Project Structure + +The brush project is organized into several key crates: + +- **`brush-core/`**: Core shell functionality, builtins, and runtime +- **`brush-parser/`**: Shell script parsing (AST generation) +- **`brush-builtins/`**: Implementation of shell builtins (e.g., echo, cd) +- **`brush-interactive/`**: Interactive shell interfaces (readline, etc.) +- **`brush-shell/`**: Main CLI application and entry point + +### Key Files & Entry Points + +**Critical files to understand first:** + +- `brush-core/src/shell.rs` - Main `Shell` struct and creation logic +- `brush-core/src/lib.rs` - Public API exports +- `brush-shell/src/main.rs` - CLI application entry point + +**Architecture patterns:** + +- Shell instances are created via `Shell::builder()` +- The project uses builder patterns for type-safe configuration +- We try to keep platform-specific code in `brush-core` under the `sys` module +- Follows Rust 2024 edition standards + +### Module Dependencies + +```text +brush-shell → brush-interactive → brush-core → brush-parser + ↘ brush-builtins ↗ +``` + +## 2. Testing Strategy + +### Test Execution Priority + +**Recommended development workflow:** + +#### Using xtask (Recommended) + +The project provides a `cargo xtask` command that centralizes common development tasks: + +```bash +# Run quick inner-loop checks (~7s warm): fmt, build, lint, unit tests +cargo xtask ci quick + +# Run full pre-commit checks (~45s warm): quick + deps, schemas, integration tests +cargo xtask ci pre-commit + +# Run with --continue-on-error to see all failures at once +cargo xtask ci pre-commit -k + +# Add -v for verbose output showing exact commands being run +cargo xtask -v ci pre-commit +``` + +#### Individual Test Commands + +```bash +# Run unit tests (fast tests excluding integration binaries) +cargo xtask test unit + +# Run integration tests (all workspace tests including compat tests) +cargo xtask test integration + +# Run tests with coverage +cargo xtask test integration --coverage --coverage-output codecov.xml +``` + +#### Manual Approach (Alternate) + +For finer-grained control: + +##### Inner Loop (Fast Iteration) + +1. **Quick validation**: `cargo check --package ` - Fast syntax/type checking +2. **Correctness validation**: `cargo test --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 -- '' +``` + +**Fast iteration strategies:** + +- Target specific crates: `cargo test --package ` +- Target specific test cases: `cargo test ` or `cargo test --test ` +- 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 ` +- 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 ` +- **Regular (before commits)**: `cargo xtask ci pre-commit` or `cargo fmt` + `cargo clippy` +- **Occasional (outer loop)**: `cargo xtask test integration` or `cargo test --workspace` +- **Rare (pre-finish only)**: `cargo xtask check deps` or `cargo deny check` + +**Pre-commit validation:** + +- Recommended: `cargo xtask ci pre-commit` +- Quick check: `cargo xtask ci quick` for fast feedback +- Manual: Run `cargo fmt` and `cargo clippy` before committing + +**Outer loop validation:** + +- `cargo deny check all` should pass (security/license auditing) - not for frequent use during development + +## 6. Performance & Error Handling Patterns + +### Error Handling + +**Error handling patterns:** + +- `thiserror` is used for implementing crate-specific errors +- Use `anyhow` only in tests + +**Logging and tracing patterns:** + +- Use `tracing` for debug logging with predefined categories +- Categories are defined in `trace_categories.rs` modules (e.g., `COMMANDS`, `COMPLETION`, `EXPANSION`, `FUNCTIONS`, `INPUT`, `JOBS`, `PARSE`, `PATTERN`, `UNIMPLEMENTED`) +- Usage pattern: `tracing::debug!(target: trace_categories::CATEGORY_NAME, "message")` +- Example: `tracing::debug!(target: trace_categories::JOBS, "Polling job {} for completion...", job_id)` + +### Performance Considerations + +**Clone vs references:** + +- Avoid cloning by default, no reason to make extra copies +- Only use cloning when you really must capture a separate copy for async safety or similarly important reasons + +--- + +## 7. Commit Message Conventions + +Following the Linux kernel convention, when AI assistants or advanced coding tools have been used in the development of a change, include an `Assisted-by:` tag in the commit message. This provides transparency about the use of such tools in the development process. + +The format for the Assisted-by tag is: +``` +Assisted-by: AGENT_NAME:MODEL_VERSION [TOOL1] [TOOL2] +``` + +Example: +``` +Assisted-by: Mistral Vibe:mistral-medium-3.5 +``` + +## Quick Reference Checklist + +When making changes to brush: + +### Before Starting + +- [ ] Understand which crate(s) are affected +- [ ] Check if changes might break dependent crates +- [ ] Identify relevant test files and examples + +### During Development + +- [ ] Run `cargo check` frequently during development +- [ ] Test changes with package-specific tests first (see section 2 for testing workflow) +- [ ] Update dependent crate usage if needed (see section 3 for compatibility considerations) +- [ ] Add/update examples for major feature additions only (see section 4) + +### Before Committing + +- [ ] Run full test suite: `cargo test` (see section 2 for complete testing workflow) +- [ ] Format code: `cargo fmt` (see section 5 for tool details) +- [ ] Check linting: `cargo clippy` +- [ ] Use conventional commit format +- [ ] If using AI assistants or advanced coding tools, include an `Assisted-by:` tag in the format `AGENT_NAME:MODEL_VERSION` (following Linux kernel convention) + +### Documentation + +- [ ] Add rustdoc to exported APIs (see section 4 for documentation standards) +- [ ] Include working examples for major features only +- [ ] Update this guide if new patterns emerge diff --git a/local/recipes/shells/brush/source/CHANGELOG.md b/local/recipes/shells/brush/source/CHANGELOG.md new file mode 100644 index 0000000000..ec56f3a776 --- /dev/null +++ b/local/recipes/shells/brush/source/CHANGELOG.md @@ -0,0 +1,1074 @@ +# Changelog + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +All notable changes to this project will be documented in this file. + +## [0.4.0] - 2026-05-02 + +### 🚀 Features + +- *(parser)* Add official `serde` feature to `brush-parser` for AST serialization ([#783](https://github.com/reubeno/brush/pull/783)) +- *(parser)* [**breaking**] Replace ParsingNearToken with ParsingNear ([#1022](https://github.com/reubeno/brush/pull/1022)) +- Introduce notion of marking errors as fatal ([#770](https://github.com/reubeno/brush/pull/770)) +- Expose key bindings for hint acceptance ([#802](https://github.com/reubeno/brush/pull/802)) +- Add 'serde' feature to `brush-core` ([#831](https://github.com/reubeno/brush/pull/831)) +- Enable using word expander without command substs ([#877](https://github.com/reubeno/brush/pull/877)) +- Enable session leading config ([#904](https://github.com/reubeno/brush/pull/904)) +- Implement \l sequence in prompt expansion ([#913](https://github.com/reubeno/brush/pull/913)) +- *(windows)* Various path fixes for windows platform ([#1075](https://github.com/reubeno/brush/pull/1075)) +- Implement fatal error propagation ([#773](https://github.com/reubeno/brush/pull/773)) +- Implement `set -u` semantics ([#774](https://github.com/reubeno/brush/pull/774)) +- Source tracking improvements ([#805](https://github.com/reubeno/brush/pull/805)) +- Support asynchronous builtin execution ([#810](https://github.com/reubeno/brush/pull/810)) +- *(builtins)* Implement caller builtin ([#812](https://github.com/reubeno/brush/pull/812)) +- Implement "compgen -A binding" ([#814](https://github.com/reubeno/brush/pull/814)) +- Emulate `exec` in subshell ([#823](https://github.com/reubeno/brush/pull/823)) +- Experimental builtin for serializing full shell state ([#835](https://github.com/reubeno/brush/pull/835)) +- Expand tilde expansion support ([#842](https://github.com/reubeno/brush/pull/842)) +- Implement `set -e` (a.k.a. `errexit`) + `pipefail` semantics ([#852](https://github.com/reubeno/brush/pull/852)) +- Experimental shell/terminal integration ([#872](https://github.com/reubeno/brush/pull/872)) +- *(hooks)* Experimental `zsh`-style preexec/precmd hooks ([#652](https://github.com/reubeno/brush/pull/652)) +- Rudimentary readline macro support ([#880](https://github.com/reubeno/brush/pull/880)) +- Optional TOML-based config file for brush-shell ([#895](https://github.com/reubeno/brush/pull/895)) +- *(read)* Fill out more of `read` builtin ([#914](https://github.com/reubeno/brush/pull/914)) +- Add convenience cmd line option to set up xtrace ([#915](https://github.com/reubeno/brush/pull/915)) +- Scaffolding for winnow parser ([#974](https://github.com/reubeno/brush/pull/974)) +- *(completion)* Implement COMP_KEY and COMP_TYPE ([#1008](https://github.com/reubeno/brush/pull/1008)) +- *(options)* Implement failglob option ([#1011](https://github.com/reubeno/brush/pull/1011)) +- *(well-known-vars)* Implement BASH_ARGC and BASH_ARGV variables ([#1013](https://github.com/reubeno/brush/pull/1013)) +- *(getopts)* Implement OPTERR semantics ([#1048](https://github.com/reubeno/brush/pull/1048)) +- *(mapfile)* Add `-O` flag ([#558](https://github.com/reubeno/brush/pull/558)) +- Add coproc AST and placeholder execution ([#1029](https://github.com/reubeno/brush/pull/1029)) +- *(trap)* Implement ERR trap ([#1020](https://github.com/reubeno/brush/pull/1020)) +- *(windows)* Add compat /dev/null handling ([#1044](https://github.com/reubeno/brush/pull/1044)) +- *(coproc)* Implement coprocs ([#1068](https://github.com/reubeno/brush/pull/1068)) +- Implement $_ well-known variable for last command argument ([#1030](https://github.com/reubeno/brush/pull/1030)) +- Add optional bundling of coreutils builtins behind experimental feature flag ([#1031](https://github.com/reubeno/brush/pull/1031)) + +### 🐛 Bug Fixes + +- Fill in more SourceLocation implementations ([#804](https://github.com/reubeno/brush/pull/804)) +- Correct parsing of escaped backslash in ANSI-C quotes ([#850](https://github.com/reubeno/brush/pull/850)) +- Bang formatted without space leads to invalid command ([#1109](https://github.com/reubeno/brush/pull/1109)) +- Avoid allocating in `pre_exec` closure ([#777](https://github.com/reubeno/brush/pull/777)) +- Improve "compgen -A {user,group}" ([#815](https://github.com/reubeno/brush/pull/815)) +- Correct completion word tokenizing ([#816](https://github.com/reubeno/brush/pull/816)) +- Honor mark-directories option ([#817](https://github.com/reubeno/brush/pull/817)) +- Correct scope mismatch on error ([#821](https://github.com/reubeno/brush/pull/821)) +- Suppress color when help is redirected ([#822](https://github.com/reubeno/brush/pull/822)) +- History import robustness ([#878](https://github.com/reubeno/brush/pull/878)) +- Avoid some allocation in brace-expansion ([#884](https://github.com/reubeno/brush/pull/884)) +- Eliminate unneeded clone ([#886](https://github.com/reubeno/brush/pull/886)) +- More direct Intos ([#946](https://github.com/reubeno/brush/pull/946)) +- Remove ref-options ([#952](https://github.com/reubeno/brush/pull/952)) +- Don't clobber builtin usage error if error display fails ([#965](https://github.com/reubeno/brush/pull/965)) +- Use std::env::{split_paths, join_paths} for `PATH` splitting/combining ([#968](https://github.com/reubeno/brush/pull/968)) +- Handle more readline macro forms ([#967](https://github.com/reubeno/brush/pull/967)) +- Build for freebsd ([#980](https://github.com/reubeno/brush/pull/980)) +- *(commands)* Use async pipe reads for command substitution +- Do not panic on file clone failure ([#1051](https://github.com/reubeno/brush/pull/1051)) +- *(wasm)* Avoid panic in split_paths on wasm ([#1064](https://github.com/reubeno/brush/pull/1064)) +- *(android)* Address android and 32-bit build issues ([#1070](https://github.com/reubeno/brush/pull/1070)) +- Address hang on macos when used as login shell ([#1095](https://github.com/reubeno/brush/pull/1095)) +- *(windows)* Fix /dev/null handling ([#1104](https://github.com/reubeno/brush/pull/1104)) +- Expose -c flag via $- ([#767](https://github.com/reubeno/brush/pull/767)) +- Don't allow builtin to invoke disabled builtin ([#719](https://github.com/reubeno/brush/pull/719)) +- Getopts with no provided args ([#796](https://github.com/reubeno/brush/pull/796)) +- Preserve $? during interactive shell prompt expansion ([#798](https://github.com/reubeno/brush/pull/798)) +- Correct initialization of SHELL var ([#800](https://github.com/reubeno/brush/pull/800)) +- Correct exit status handling in assignment-only cmds ([#801](https://github.com/reubeno/brush/pull/801)) +- Extended test redirection ([#811](https://github.com/reubeno/brush/pull/811)) +- Negative substring offsets ([#818](https://github.com/reubeno/brush/pull/818)) +- Do not tilde-expand prompts ([#819](https://github.com/reubeno/brush/pull/819)) +- Escaping in pattern character sets ([#824](https://github.com/reubeno/brush/pull/824)) +- Special-casing for OLDPWD ([#825](https://github.com/reubeno/brush/pull/825)) +- Omit unset vars from `set` builtin output ([#826](https://github.com/reubeno/brush/pull/826)) +- Getopts short options ([#827](https://github.com/reubeno/brush/pull/827)) +- Update default of COMP_WORDBREAKS ([#828](https://github.com/reubeno/brush/pull/828)) +- Disable reedline for cmd mode / script exec ([#845](https://github.com/reubeno/brush/pull/845)) +- Correct trace depth computation ([#847](https://github.com/reubeno/brush/pull/847)) +- Assorted ANSI-C quote compat fixes ([#851](https://github.com/reubeno/brush/pull/851)) +- True and false must ignore all args ([#865](https://github.com/reubeno/brush/pull/865)) +- Gracefully handle closed stdout ([#873](https://github.com/reubeno/brush/pull/873)) +- Gracefully handle closed stderr ([#875](https://github.com/reubeno/brush/pull/875)) +- Delay profile/rc loading until after input backend attached ([#879](https://github.com/reubeno/brush/pull/879)) +- *(completion)* Properly escape filenames with spaces or special chars ([#870](https://github.com/reubeno/brush/pull/870)) +- *(unset)* Allow unsetting non-standard names ([#893](https://github.com/reubeno/brush/pull/893)) +- *(test)* Correct handling of -- and invalid test exprs ([#894](https://github.com/reubeno/brush/pull/894)) +- Reenable a few tests ([#896](https://github.com/reubeno/brush/pull/896)) +- Handle redir form: `>&` PATH ([#908](https://github.com/reubeno/brush/pull/908)) +- Compat fixes for \[ and \] in prompt expansion ([#909](https://github.com/reubeno/brush/pull/909)) +- Parsing array accesses in array indices ([#910](https://github.com/reubeno/brush/pull/910)) +- Add missing assignment arithmetic operators (|=, ^=) ([#911](https://github.com/reubeno/brush/pull/911)) +- Correct overzealous regex escaping ([#912](https://github.com/reubeno/brush/pull/912)) +- Systematic removal of problematic .unwrap() calls ([#921](https://github.com/reubeno/brush/pull/921)) +- *(builtins)* Shadowing special builtins with funcs ([#922](https://github.com/reubeno/brush/pull/922)) +- *(hash)* Accept names with slashes without lookup or error ([#924](https://github.com/reubeno/brush/pull/924)) +- Ignore errors in -x tracing ([#928](https://github.com/reubeno/brush/pull/928)) +- Trim space in test comparisons ([#950](https://github.com/reubeno/brush/pull/950)) +- Exit status in command arg expansion failure ([#951](https://github.com/reubeno/brush/pull/951)) +- Correctly handle pipe errors in builtins ([#953](https://github.com/reubeno/brush/pull/953)) +- *(completion)* Provide default fallback completion for shell vars ([#954](https://github.com/reubeno/brush/pull/954)) +- *(extglob)* Handle escaped parens ([#960](https://github.com/reubeno/brush/pull/960)) +- *(patterns)* Correct backslash preservation ([#963](https://github.com/reubeno/brush/pull/963)) +- *(return)* Exit code when stderr is not writable ([#975](https://github.com/reubeno/brush/pull/975)) +- Check func names against reserved words ([#978](https://github.com/reubeno/brush/pull/978)) +- *(test)* Resolve bash to absolute path before running compat tests ([#994](https://github.com/reubeno/brush/pull/994)) +- *(pathsearch)* Include symlink'd executables in search ([#991](https://github.com/reubeno/brush/pull/991)) ([#992](https://github.com/reubeno/brush/pull/992)) +- Readonly should operate on global scope ([#1003](https://github.com/reubeno/brush/pull/1003)) +- *(peg)* Support legacy $[expr] arithmetic syntax ([#1004](https://github.com/reubeno/brush/pull/1004)) +- *(arrays)* Add set + unset support for negative array indices ([#1005](https://github.com/reubeno/brush/pull/1005)) +- *(expansion)* Allow ${#arr[i]} on unset variables with set -u ([#1007](https://github.com/reubeno/brush/pull/1007)) +- *(bind)* Silently succeed when key bindings unavailable ([#1009](https://github.com/reubeno/brush/pull/1009)) +- *(compgen)* Disable pathname expansion for -W word list ([#1010](https://github.com/reubeno/brush/pull/1010)) +- *(completion)* Only deduplicate completions in interactive presentation ([#1012](https://github.com/reubeno/brush/pull/1012)) +- *(trap)* - with multiple signals should clear all handlers ([#1015](https://github.com/reubeno/brush/pull/1015)) +- *(heredoc)* Preserve quotes in heredoc expansion (rebased) ([#1014](https://github.com/reubeno/brush/pull/1014)) +- *(arithmetic)* Allow space around prefix arithmetic operators ([#1016](https://github.com/reubeno/brush/pull/1016)) +- *(arithmetic)* More faithful overflow/underflow handling ([#1017](https://github.com/reubeno/brush/pull/1017)) +- *(arithmetic-for)* Correct parsing + eval issues ([#1018](https://github.com/reubeno/brush/pull/1018)) +- *(arithmetic)* Implement high-radix literals ([#1019](https://github.com/reubeno/brush/pull/1019)) +- *(printf)* Quote empty strings as '' in printf %q ([#1026](https://github.com/reubeno/brush/pull/1026)) +- *(expansion)* Correct descending brace sequence expansion with step ([#1025](https://github.com/reubeno/brush/pull/1025)) +- *(builtins)* Add builtin commands to `compgen -A command`([#997](https://github.com/reubeno/brush/pull/997)) ([#1027](https://github.com/reubeno/brush/pull/1027)) +- *(expansion)* Preserve quoted empty strings with nullglob enabled ([#1035](https://github.com/reubeno/brush/pull/1035)) +- *(command_subst)* Strip NULL bytes from command substitutions ([#1049](https://github.com/reubeno/brush/pull/1049)) +- Return 2 for unknown cmdline options ([#1050](https://github.com/reubeno/brush/pull/1050)) +- *(complete)* Complete current and parent directories. ([#887](https://github.com/reubeno/brush/pull/887)) +- *(arithmetic)* Detect infinite arithmetic var reference recursion ([#1021](https://github.com/reubeno/brush/pull/1021)) +- *(tokenizer)* Prioritize heredoc body over terminating char in `next_token_until` ([#1055](https://github.com/reubeno/brush/pull/1055)) +- *(tokenizer)* Unquote heredoc end tag in `delimit_current_token` ([#1056](https://github.com/reubeno/brush/pull/1056)) ([#1057](https://github.com/reubeno/brush/pull/1057)) +- Use i64 for exit ([#1065](https://github.com/reubeno/brush/pull/1065)) +- *(heredoc)* Handle unbalanced single quotes and backticks in heredoc bodies inside `$(…)` ([#1067](https://github.com/reubeno/brush/pull/1067)) +- *(cmdline)* Handle `--` as option terminator for `-c` flag ([#1076](https://github.com/reubeno/brush/pull/1076)) +- *(login)* Correctly reflect login option via shopt ([#1078](https://github.com/reubeno/brush/pull/1078)) +- *(-c)* Call EXIT traps on exit; add tests ([#1080](https://github.com/reubeno/brush/pull/1080)) +- *(builtins)* Add reserved keywords to `compgen -A command` ([#1047](https://github.com/reubeno/brush/pull/1047)) +- *(-f)* Implement -f command line option ([#1091](https://github.com/reubeno/brush/pull/1091)) +- *(parser)* Preserve whitespace in regex operands of extended tests ([#1096](https://github.com/reubeno/brush/pull/1096)) +- *(wellknownvars)* Set OSTYPE on macOS ([#1097](https://github.com/reubeno/brush/pull/1097)) + +### 🚜 Refactor + +- Reexport parser types ([#784](https://github.com/reubeno/brush/pull/784)) +- Reorganize existing PEG parser ([#899](https://github.com/reubeno/brush/pull/899)) +- Split shell.rs => shell/*.rs ([#945](https://github.com/reubeno/brush/pull/945)) +- Use let chains ([#982](https://github.com/reubeno/brush/pull/982)) +- Make `try_iter_open_fds` fully iterator-based ([#1001](https://github.com/reubeno/brush/pull/1001)) +- Rename TokenLocation -> SourceSpan ([#788](https://github.com/reubeno/brush/pull/788)) +- Merge func call and script stacks ([#789](https://github.com/reubeno/brush/pull/789)) +- Merge brushinfo and brushctl ([#813](https://github.com/reubeno/brush/pull/813)) +- Shell / interactive-shell layering ([#832](https://github.com/reubeno/brush/pull/832)) +- Make Shell fields private ([#900](https://github.com/reubeno/brush/pull/900)) +- Extract build/test/validate commands into `xtask` ([#898](https://github.com/reubeno/brush/pull/898)) +- Reuse test harness for non-oracle tests ([#916](https://github.com/reubeno/brush/pull/916)) +- [**breaking**] Make `Shell` generic over `ShellExtensions` type param ([#941](https://github.com/reubeno/brush/pull/941)) + +### 📚 Documentation + +- Update todo comments ([#787](https://github.com/reubeno/brush/pull/787)) +- Update readme ([#853](https://github.com/reubeno/brush/pull/853)) +- Update readme ([#976](https://github.com/reubeno/brush/pull/976)) +- Minor updates ([#1061](https://github.com/reubeno/brush/pull/1061)) +- Update compat tables ([#1062](https://github.com/reubeno/brush/pull/1062)) +- Update header image on readme ([#1113](https://github.com/reubeno/brush/pull/1113)) +- Update reference docs ([#1114](https://github.com/reubeno/brush/pull/1114)) + +### ⚡ Performance + +- Assorted performance changes ([#936](https://github.com/reubeno/brush/pull/936)) +- Prealloc capacity for some vectors, remove unnecessary Strings ([#940](https://github.com/reubeno/brush/pull/940)) +- Address regex cache contention ([#1043](https://github.com/reubeno/brush/pull/1043)) + +### 🧪 Testing + +- *(parser)* Per syntax element tests ([#938](https://github.com/reubeno/brush/pull/938)) +- Add error handling tests, resolve nextest issue ([#768](https://github.com/reubeno/brush/pull/768)) +- Extend IFS tests ([#775](https://github.com/reubeno/brush/pull/775)) +- Add not-yet-passing call-stack/`caller` tests ([#785](https://github.com/reubeno/brush/pull/785)) +- Skip confused test ([#820](https://github.com/reubeno/brush/pull/820)) +- Refactor + augment expansion test cases ([#846](https://github.com/reubeno/brush/pull/846)) +- More tests for traps and related options ([#947](https://github.com/reubeno/brush/pull/947)) +- Add mkosi config ([#1093](https://github.com/reubeno/brush/pull/1093)) +- Add basic wasm32-wasip2 tests to CI ([#1098](https://github.com/reubeno/brush/pull/1098)) +- *(nameref)* Add large set of nameref-focused tests ([#1102](https://github.com/reubeno/brush/pull/1102)) +- Temporarily disable flaky test ([#1103](https://github.com/reubeno/brush/pull/1103)) + +### ⚙️ Miscellaneous Tasks + +- Rename feature: fuzz-testing => arbitrary ([#844](https://github.com/reubeno/brush/pull/844)) +- Switch non-Unix plats to use std::env::home_dir ([#830](https://github.com/reubeno/brush/pull/830)) +- Update dependencies ([#985](https://github.com/reubeno/brush/pull/985)) +- *(clippy)* Fix nightly lints ([#998](https://github.com/reubeno/brush/pull/998)) +- Enable windows tests ([#1083](https://github.com/reubeno/brush/pull/1083)) +- Replace git expectrl dependency with v0.8.0 release ([#779](https://github.com/reubeno/brush/pull/779)) +- Workflow sync + cleanup ([#790](https://github.com/reubeno/brush/pull/790)) +- Upgrade several dependencies ([#843](https://github.com/reubeno/brush/pull/843)) +- Add placeholders for new named options (not yet supported) ([#848](https://github.com/reubeno/brush/pull/848)) +- Remove pprof dependency ([#891](https://github.com/reubeno/brush/pull/891)) +- Update dependencies ([#892](https://github.com/reubeno/brush/pull/892)) +- Update dependencies ([#923](https://github.com/reubeno/brush/pull/923)) +- Upgrade deps ([#942](https://github.com/reubeno/brush/pull/942)) +- Update dependencies ([#966](https://github.com/reubeno/brush/pull/966)) +- Update dependencies ([#977](https://github.com/reubeno/brush/pull/977)) +- Upgrade MSRV to 1.88 and update deps ([#981](https://github.com/reubeno/brush/pull/981)) +- Upgrade dependencies ([#1063](https://github.com/reubeno/brush/pull/1063)) +- Address clippy error in xtask ([#1108](https://github.com/reubeno/brush/pull/1108)) + +### Build + +- *(deps)* Bump utf8-chars from 3.0.5 to 3.0.6 in the cargo group ([#781](https://github.com/reubeno/brush/pull/781)) +- *(deps)* Bump the cargo group with 2 updates ([#807](https://github.com/reubeno/brush/pull/807)) +- *(deps)* Bump the cargo group with 3 updates ([#836](https://github.com/reubeno/brush/pull/836)) +- *(deps)* Bump uuid from 1.18.1 to 1.19.0 in the cargo group ([#840](https://github.com/reubeno/brush/pull/840)) +- *(deps)* Bump the cargo group with 3 updates ([#882](https://github.com/reubeno/brush/pull/882)) +- *(deps)* Bump the cargo group with 3 updates ([#925](https://github.com/reubeno/brush/pull/925)) +- *(deps)* Bump thiserror from 2.0.17 to 2.0.18 in the cargo group ([#961](https://github.com/reubeno/brush/pull/961)) +- *(deps)* Bump the cargo group with 8 updates ([#1024](https://github.com/reubeno/brush/pull/1024)) +- *(deps)* Bump the cargo group with 4 updates ([#1032](https://github.com/reubeno/brush/pull/1032)) +- *(deps)* Bump the cargo group with 6 updates ([#1041](https://github.com/reubeno/brush/pull/1041)) +- *(deps)* Bump the cargo group with 7 updates ([#1059](https://github.com/reubeno/brush/pull/1059)) +- *(deps)* Bump the cargo group with 4 updates ([#1081](https://github.com/reubeno/brush/pull/1081)) +- *(deps)* Bump the cargo group with 4 updates ([#1088](https://github.com/reubeno/brush/pull/1088)) +- *(deps)* Bump the cargo group with 5 updates ([#1112](https://github.com/reubeno/brush/pull/1112)) + + + +## [0.3.0] - 2025-11-17 + +### 🚀 Features + +- Add ShellBuilder, ParserBuilder ([#651](https://github.com/reubeno/brush/pull/651)) +- [**breaking**] Refactor SourcePosition to use Arc ([#727](https://github.com/reubeno/brush/pull/727)) +- *(bind)* Extend key binding support ([#740](https://github.com/reubeno/brush/pull/740)) +- Enable custom error formatting ([#722](https://github.com/reubeno/brush/pull/722)) +- [**breaking**] Introduce SourceLocation ([#728](https://github.com/reubeno/brush/pull/728)) +- Implement fc builtin ([#739](https://github.com/reubeno/brush/pull/739)) +- Implement alternate arithmetic for syntax ([#744](https://github.com/reubeno/brush/pull/744)) +- Implement BASH_XTRACEFD ([#747](https://github.com/reubeno/brush/pull/747)) +- Revisit how ExecutionParameters layer open files atop the Shell ([#749](https://github.com/reubeno/brush/pull/749)) +- Basic support for trap EXIT ([#750](https://github.com/reubeno/brush/pull/750)) +- Implement opt-in fd inheritance from host env ([#753](https://github.com/reubeno/brush/pull/753)) + +### 🐛 Bug Fixes + +- Workaround error on nightly ([#711](https://github.com/reubeno/brush/pull/711)) +- Comment unsafe blocks + better harden 1 block ([#733](https://github.com/reubeno/brush/pull/733)) +- Don't fail importing unreadable history lines ([#710](https://github.com/reubeno/brush/pull/710)) +- Tokenizer handling of here docs in quoted command substitutions ([#716](https://github.com/reubeno/brush/pull/716)) +- Address lint errors from stable + nightly ([#723](https://github.com/reubeno/brush/pull/723)) +- *(builtins)* Correct `read` var update on empty input ([#729](https://github.com/reubeno/brush/pull/729)) +- Address race conditions in basic input tests ([#730](https://github.com/reubeno/brush/pull/730)) +- Do not pass along exported-but-unset vars ([#732](https://github.com/reubeno/brush/pull/732)) +- *(builtins)* Suppress error when type -p sees no command ([#745](https://github.com/reubeno/brush/pull/745)) +- Command substitutions with large output ([#748](https://github.com/reubeno/brush/pull/748)) +- Expansion in select double-quoted parameter exprs ([#751](https://github.com/reubeno/brush/pull/751)) +- Correct expansion behavior in prompts ([#756](https://github.com/reubeno/brush/pull/756)) +- Escaping and pipeline parse issues ([#762](https://github.com/reubeno/brush/pull/762)) + +### 🚜 Refactor + +- Use `Shell` builder pattern in more code ([#688](https://github.com/reubeno/brush/pull/688)) +- Os_pipe::pipe() -> std::io::pipe() ([#695](https://github.com/reubeno/brush/pull/695)) +- Extract script + function call stacks to their own modules ([#709](https://github.com/reubeno/brush/pull/709)) +- Update Shell::new() to take creation options as owned ([#689](https://github.com/reubeno/brush/pull/689)) +- Move builtins into their own crate ([#690](https://github.com/reubeno/brush/pull/690)) +- Shell struct API improvements ([#692](https://github.com/reubeno/brush/pull/692)) +- Error/result type overhaul ([#720](https://github.com/reubeno/brush/pull/720)) +- Move more platform-specific code under sys ([#735](https://github.com/reubeno/brush/pull/735)) + +### 📚 Documentation + +- Update readme ([#742](https://github.com/reubeno/brush/pull/742)) + +### 🧪 Testing + +- Add not-yet-passing tests for set -u and set -e ([#736](https://github.com/reubeno/brush/pull/736)) +- Add new command substitution test case ([#752](https://github.com/reubeno/brush/pull/752)) + +### ⚙️ Miscellaneous Tasks + +- Run static code checks on linux + macOS too ([#678](https://github.com/reubeno/brush/pull/678)) +- Fix build error with cargo nightly ([#687](https://github.com/reubeno/brush/pull/687)) +- *(msrv)* [**breaking**] Upgrade MSRV to 1.87.0 ([#693](https://github.com/reubeno/brush/pull/693)) +- Fix benchmark execution ([#691](https://github.com/reubeno/brush/pull/691)) +- Update dependencies ([#696](https://github.com/reubeno/brush/pull/696)) +- Update dependencies ([#757](https://github.com/reubeno/brush/pull/757)) +- Add brush crate + docs publishing ([#760](https://github.com/reubeno/brush/pull/760)) + +### Build + +- *(deps)* Bump procfs from 0.17.0 to 0.18.0 in the cargo group across 1 directory ([#671](https://github.com/reubeno/brush/pull/671)) +- *(deps)* Bump bon from 3.7.2 to 3.8.0 in the cargo group ([#698](https://github.com/reubeno/brush/pull/698)) +- *(deps)* Bump the cargo group with 4 updates ([#676](https://github.com/reubeno/brush/pull/676)) +- *(deps)* Bump serde from 1.0.221 to 1.0.223 in the cargo group ([#680](https://github.com/reubeno/brush/pull/680)) +- *(deps)* Bump the cargo group with 5 updates ([#684](https://github.com/reubeno/brush/pull/684)) +- *(deps)* Bump the cargo group with 3 updates ([#686](https://github.com/reubeno/brush/pull/686)) +- *(deps)* Bump the cargo group with 5 updates ([#708](https://github.com/reubeno/brush/pull/708)) +- *(deps)* Bump the cargo group with 8 updates ([#713](https://github.com/reubeno/brush/pull/713)) +- *(deps)* Bump the cargo group with 3 updates ([#755](https://github.com/reubeno/brush/pull/755)) + + + +## [0.2.23] - 2025-08-30 + +### 🐛 Bug Fixes + +- *(cmdline)* Correct exit code for `--version` + `--help` ([#667](https://github.com/reubeno/brush/pull/667)) + + +## [0.2.22] - 2025-08-29 + +### 🚀 Features + +- *(diag)* Add minimal miette support to parser ([#648](https://github.com/reubeno/brush/pull/648)) + +### 🐛 Bug Fixes + +- Exclude bind-bound commands from history ([#650](https://github.com/reubeno/brush/pull/650)) +- *(cmdline)* Improve error handling for unknown cmdline options ([#656](https://github.com/reubeno/brush/pull/656)) + +### 📚 Documentation + +- Update readme ([#657](https://github.com/reubeno/brush/pull/657)) + +### ⚙️ Miscellaneous Tasks + +- Additional clippy fixes ([#661](https://github.com/reubeno/brush/pull/661)) +- Downgrade homedir ([#662](https://github.com/reubeno/brush/pull/662)) +- Address warnings on windows targets ([#663](https://github.com/reubeno/brush/pull/663)) + +### Build + +- *(deps)* Bump the cargo group with 4 updates ([#654](https://github.com/reubeno/brush/pull/654)) +- *(deps)* Bump the cargo group with 4 updates ([#659](https://github.com/reubeno/brush/pull/659)) +- *(deps)* Bump tracing-subscriber from 0.3.19 to 0.3.20 in the cargo group ([#664](https://github.com/reubeno/brush/pull/664)) + + +## [0.2.21] - 2025-08-13 + +### 🚀 Features + +- *(history)* Implement history builtin ([#599](https://github.com/reubeno/brush/pull/599)) + +### 🐛 Bug Fixes + +- *(parser)* Resolve issue with parser confusing subshell for arith expr ([#624](https://github.com/reubeno/brush/pull/624)) +- *(expansion)* Support broader set of nested brace expansions ([#625](https://github.com/reubeno/brush/pull/625)) +- Correct obvious string indexing errors ([#641](https://github.com/reubeno/brush/pull/641)) +- Fixes for preexec-style bash extensions ([#643](https://github.com/reubeno/brush/pull/643)) +- Prepare tests to run against bash-5.3 ([#610](https://github.com/reubeno/brush/pull/610)) +- *(unset)* Correct unset of associative array element ([#626](https://github.com/reubeno/brush/pull/626)) +- *(declare)* Refine varname validation ([#629](https://github.com/reubeno/brush/pull/629)) +- Hyphenated script args ([#630](https://github.com/reubeno/brush/pull/630)) +- Special case for command subst ([#632](https://github.com/reubeno/brush/pull/632)) + +### 📚 Documentation + +- *(readme)* Update Arch Linux install instructions ([#604](https://github.com/reubeno/brush/pull/604)) +- Adds homebrew section to README installation instructions ([#638](https://github.com/reubeno/brush/pull/638)) + +### ⚙️ Miscellaneous Tasks + +- Publish license notices in CD flow ([#622](https://github.com/reubeno/brush/pull/622)) +- Update dependencies ([#623](https://github.com/reubeno/brush/pull/623)) +- Cleanup allow attributes, switch to expect where possible ([#642](https://github.com/reubeno/brush/pull/642)) + +### Build + +- *(deps)* Bump indenter from 0.3.3 to 0.3.4 in the cargo group ([#627](https://github.com/reubeno/brush/pull/627)) +- *(deps)* Bump the cargo group with 3 updates ([#612](https://github.com/reubeno/brush/pull/612)) +- *(deps)* Bump the cargo group with 2 updates ([#613](https://github.com/reubeno/brush/pull/613)) +- *(deps)* Bump the cargo group with 2 updates ([#619](https://github.com/reubeno/brush/pull/619)) +- *(deps)* Bump the cargo group across 1 directory with 5 updates ([#609](https://github.com/reubeno/brush/pull/609)) +- *(deps)* Bump tokio from 1.46.1 to 1.47.0 in the cargo group ([#616](https://github.com/reubeno/brush/pull/616)) +- *(deps)* Bump the cargo group with 2 updates ([#621](https://github.com/reubeno/brush/pull/621)) + + +## [0.2.20] - 2025-07-04 + +### 🚀 Features + +- *(api)* API usability improvements for `Shell::invoke_function` (#596) +- Enable -o/+o on brush command line (#590) + +### 🐛 Bug Fixes + +- *(dot)* Only shadow args when some provided to `source` (#582) + +### 🚜 Refactor + +- *(ShellValue)* Take in an `Into` (#598) + +### 📚 Documentation + +- README.md installation updates (#580) +- Update README.md badges (#588) + +### 🧪 Testing + +- Tag test binary dependencies (#585) +- Add test cases for open issues (#587) +- Add not-yet-passing history tests (#591) + +### ⚙️ Miscellaneous Tasks + +- Update dependencies + deny policy (#586) +- Remove unneeded dev deps from 'test-with' (#594) +- Update dependencies (#601) + +### Build + +- *(deps)* Bump test-with from 0.15.1 to 0.15.2 in the cargo group (#593) + + +## [0.2.19] - 2025-06-25 + +### 🚀 Features + +- *(AndOrList)* Add iteration abilities (#512) +- Generic Shell functions to parse a script from bytes (#509) +- Ability to clear all functions from shell environment (#546) +- *(vars)* Implement correct updating for -u/-c/-l vars (#529) +- *(env)* Introduce BRUSH_VERSION variable (#531) +- Enable cargo-binstall to work with brush (#536) +- *(parser)* Add gettext enabled quotes (#446) +- *(printf)* Replace printf impl with uucore wrapper (#552) +- *(args)* Add --rcfile command-line option (#568) + +### 🐛 Bug Fixes + +- *(vars)* Ensure effective GID is first in GROUPS (#526) +- *(traps)* Add stub definition for RETURN trap (#559) +- *(typeset)* Mark typeset as a declaration builtin (#517) +- *(tokenizer)* Correctly treat $( (...) ) as a cmd substitution (#521) +- *(backquote)* Correctly handle trailing backslash in backquoted command (#524) +- *(test)* Reuse -ef, -nt, -ot support (#525) +- *(arithmetic)* Permit space after unary operators (#527) +- *(tokenizer)* Correctly handle here docs terminated by EOF (#551) +- *(interactive)* Fix behavior of cmds piped to stdin (#539) +- *(functions)* Allow func names to contain slashes (#560) +- *(tokenizer)* Handle escaped single-quote in ANSI-C quoted string (#561) +- *(arithmetic)* Correct left shift handling (#562) +- *(local)* Enable use of local to detect function (#565) +- *(expansion)* Handle signed numbers in brace-expansion ranges (#566) +- *(redirection)* Assorted fixes to redirection (#567) +- *(prompt)* Implement `\A` (#569) +- *(expansion)* Correct ${!PARAM@...} (#570) +- *(expansion)* Fix parsing escaped single-quotes in ANSI-C strs (#571) +- *(for/case)* Allow reserved words in for word lists (#578) + +### 🚜 Refactor + +- *(parser)* Abstract parse errors (#574) + +### ⚡ Performance + +- Remove redundant lookups in path searching (#573) + +### 🧪 Testing + +- *(parser)* Enable serde::Serialize on AST et al. for test targets (#544) +- *(tokenizer)* Adopt insta for tokenizer tests (#550) +- *(parser)* Start using insta crate for snapshot-testing parser (#545) + +### ⚙️ Miscellaneous Tasks + +- Upgrade MSRV to 1.85.0 (#553) +- Upgrade crates to Rust 2024 edition (#554) +- Enable more lints + fixes (#555) +- Upgrade dependencies (#556) + +### Build + +- *(deps)* Bump pprof from 0.14.0 to 0.15.0 in the cargo group (#542) +- *(deps)* Bump the cargo group with 3 updates (#522) + + +## [0.2.18] - 2025-05-22 + +### 🚀 Features + +- *(apply_unary_predicate_to_str)* Implement `-R` operand +- *(apply_binary_predicate)* Implement `-ef` operand +- *(apply_binary_predicate)* Implement `-nt` operand +- *(ShellValue)* Add `is_set` method +- *(apply_binary_predicate)* Implement `-ot` operand +- *(Shell)* Add methods to add environmental variables and builtins ([#447](https://github.com/reubeno/brush/pull/447)) +- Implement {cd,pwd} -{L,P} ([#458](https://github.com/reubeno/brush/pull/458)) +- *(builtins)* Implement initial (partial) `bind -x` support ([#478](https://github.com/reubeno/brush/pull/478)) +- *(mapfile)* Register also as readarray ([#486](https://github.com/reubeno/brush/pull/486)) +- *(funcs)* Implement function exporting/importing ([#492](https://github.com/reubeno/brush/pull/492)) +- *(mapfile)* Implement `-n`, `-d`, `-s` ([#490](https://github.com/reubeno/brush/pull/490)) +- *(ulimit)* Implement ulimit builtin ([#482](https://github.com/reubeno/brush/pull/482)) + +### 🐛 Bug Fixes + +- *(break,continue)* Use `default_value_t` for flag values ([#493](https://github.com/reubeno/brush/pull/493)) +- *(help)* Better compact argument help ([#499](https://github.com/reubeno/brush/pull/499)) +- *(tests)* Wrong boolean operator +- *(trap)* Handle '-' unregistration syntax ([#452](https://github.com/reubeno/brush/pull/452)) +- *(complete)* Fixes + tests for "declare -r" ([#462](https://github.com/reubeno/brush/pull/462)) +- Enable unnameable_types lint and mitigate errors ([#459](https://github.com/reubeno/brush/pull/459)) +- *(builtin)* Fix issues with 'builtin' invoking declaration builtins ([#466](https://github.com/reubeno/brush/pull/466)) +- *(declare)* Suppress errors for non-existent functions in declare -f / -F ([#467](https://github.com/reubeno/brush/pull/467)) +- *(expansion)* Allow negative subscripts with indexed arrays + slices ([#468](https://github.com/reubeno/brush/pull/468)) +- Allow "bind" usage in .bashrc ([#485](https://github.com/reubeno/brush/pull/485)) +- *(expansion)* Fix issues parsing backquoted commands nested in single/double-quoted strings ([#491](https://github.com/reubeno/brush/pull/491)) +- Fix typos + add spell-checking PR check ([#501](https://github.com/reubeno/brush/pull/501)) + +### 📚 Documentation + +- Update readme ([#481](https://github.com/reubeno/brush/pull/481)) +- Add discord invite to readme ([#494](https://github.com/reubeno/brush/pull/494)) + +### 🧪 Testing + +- *(extended-tests)* Explicitly set modified date on test files ([#453](https://github.com/reubeno/brush/pull/453)) +- Add compat test for $_ ([#480](https://github.com/reubeno/brush/pull/480)) +- Add known-failing tests to reproduce reported issues ([#483](https://github.com/reubeno/brush/pull/483)) + +### ⚙️ Miscellaneous Tasks + +- Enable (and fix) current checks across all workspace crates ([#457](https://github.com/reubeno/brush/pull/457)) +- *(extended_tests)* Add `-ef`, `-nt`, and `-ot` +- Upgrade dependencies: reedline, nix, clap, thiserror, etc. ([#456](https://github.com/reubeno/brush/pull/456)) +- Better log + connect unimpl functionality with GH issues ([#476](https://github.com/reubeno/brush/pull/476)) + +### Build + +- *(deps)* Bump the cargo group with 3 updates ([#461](https://github.com/reubeno/brush/pull/461)) +- *(deps)* Bump the cargo group with 2 updates ([#487](https://github.com/reubeno/brush/pull/487)) + +### Ref + +- *(Shell)* Turn `&Path` parameters into `AsRef` ([#448](https://github.com/reubeno/brush/pull/448)) + + +## [0.2.17] - 2025-04-24 + +### 🚀 Features + +- *(Shell)* Add `get_env_var` method for more generic variable returns (#438) + +### 🐛 Bug Fixes + +- Honor COMP_WORDBREAKS in completion tokenization (#407) +- Handle complete builtin run without options (#435) + +### 📚 Documentation + +- Add instructions for installing from the AUR (#433) + +### 🧪 Testing + +- Implement --skip in brush-compat-tests harness (#432) + +### ⚙️ Miscellaneous Tasks + +- *(Shell)* Use relaxed typing for string input (#437) +- Enable building for wasm32-unknown-unknown (#425) + +### Build + +- *(deps)* Bump rand from 0.9.0 to 0.9.1 in the cargo group (#431) +- *(deps)* Bump the cargo group with 3 updates (#426) +- *(deps)* Bump the cargo group with 2 updates (#427) + + +## [0.2.16] - 2025-03-25 + +### 🚀 Features + +- *(arithmetic)* Support explicit base#literal in arithmetic (#388) +- Implement `command -p` (#402) + +### 🐛 Bug Fixes + +- Default PS1 and PS2 in interactive mode (#390) +- *(builtins)* Implement command-less exec semantics with open fds (#384) +- *(builtins)* Correct read handling of IFS/space (#385) +- *(extglob)* Correct handling of extglobs with empty branches (#386) +- Correct path tests on empty strings (#391) +- Allow newline in empty array assignment (#405) +- Improve panic handling output (#409) +- *(regex)* Enable multiline mode for extended test regexes (#416) +- Parse '#' as char only if inside a variable expansion (#418) + +### 📚 Documentation + +- Symlink licenses under crate dirs (#400) + +### 🧪 Testing + +- Add more linux distros to test matrix (#412) +- Enable testing on nixos/nix container image (#413) + +### ⚙️ Miscellaneous Tasks + +- Upgrade cached crate (#398) +- Rewrite dir diffing test code to avoid deps + +### Build + +- *(deps)* Bump the cargo group with 2 updates (#393) +- *(deps)* Bump whoami from 1.5.2 to 1.6.0 in the cargo group (#423) +- *(deps)* Bump the cargo group with 2 updates (#389) +- *(deps)* Bump serde from 1.0.218 to 1.0.219 in the cargo group (#401) +- *(deps)* Bump the cargo group with 2 updates (#410) + + +## [0.2.15] - 2025-02-03 + +### 🚀 Features + +- *(continue)* Implement continue n for n >= 2 ([#326](https://github.com/reubeno/brush/pull/326)) +- *(options)* Implement dotglob semantics ([#332](https://github.com/reubeno/brush/pull/332)) +- *(options)* Implement "set -t" ([#333](https://github.com/reubeno/brush/pull/333)) +- *(options)* Implement "set -a" ([#336](https://github.com/reubeno/brush/pull/336)) +- *(env)* Introduce dynamic variables ([#360](https://github.com/reubeno/brush/pull/360)) + +### 🐛 Bug Fixes + +- *(builtins)* Skip unenumerable vars in set builtin ([#322](https://github.com/reubeno/brush/pull/322)) +- *(expansion)* Handle negative substring offset ([#372](https://github.com/reubeno/brush/pull/372)) +- *(completion)* Better handle native errors in completion funcs ([#373](https://github.com/reubeno/brush/pull/373)) +- *(builtins)* Correct parsing of bind positional arg ([#381](https://github.com/reubeno/brush/pull/381)) +- *(patterns)* Fix incorrect parse of char ranges ([#323](https://github.com/reubeno/brush/pull/323)) +- *(exit)* Correct exit semantics in various compund statements ([#347](https://github.com/reubeno/brush/pull/347)) +- *(for)* Correct semantics for "for" without "in" ([#348](https://github.com/reubeno/brush/pull/348)) +- Correct semantics of = in non-extended test commands ([#349](https://github.com/reubeno/brush/pull/349)) +- *(return)* Error if return used outside sourced script or function ([#350](https://github.com/reubeno/brush/pull/350)) +- *(arithmetic)* Recursively evaluate var references ([#351](https://github.com/reubeno/brush/pull/351)) +- *(arithmetic)* Fixes for nested parenthesis parsing in arithmetic ([#353](https://github.com/reubeno/brush/pull/353)) +- *(builtins)* Fix set builtin handling of - and -- ([#354](https://github.com/reubeno/brush/pull/354)) +- *(builtins)* Do not interpret --help in command builtin command args ([#355](https://github.com/reubeno/brush/pull/355)) +- *(builtins)* Correct more 'set' argument parsing ([#356](https://github.com/reubeno/brush/pull/356)) +- *(variables)* More correct handling of integer variables ([#357](https://github.com/reubeno/brush/pull/357)) +- *(redirection)* Make sure redirection fd + operator are contiguous ([#359](https://github.com/reubeno/brush/pull/359)) +- Better error when cwd is gone ([#370](https://github.com/reubeno/brush/pull/370)) +- *(builtins)* Fix read builtin ignoring tab chars ([#371](https://github.com/reubeno/brush/pull/371)) +- Propagate execution parameters more thoroughly ([#374](https://github.com/reubeno/brush/pull/374)) +- *(redirection)* Allow continuing past redir errors ([#375](https://github.com/reubeno/brush/pull/375)) + +### ⚡ Performance + +- Remove unneeded string cloning for arithmetic eval ([#324](https://github.com/reubeno/brush/pull/324)) +- Simplify export enumeration ([#363](https://github.com/reubeno/brush/pull/363)) +- Skip word parsing if no expansion required ([#365](https://github.com/reubeno/brush/pull/365)) +- Minor optimizations for shell create + command run ([#362](https://github.com/reubeno/brush/pull/362)) + +### 🧪 Testing + +- *(perf)* Update tokenizer/parser benchmarks ([#321](https://github.com/reubeno/brush/pull/321)) +- Resolve false errors about side effects in bash-completion tests ([#379](https://github.com/reubeno/brush/pull/379)) + +### ⚙️ Miscellaneous Tasks + +- Remove some unneeded `pub(crate)` visibility annotations ([#346](https://github.com/reubeno/brush/pull/346)) +- Remove unneeded result wrappings ([#367](https://github.com/reubeno/brush/pull/367)) +- Remove a few object clones ([#368](https://github.com/reubeno/brush/pull/368)) +- Update readme ([#331](https://github.com/reubeno/brush/pull/331)) +- Add pattern and expansion tests to track newly filed issues ([#330](https://github.com/reubeno/brush/pull/330)) +- Minor cleanups and test additions ([#364](https://github.com/reubeno/brush/pull/364)) +- Fix rng warnings ([#378](https://github.com/reubeno/brush/pull/378)) + +### Build + +- *(deps)* Bump indexmap from 2.7.0 to 2.7.1 in the cargo group ([#334](https://github.com/reubeno/brush/pull/334)) +- *(deps)* Bump the cargo group across 1 directory with 4 updates ([#320](https://github.com/reubeno/brush/pull/320)) +- *(deps)* Bump the cargo group with 3 updates ([#376](https://github.com/reubeno/brush/pull/376)) + + +## [0.2.14] - 2025-01-10 + +### 🚀 Features + +- *(prompts)* Enable PS0, custom right-side prompts, more ([#278](https://github.com/reubeno/brush/pull/278)) +- *(completion)* Programmable completion support for filters + commands +- *(non-posix)* Implement `time` keyword ([#310](https://github.com/reubeno/brush/pull/310)) +- *(builtins)* Implement suspend ([#311](https://github.com/reubeno/brush/pull/311)) +- *(set)* Implement nullglob option ([#279](https://github.com/reubeno/brush/pull/279)) +- *(set)* Implement nocaseglob + nocasematch options ([#282](https://github.com/reubeno/brush/pull/282)) +- *(builtins)* Add minimal mapfile + bind impls +- *(debug)* Improved function tracing capabilities +- *(options)* Implement lastpipe option +- Implement brace expansion ([#290](https://github.com/reubeno/brush/pull/290)) +- *(options)* Implement noclobber option (a.k.a. -C) ([#291](https://github.com/reubeno/brush/pull/291)) +- *(builtins)* Implement more of kill builtin ([#305](https://github.com/reubeno/brush/pull/305)) +- *(builtins)* Implement times builtin ([#309](https://github.com/reubeno/brush/pull/309)) + +### 🐛 Bug Fixes + +- Correct sh mode vs posix mode confusion for syntax extensions +- Assorted non-fatal clippy warnings ([#274](https://github.com/reubeno/brush/pull/274)) +- *(builtins)* Correct behavior of set builtin with no args +- More consistently honor shell options when invoking the tokenizer +- Update COMP_WORDBREAKS default value +- Honor extglob for expansion transformations +- Sync PWD with actual workdir on launch +- *(jobs)* Only report job status when job control option is enabled ([#306](https://github.com/reubeno/brush/pull/306)) +- Stop incorrectly parsing assignment as function def ([#273](https://github.com/reubeno/brush/pull/273)) +- Multiple issues blocking docker cmd completion ([#275](https://github.com/reubeno/brush/pull/275)) +- Improve substring ops with multi-byte chars ([#280](https://github.com/reubeno/brush/pull/280)) +- Better handle escape chars in pattern bracket exprs ([#281](https://github.com/reubeno/brush/pull/281)) +- *(completion)* Multiple fixes for compgen builtin usage +- *(regex)* Work around incompatibilities between shell + rust regexes +- *(extendedtests)* Add missing arithmetic eval in extended tests +- *(command)* Handle sending basic command errors to redirected stderr +- Improve accuracy of negative extglobs +- Implement date and time in prompts ([#298](https://github.com/reubeno/brush/pull/298)) +- *(completion)* Handle -o {default,dirnames,plusdirs} ([#300](https://github.com/reubeno/brush/pull/300)) +- *(expansion)* Correct length for 1-element arrays ([#316](https://github.com/reubeno/brush/pull/316)) +- Correct issues with `!` extglobs and compgen -X ([#317](https://github.com/reubeno/brush/pull/317)) + +### 📚 Documentation + +- Update README to reflect test expansion + +### ⚡ Performance + +- Cache parsing for arithmetic expressions ([#301](https://github.com/reubeno/brush/pull/301)) +- Remove unneeded async from arithmetic eval ([#312](https://github.com/reubeno/brush/pull/312)) +- Remove setup operations from microbenchmarks ([#307](https://github.com/reubeno/brush/pull/307)) +- Reimplement colon command as a "simple builtin" ([#315](https://github.com/reubeno/brush/pull/315)) + +### 🧪 Testing + +- *(completion)* Add another completion test +- *(completion)* Enable use of pexpect et al. with basic input backend + +### ⚙️ Miscellaneous Tasks + +- Update comments +- Improve tracing for completion function invocation +- Remove unneeded helper code +- Address warnings ([#313](https://github.com/reubeno/brush/pull/313)) + +### Build + +- *(deps)* Bump the cargo group with 3 updates ([#285](https://github.com/reubeno/brush/pull/285)) +- *(deps)* Bump the cargo group with 4 updates ([#289](https://github.com/reubeno/brush/pull/289)) +- *(deps)* Bump the cargo group with 3 updates ([#294](https://github.com/reubeno/brush/pull/294)) +- *(deps)* Bump anyhow from 1.0.94 to 1.0.95 in the cargo group ([#297](https://github.com/reubeno/brush/pull/297)) +- *(deps)* Bump the cargo group with 2 updates ([#299](https://github.com/reubeno/brush/pull/299)) +- *(deps)* Bump the cargo group with 2 updates ([#304](https://github.com/reubeno/brush/pull/304)) + + +## [0.2.13] - 2024-11-26 + +### 🚀 Features + +- *(ast)* Derive `PartialEq` and `Eq` for testing ([#259](https://github.com/reubeno/brush/pull/259)) + +### 🐛 Bug Fixes + +- Correct parsing of parens in arithmetic command ([#270](https://github.com/reubeno/brush/pull/270)) + +### ⚙️ Miscellaneous Tasks + +- Upgrade dependencies ([#271](https://github.com/reubeno/brush/pull/271)) + +### Build + +- *(deps)* Bump the cargo group with 3 updates ([#258](https://github.com/reubeno/brush/pull/258)) +- *(deps)* Bump the cargo group with 7 updates ([#267](https://github.com/reubeno/brush/pull/267)) + + +## [0.2.12] - 2024-11-03 + +### 🚀 Features + +- Implement support for ;;& and ;& in case items ([#223](https://github.com/reubeno/brush/pull/223)) +- Implement `|&` extension ([#240](https://github.com/reubeno/brush/pull/240)) +- Implement `kill -l` ([#221](https://github.com/reubeno/brush/pull/221)) +- Implement `|&` for function declarations ([#244](https://github.com/reubeno/brush/pull/244)) + +### 🐛 Bug Fixes + +- Omit dirs from executable searches ([#236](https://github.com/reubeno/brush/pull/236)) +- Handle PS2 prompts that require prompt-expansion ([#239](https://github.com/reubeno/brush/pull/239)) +- Allow usually-operator chars in regex parens ([#224](https://github.com/reubeno/brush/pull/224)) +- Assorted correctness issues in getopts builtin ([#225](https://github.com/reubeno/brush/pull/225)) +- Assorted completion-related issues ([#226](https://github.com/reubeno/brush/pull/226)) +- String replacement with slashes ([#231](https://github.com/reubeno/brush/pull/231)) +- Correct pattern removal expansions on arrays ([#232](https://github.com/reubeno/brush/pull/232)) +- *(completion)* Fix -- handling in getopts ([#235](https://github.com/reubeno/brush/pull/235)) +- *(completion)* Correct behavior of slice past end of array ([#237](https://github.com/reubeno/brush/pull/237)) +- Support here documents in command substitutions ([#255](https://github.com/reubeno/brush/pull/255)) + +### 🧪 Testing + +- Run completion tests using bash-completion 2.14.0 ([#238](https://github.com/reubeno/brush/pull/238)) +- Add os-targeted integration tests ([#241](https://github.com/reubeno/brush/pull/241)) + +### ⚙️ Miscellaneous Tasks + +- Upgrade crate dependencies ([#247](https://github.com/reubeno/brush/pull/247)) + +### Build + +- *(deps)* Bump the cargo group with 2 updates ([#220](https://github.com/reubeno/brush/pull/220)) + + +## [0.2.11] - 2024-10-18 + +### 🚀 Features + +- Experimentally enable reedline as an input backend ([#186](https://github.com/reubeno/brush/pull/186)) +- Default to reedline and add syntax highlighting support ([#187](https://github.com/reubeno/brush/pull/187)) +- Add a panic handler via human-panic ([#191](https://github.com/reubeno/brush/pull/191)) +- Several fixes for bash-completion + tests ([#192](https://github.com/reubeno/brush/pull/192)) +- Implement `cd -` ([#201](https://github.com/reubeno/brush/pull/201)) +- Implement command hashing ([#206](https://github.com/reubeno/brush/pull/206)) + +### 🐛 Bug Fixes + +- Deduplicate completion candidates ([#189](https://github.com/reubeno/brush/pull/189)) +- Cleanup transient completion variables ([#213](https://github.com/reubeno/brush/pull/213)) +- Allow newlines in extended test exprs ([#188](https://github.com/reubeno/brush/pull/188)) +- Fixes for short-circuit precedence + parameter expr replacement ([#193](https://github.com/reubeno/brush/pull/193)) +- Workarounds for edge word parsing cases ([#194](https://github.com/reubeno/brush/pull/194)) +- Assorted completion issues with ~ and vars ([#199](https://github.com/reubeno/brush/pull/199)) +- Slight compat improvements to set -x ([#205](https://github.com/reubeno/brush/pull/205)) +- Matching newline chars in glob patterns ([#207](https://github.com/reubeno/brush/pull/207)) +- Honor IFS in read builtin ([#208](https://github.com/reubeno/brush/pull/208)) +- Correct behavior of break in arithmetic for loop ([#210](https://github.com/reubeno/brush/pull/210)) +- Address issues with array unset ([#211](https://github.com/reubeno/brush/pull/211)) +- Handle expansion in here documents ([#212](https://github.com/reubeno/brush/pull/212)) + +### 📚 Documentation + +- Update readme ([#182](https://github.com/reubeno/brush/pull/182)) +- Update readme with new links ([#204](https://github.com/reubeno/brush/pull/204)) + +### 🧪 Testing + +- Enable setting min oracle version on tests ([#184](https://github.com/reubeno/brush/pull/184)) + +### ⚙️ Miscellaneous Tasks + +- Where possible replace `async-trait` with native async trait support in 1.75+ ([#197](https://github.com/reubeno/brush/pull/197)) + +### Build + +- *(deps)* Bump futures from 0.3.30 to 0.3.31 in the cargo group ([#190](https://github.com/reubeno/brush/pull/190)) +- Leave rustyline disabled by default ([#196](https://github.com/reubeno/brush/pull/196)) +- *(deps)* Bump the cargo group with 4 updates ([#203](https://github.com/reubeno/brush/pull/203)) +- Remove rustyline support ([#216](https://github.com/reubeno/brush/pull/216)) + + +## [0.2.10] - 2024-09-30 + +### 🐛 Bug Fixes + +- Allow source to be used with process substitution ([#175](https://github.com/reubeno/brush/pull/175)) +- Address multiple issues with foreground controls for pipeline commands ([#180](https://github.com/reubeno/brush/pull/180)) + +### 🧪 Testing + +- Move to cargo nextest ([#176](https://github.com/reubeno/brush/pull/176)) +- Correctly report skipped tests for nextest ([#178](https://github.com/reubeno/brush/pull/178)) +- Convert more test skips to known failures ([#179](https://github.com/reubeno/brush/pull/179)) + +### Build + +- *(deps)* Bump the cargo group with 2 updates ([#177](https://github.com/reubeno/brush/pull/177)) + + +## [0.2.9] - 2024-09-26 + +### 🚀 Features + +- Launch processes in their own process groups ([#166](https://github.com/reubeno/brush/pull/166)) + +### 🐛 Bug Fixes + +- Posix compliant argument parsing for `-c` mode ([#147](https://github.com/reubeno/brush/pull/147)) + +### 🧪 Testing + +- Add more basic interactive tests ([#168](https://github.com/reubeno/brush/pull/168)) +- Bring up macos testing ([#172](https://github.com/reubeno/brush/pull/172)) + +### Build + +- *(deps)* Bump thiserror from 1.0.63 to 1.0.64 in the cargo group ([#167](https://github.com/reubeno/brush/pull/167)) +- Temporarily disable failing test ([#170](https://github.com/reubeno/brush/pull/170)) +- Refactor PR workflow to better support multi-platform build + test ([#169](https://github.com/reubeno/brush/pull/169)) + + +## [0.2.8] - 2024-09-17 + +### 🐛 Bug Fixes + +- Implement ~USER syntax ([#160](https://github.com/reubeno/brush/pull/160)) +- Compgen needs to expand target arg ([#162](https://github.com/reubeno/brush/pull/162)) +- Do not invoke debug traps during completion funcs ([#163](https://github.com/reubeno/brush/pull/163)) +- Disable flaky test until it can be root-caused + +### 📚 Documentation + +- Generate man page via xtask ([#157](https://github.com/reubeno/brush/pull/157)) + +### ⚡ Performance + +- Short-term optimization for common-case printf + +### ⚙️ Miscellaneous Tasks + +- Extract InteractiveShell as trait + refactor ([#159](https://github.com/reubeno/brush/pull/159)) + +### Build + +- *(deps)* Bump tokio from 1.39.3 to 1.40.0 in the cargo group ([#156](https://github.com/reubeno/brush/pull/156)) +- *(deps)* Bump the cargo group with 6 updates ([#158](https://github.com/reubeno/brush/pull/158)) +- *(deps)* Bump the cargo group with 2 updates ([#161](https://github.com/reubeno/brush/pull/161)) + + +## [0.2.7] - 2024-09-01 + +### 🚀 Features + +- Move MSRV up to 1.75.0 ([#139](https://github.com/reubeno/brush/pull/139)) + +### 🐛 Bug Fixes + +- Correct echo -e escape expansion for \x sequences ([#143](https://github.com/reubeno/brush/pull/143)) +- Disable displaying tracing target ([#140](https://github.com/reubeno/brush/pull/140)) +- Correct multiple issues with process substitution + redirection ([#145](https://github.com/reubeno/brush/pull/145)) + +### Build + +- *(deps)* Bump tokio from 1.39.1 to 1.39.2 in the cargo group ([#141](https://github.com/reubeno/brush/pull/141)) +- *(deps)* Bump the cargo group with 3 updates ([#148](https://github.com/reubeno/brush/pull/148)) +- *(deps)* Bump serde from 1.0.204 to 1.0.206 in the cargo group ([#150](https://github.com/reubeno/brush/pull/150)) +- *(deps)* Bump the cargo group with 2 updates ([#152](https://github.com/reubeno/brush/pull/152)) +- *(deps)* Bump serde from 1.0.208 to 1.0.209 in the cargo group ([#154](https://github.com/reubeno/brush/pull/154)) + + +## [0.2.6] - 2024-07-23 + +### 🐛 Bug Fixes + +- Correct relative path resolution cases +- Relative path completion fixes ([#137](https://github.com/reubeno/brush/pull/137)) + + +## [0.2.5] - 2024-07-23 + +### 🐛 Bug Fixes + +- Build error outside git ([#134](https://github.com/reubeno/brush/pull/134)) + +### Build + +- *(deps)* Bump the cargo group with 2 updates ([#133](https://github.com/reubeno/brush/pull/133)) + + +## [0.2.4] - 2024-07-19 + +### 🚀 Features + +- Initial support for non-linux +- Enable simpler builtins implemented outside brush ([#130](https://github.com/reubeno/brush/pull/130)) +- Get building on windows and wasm-wasip1 targets ([#116](https://github.com/reubeno/brush/pull/116)) +- Add brushctl builtin, seed with event toggling support + +### 🐛 Bug Fixes + +- Absorb breaking change in homedir crate +- Clippy and check warnings ([#123](https://github.com/reubeno/brush/pull/123)) +- Correct completion fallback logic when spec matches but 0 results ([#125](https://github.com/reubeno/brush/pull/125)) +- Various build warnings on windows build ([#126](https://github.com/reubeno/brush/pull/126)) +- Exclude tags from git version info ([#115](https://github.com/reubeno/brush/pull/115)) + +### 📚 Documentation + +- Update readme ([#127](https://github.com/reubeno/brush/pull/127)) + +### ⚙️ Miscellaneous Tasks + +- Merge builtin and builtins modules +- Update comments ([#129](https://github.com/reubeno/brush/pull/129)) + +### Build + +- *(deps)* Bump the cargo group across 1 directory with 5 updates + + +## [0.2.3] - 2024-07-03 + +### 🚀 Features + +- Enable -O and +O on command line (#105) +- Start using cargo-fuzz for testing (#106) +- Enable fuzz-testing arithmetic eval (#108) +- Include more details in version info (#112) + +### 🐛 Bug Fixes + +- Correct expansion when PWD is / (#96) +- Ensure parser error actually impls Error (#98) +- Realign newline parsing with spec (#99) +- Correct handling of unterminated expansions (#101) +- Add &>> implementation (#103) +- Correct metadata for fuzz crate (#107) +- Resolve assorted arithmetic eval issues (#110) +- Correct ** overflow behavior (#111) + +### ⚙️ Miscellaneous Tasks + +- Update Cargo.lock (#113) +- Release + +### Build + +- Take targeted dependency updates (#93) +- Update config (#97) + +## [0.2.2] - 2024-06-19 + +### 🚀 Features + +- Implement 'command' builtin (#77) +- Add stubs for help man page generation +- Fill out read builtin impl +- Rework here doc files (#85) +- Set + validate intentional MSRV (1.72.0) (#86) +- Add basic changelog +- Add basic changelog (#87) + +### 🐛 Bug Fixes + +- Compgen -W expansion (#78) +- Don't split completions that aren't file paths (#79) +- Allow interrupting read builtin, run pipeline cmds in subshell (#81) +- Add missing flush calls +- Tweak manifests to work with release flow (#89) +- Ensure brush-core builds outside workspace (#90) + +### 📚 Documentation + +- Add crate shields to readme (#74) +- Add missing code documentation + +### ⚙️ Miscellaneous Tasks + +- *(release)* Bump version to 0.2.0 (#88) + +### Build + +- Update dependencies +- Adjust clippy warnings + +## [0.1.0] - 2024-06-11 + +### Build + +- Prepare for initial release (#68) +- Enable publishing (#71) + + diff --git a/local/recipes/shells/brush/source/CODE_OF_CONDUCT.md b/local/recipes/shells/brush/source/CODE_OF_CONDUCT.md new file mode 100644 index 0000000000..4502527269 --- /dev/null +++ b/local/recipes/shells/brush/source/CODE_OF_CONDUCT.md @@ -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 diff --git a/local/recipes/shells/brush/source/CONTRIBUTING.md b/local/recipes/shells/brush/source/CONTRIBUTING.md new file mode 100644 index 0000000000..1aa44eacf2 --- /dev/null +++ b/local/recipes/shells/brush/source/CONTRIBUTING.md @@ -0,0 +1,138 @@ + +# 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 + + +## 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 + +> [!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 + + +#### 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? + + +#### 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. + + +#### 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. + + +#### 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)! \ No newline at end of file diff --git a/local/recipes/shells/brush/source/Cargo.toml b/local/recipes/shells/brush/source/Cargo.toml new file mode 100644 index 0000000000..b759572c2c --- /dev/null +++ b/local/recipes/shells/brush/source/Cargo.toml @@ -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" diff --git a/local/recipes/shells/brush/source/LICENSE b/local/recipes/shells/brush/source/LICENSE new file mode 100644 index 0000000000..3d3cb78667 --- /dev/null +++ b/local/recipes/shells/brush/source/LICENSE @@ -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. diff --git a/local/recipes/shells/brush/source/README.md b/local/recipes/shells/brush/source/README.md new file mode 100644 index 0000000000..c43c294fc0 --- /dev/null +++ b/local/recipes/shells/brush/source/README.md @@ -0,0 +1,215 @@ +
+ +
+ +
+ + +

+ + + + + + +
+ + + + 1389 compatibility tests + + + Packaging status + + + + Discord invite + +

+ + + + +

+ +
+ +`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.
+✨ Syntax highlighting and auto-suggestions built in.
+🧪 Validated against bash with [~1700 compatibility tests](brush-shell/tests/cases).
+🧩 Easily embeddable in your Rust apps using `brush_core::Shell`.
+ +

+ +

+ +> ⚠️ **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._ + +
+🍺 Installing using Homebrew (macOS/Linux) + +Homebrew users can install using [the `brush` formula](https://formulae.brew.sh/formula/brush): + +```bash +brew install brush +``` + +
+ +
+ Installing on Arch Linux + +Arch Linux users can install `brush` from the official [extra repository](https://archlinux.org/packages/extra/x86_64/brush/): + +```bash +pacman -S brush +``` + +
+ +
+icon Installing on MSYS2 + +MSYS2 users can install `brush` from the [repository](https://packages.msys2.org/base/mingw-w64-brush): + +```bash +pacman -S mingw-w64-ucrt-x86_64-brush # or mingw-w64-clang-x86_64-brush or mingw-w64-clang-aarch64-brush +``` + +
+ +
+🚀 Installing prebuilt binaries via `cargo binstall` + +You may use [cargo binstall](https://github.com/cargo-bins/cargo-binstall) to install pre-built `brush` binaries. Once you've installed `cargo-binstall` you can run: + +```bash +cargo binstall brush-shell +``` + +
+ +
+🚀 Installing prebuilt binaries from GitHub + +We publish prebuilt binaries of `brush` for Linux (x86_64, aarch64) and macOS (aarch64) to GitHub for official [releases](https://github.com/reubeno/brush/releases). You can manually download and extract the `brush` binary from one of the archives published there, or otherwise use the GitHub CLI to download it, e.g.: + +```bash +gh release download --repo reubeno/brush --pattern "brush-x86_64-unknown-linux-gnu.*" +``` + +After downloading the archive for your platform, you may verify its authenticity using the [GitHub CLI](https://cli.github.com/), e.g.: + +```bash +gh attestation verify brush-x86_64-unknown-linux-gnu.tar.gz --repo reubeno/brush +``` + +
+ +
+🐧 Installing using Nix + +If you are a Nix user, you can use the registered version: + +```bash +nix run 'github:NixOS/nixpkgs/nixpkgs-unstable#brush' -- --version +``` + +
+ +
+ 🔨 Building from sources + +To build from sources, first install a working (and recent) `rust` toolchain; we recommend installing it via [`rustup`](https://rustup.rs/). Then run: + +```bash +cargo install --locked brush-shell +``` + +
+ +## 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 + +
+🙏 Credits + +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 + +
+ +--- + +Licensed under the [MIT license](LICENSE). diff --git a/local/recipes/shells/brush/source/benchmarks/.gitignore b/local/recipes/shells/brush/source/benchmarks/.gitignore new file mode 100644 index 0000000000..c18dd8d83c --- /dev/null +++ b/local/recipes/shells/brush/source/benchmarks/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/local/recipes/shells/brush/source/benchmarks/bench.py b/local/recipes/shells/brush/source/benchmarks/bench.py new file mode 100755 index 0000000000..3dcb6d80df --- /dev/null +++ b/local/recipes/shells/brush/source/benchmarks/bench.py @@ -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() diff --git a/local/recipes/shells/brush/source/brush-builtins/Cargo.toml b/local/recipes/shells/brush/source/brush-builtins/Cargo.toml new file mode 100644 index 0000000000..f8a4846766 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-builtins/Cargo.toml @@ -0,0 +1,173 @@ +[package] +name = "brush-builtins" +description = "Builtins for a POSIX/bash shell (used by brush-shell)" +version = "0.2.0" +categories.workspace = true +edition.workspace = true +keywords.workspace = true +license.workspace = true +readme.workspace = true +repository.workspace = true +rust-version.workspace = true + +[lib] +bench = false + +[features] +default = [ + 'builtin.alias', + 'builtin.bg', + 'builtin.bind', + 'builtin.break', + 'builtin.builtin', + 'builtin.caller', + 'builtin.cd', + 'builtin.colon', + 'builtin.command', + 'builtin.compgen', + 'builtin.compopt', + 'builtin.complete', + 'builtin.continue', + 'builtin.declare', + 'builtin.dirs', + 'builtin.dot', + 'builtin.echo', + 'builtin.enable', + 'builtin.eval', + 'builtin.exec', + 'builtin.exit', + 'builtin.export', + 'builtin.false', + 'builtin.fc', + 'builtin.fg', + 'builtin.getopts', + 'builtin.hash', + 'builtin.help', + 'builtin.history', + 'builtin.jobs', + 'builtin.kill', + 'builtin.let', + 'builtin.mapfile', + 'builtin.popd', + 'builtin.printf', + 'builtin.pushd', + 'builtin.pwd', + 'builtin.read', + 'builtin.return', + 'builtin.set', + 'builtin.shift', + 'builtin.shopt', + 'builtin.suspend', + 'builtin.test', + 'builtin.times', + 'builtin.trap', + 'builtin.true', + 'builtin.type', + 'builtin.ulimit', + 'builtin.umask', + 'builtin.unalias', + 'builtin.unset', + 'builtin.wait', +] + +'builtin.alias' = [] +'builtin.bg' = [] +'builtin.bind' = [] +'builtin.break' = [] +'builtin.builtin' = [] +'builtin.caller' = [] +'builtin.cd' = [] +'builtin.colon' = [] +'builtin.command' = [] +'builtin.compgen' = [] +'builtin.complete' = [] +'builtin.compopt' = [] +'builtin.continue' = [] +'builtin.declare' = [] +'builtin.dirs' = [] +'builtin.dot' = [] +'builtin.echo' = [] +'builtin.enable' = [] +'builtin.eval' = [] +'builtin.exec' = [] +'builtin.exit' = [] +'builtin.export' = [] +'builtin.false' = [] +'builtin.fc' = [] +'builtin.fg' = [] +'builtin.getopts' = [] +'builtin.hash' = [] +'builtin.help' = [] +'builtin.history' = [] +'builtin.jobs' = [] +'builtin.kill' = [] +'builtin.let' = [] +'builtin.mapfile' = [] +'builtin.popd' = [] +'builtin.printf' = [] +'builtin.pushd' = [] +'builtin.pwd' = [] +'builtin.read' = [] +'builtin.return' = [] +'builtin.set' = [] +'builtin.shift' = [] +'builtin.shopt' = [] +'builtin.suspend' = [] +'builtin.test' = [] +'builtin.times' = [] +'builtin.trap' = [] +'builtin.true' = [] +'builtin.type' = [] +'builtin.ulimit' = [] +'builtin.umask' = [] +'builtin.unalias' = [] +'builtin.unset' = [] +'builtin.wait' = [] + +[lints] +workspace = true + +[dependencies] +brush-core = { version = "^0.5.0", path = "../brush-core" } +brush-parser = { version = "^0.4.0", path = "../brush-parser" } +cfg-if = "1.0.4" +chrono = "0.4.44" +clap = { version = "4.6.0", features = ["derive", "wrap_help"] } +fancy-regex = "0.18.0" +itertools = "0.14.0" +strum = "0.28.0" +thiserror = "2.0.18" +tracing = "0.1.44" + +[target.'cfg(target_family = "wasm")'.dependencies] +tokio = { version = "1.52.3", features = ["io-util", "macros", "rt"] } + +[target.'cfg(any(unix, windows))'.dependencies] +tokio = { version = "1.52.3", features = [ + "io-util", + "macros", + "process", + "rt", + "rt-multi-thread", + "signal", + "sync", +] } +uucore = { version = "0.8.0", default-features = false, features = ["format"] } + +[target.'cfg(unix)'.dependencies] +nix = { version = "0.31.2", features = [ + "fs", + "process", + "resource", + "signal", + "term", + "user", +] } +rlimit = "0.11.0" + +[target.'cfg(any(target_os = "linux", target_os = "android"))'.dependencies] +procfs = "0.18.0" + +[dev-dependencies] +anyhow = "1.0.102" +pretty_assertions = { version = "1.4.1", features = ["unstable"] } diff --git a/local/recipes/shells/brush/source/brush-builtins/LICENSE b/local/recipes/shells/brush/source/brush-builtins/LICENSE new file mode 120000 index 0000000000..ea5b60640b --- /dev/null +++ b/local/recipes/shells/brush/source/brush-builtins/LICENSE @@ -0,0 +1 @@ +../LICENSE \ No newline at end of file diff --git a/local/recipes/shells/brush/source/brush-builtins/src/alias.rs b/local/recipes/shells/brush/source/brush-builtins/src/alias.rs new file mode 100644 index 0000000000..8ba642ffd9 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-builtins/src/alias.rs @@ -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, +} + +impl builtins::Command for AliasCommand { + type Error = brush_core::Error; + + async fn execute( + &self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + let mut exit_code = ExecutionResult::success(); + + if self.print || self.aliases.is_empty() { + for (name, value) in context.shell.aliases() { + writeln!(context.stdout(), "alias {name}='{value}'")?; + } + } else { + for alias in &self.aliases { + if let Some((name, unexpanded_value)) = alias.split_once('=') + && !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) + } +} diff --git a/local/recipes/shells/brush/source/brush-builtins/src/bg.rs b/local/recipes/shells/brush/source/brush-builtins/src/bg.rs new file mode 100644 index 0000000000..97d8342238 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-builtins/src/bg.rs @@ -0,0 +1,47 @@ +use clap::Parser; +use std::io::Write; + +use brush_core::{ExecutionResult, builtins}; + +/// Moves a job to run in the background. +#[derive(Parser)] +pub(crate) struct BgCommand { + /// List of job specs to move to background. + job_specs: Vec, +} + +impl builtins::Command for BgCommand { + type Error = brush_core::Error; + + async fn execute( + &self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + let mut exit_code = ExecutionResult::success(); + + if !self.job_specs.is_empty() { + for job_spec in &self.job_specs { + if let Some(job) = context.shell.jobs_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) + } +} diff --git a/local/recipes/shells/brush/source/brush-builtins/src/bind.rs b/local/recipes/shells/brush/source/brush-builtins/src/bind.rs new file mode 100644 index 0000000000..de41770c91 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-builtins/src/bind.rs @@ -0,0 +1,572 @@ +use clap::{Parser, ValueEnum}; +use itertools::Itertools as _; +use std::{collections::HashMap, io::Write, str::FromStr as _, sync::Arc}; +use strum::IntoEnumIterator; +use tokio::sync::Mutex; + +use brush_core::{ + ExecutionExitCode, ExecutionResult, builtins, + interfaces::{self, InputFunction, KeyAction, KeySequence}, + sys, trace_categories, +}; + +/// Identifier for a keymap +#[derive(Clone, ValueEnum)] +enum BindKeyMap { + #[clap(name = "emacs-standard", alias = "emacs")] + EmacsStandard, + #[clap(name = "emacs-meta")] + EmacsMeta, + #[clap(name = "emacs-ctlx")] + EmacsCtlx, + #[clap(name = "vi-command", aliases = &["vi", "vi-move"])] + ViCommand, + #[clap(name = "vi-insert")] + ViInsert, +} + +impl BindKeyMap { + const fn is_vi(&self) -> bool { + matches!(self, Self::ViCommand | Self::ViInsert) + } + + #[expect(dead_code)] + const fn is_emacs(&self) -> bool { + matches!( + self, + Self::EmacsStandard | Self::EmacsMeta | Self::EmacsCtlx + ) + } +} + +/// Inspect and modify key bindings and other input configuration. +#[derive(Parser)] +pub(crate) struct BindCommand { + /// Name of key map to use. + #[arg(short = 'm')] + keymap: Option, + /// List functions. + #[arg(short = 'l')] + list_funcs: bool, + /// List functions and bindings. + #[arg(short = 'P')] + list_funcs_and_bindings: bool, + /// List functions and bindings in a format suitable for use as input. + #[arg(short = 'p')] + list_funcs_and_bindings_reusable: bool, + /// List key sequences that invoke macros. + #[arg(short = 'S')] + list_key_seqs_that_invoke_macros: bool, + /// List key sequences that invoke macros in a format suitable for use as input. + #[arg(short = 's')] + list_key_seqs_that_invoke_macros_reusable: bool, + /// List variables. + #[arg(short = 'V')] + list_vars: bool, + /// List variables in a format suitable for use as input. + #[arg(short = 'v')] + list_vars_reusable: bool, + /// Find the keys bound to the given named function. + #[arg(short = 'q', value_name = "FUNC_NAME")] + query_func_bindings: Option, + /// Remove all bindings for the given named function. + #[arg(short = 'u', value_name = "FUNC_NAME")] + remove_func_bindings: Option, + /// Remove the binding for the given key sequence. + #[arg(short = 'r', value_name = "KEY_SEQ")] + remove_key_seq_binding: Option, + /// Import bindings from the given file. + #[arg(short = 'f', value_name = "PATH")] + bindings_file: Option, + /// Bind key sequence to command. + #[arg(short = 'x', value_name = "BINDING")] + key_seq_bindings: Vec, + /// List key sequence bindings. + #[arg(short = 'X')] + list_key_seq_bindings: bool, + /// Key sequence binding to readline function or command. + key_sequence: Option, +} + +#[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( + &self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + 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>, + context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, + ) -> Result { + let mut bindings = bindings.lock().await; + + if self.list_funcs { + for func in interfaces::InputFunction::iter() { + writeln!(context.stdout(), "{func}")?; + } + } + + if self.list_funcs_and_bindings { + 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 { + // 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 { + 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+ 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::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> = HashMap::new(); + for (seq, action) in &bindings.get_current() { + let KeyAction::DoInputFunction(func) = action else { + continue; + }; + + sequences_by_func + .entry(func.clone()) + .or_default() + .push(seq.clone()); + } + + let sorted_funcs = interfaces::InputFunction::iter().sorted_by_key(|f| f.to_string()); + + for func in sorted_funcs { + match sequences_by_func.get(&func) { + Some(seqs) if reusable => { + for seq in seqs { + writeln!(context.stdout(), "\"{seq}\": {func}")?; + } + } + Some(seqs) => { + writeln!( + context.stdout(), + "{func} can be found on {}.", + seqs.iter().map(|seq| std::format!("\"{seq}\"")).join(", ") + )?; + } + None if reusable => { + writeln!(context.stdout(), "# {func} (not bound)")?; + } + None => { + writeln!(context.stdout(), "{func} is not bound to any keys")?; + } + } + } + + Ok(()) +} + +fn display_macros( + bindings: &dyn interfaces::KeyBindings, + context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, + reusable: bool, +) -> Result<(), BindError> { + for (left, right) in bindings.get_macros() { + if reusable { + writeln!(context.stdout(), "\"{left}\": \"{right}\"")?; + } else { + writeln!(context.stdout(), "{left} outputs {right}")?; + } + } + + Ok(()) +} + +fn find_key_seqs_bound_to_function( + bindings: &dyn interfaces::KeyBindings, + func_str: &str, +) -> Result, 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) + ); + } +} diff --git a/local/recipes/shells/brush/source/brush-builtins/src/break_.rs b/local/recipes/shells/brush/source/brush-builtins/src/break_.rs new file mode 100644 index 0000000000..d16a03948b --- /dev/null +++ b/local/recipes/shells/brush/source/brush-builtins/src/break_.rs @@ -0,0 +1,34 @@ +use clap::Parser; + +use brush_core::{ExecutionControlFlow, ExecutionExitCode, ExecutionResult, builtins}; + +/// Breaks out of a control-flow loop. +#[derive(Parser)] +pub(crate) struct BreakCommand { + /// If specified, indicates which nested loop to break out of. + #[clap(default_value_t = 1)] + which_loop: i8, +} + +impl builtins::Command for BreakCommand { + type Error = brush_core::Error; + + async fn execute( + &self, + _context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + // If specified, which_loop needs to be positive. + if self.which_loop <= 0 { + return Ok(ExecutionExitCode::InvalidUsage.into()); + } + + let mut result = ExecutionResult::success(); + + result.next_control_flow = ExecutionControlFlow::BreakLoop { + #[expect(clippy::cast_sign_loss)] + levels: (self.which_loop - 1) as usize, + }; + + Ok(result) + } +} diff --git a/local/recipes/shells/brush/source/brush-builtins/src/builder.rs b/local/recipes/shells/brush/source/brush-builtins/src/builder.rs new file mode 100644 index 0000000000..291ecc6d84 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-builtins/src/builder.rs @@ -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 ShellBuilderExt + for brush_core::ShellBuilder +{ + fn default_builtins(self, set: BuiltinSet) -> Self { + self.builtins(crate::default_builtins(set)) + } +} diff --git a/local/recipes/shells/brush/source/brush-builtins/src/builtin_.rs b/local/recipes/shells/brush/source/brush-builtins/src/builtin_.rs new file mode 100644 index 0000000000..271f0633c2 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-builtins/src/builtin_.rs @@ -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, +} + +impl builtins::DeclarationCommand for BuiltinCommand { + fn set_declarations(&mut self, args: Vec) { + self.args = args; + } +} + +impl builtins::Command for BuiltinCommand { + type Error = brush_core::Error; + + async fn execute( + &self, + mut context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + if self.args.is_empty() { + return Ok(ExecutionResult::success()); + } + + let args: Vec<_> = self.args.iter().skip(1).cloned().collect(); + if args.is_empty() { + return Ok(ExecutionResult::success()); + } + + let builtin_name = args[0].to_string(); + + if let Some(builtin) = context.shell.builtins().get(&builtin_name) + && !builtin.disabled + { + context.command_name = builtin_name; + (builtin.execute_func)(context, args).await + } else { + Err(brush_core::ErrorKind::BuiltinNotFound(builtin_name).into()) + } + } +} diff --git a/local/recipes/shells/brush/source/brush-builtins/src/caller.rs b/local/recipes/shells/brush/source/brush-builtins/src/caller.rs new file mode 100644 index 0000000000..58c7b3a849 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-builtins/src/caller.rs @@ -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, +} + +impl builtins::Command for CallerCommand { + type Error = brush_core::Error; + + async fn execute( + &self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + 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()) + } +} diff --git a/local/recipes/shells/brush/source/brush-builtins/src/cd.rs b/local/recipes/shells/brush/source/brush-builtins/src/cd.rs new file mode 100644 index 0000000000..fdae4ca69e --- /dev/null +++ b/local/recipes/shells/brush/source/brush-builtins/src/cd.rs @@ -0,0 +1,97 @@ +use std::io::Write; +use std::path::PathBuf; + +use clap::Parser; + +use brush_core::{ExecutionResult, builtins, error}; + +/// Change the current shell working directory. +#[derive(Parser)] +pub(crate) struct CdCommand { + /// Force following symlinks. + #[arg(short = 'L', overrides_with = "use_physical_dir")] + force_follow_symlinks: bool, + + /// Use physical dir structure without following symlinks. + #[arg(short = 'P', overrides_with = "force_follow_symlinks")] + use_physical_dir: bool, + + /// Exit with non zero exit status if current working directory resolution fails. + #[arg(short = 'e')] + exit_on_failed_cwd_resolution: bool, + + /// Show file with extended attributes as a dir with extended + /// attributes. + #[arg(short = '@')] + file_with_xattr_as_dir: bool, + + /// By default it is the value of the HOME shell variable. If `TARGET_DIR` is "-", it is + /// converted to $OLDPWD. + target_dir: Option, +} + +impl builtins::Command for CdCommand { + type Error = brush_core::Error; + + async fn execute( + &self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + // 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()) + } +} diff --git a/local/recipes/shells/brush/source/brush-builtins/src/colon.rs b/local/recipes/shells/brush/source/brush-builtins/src/colon.rs new file mode 100644 index 0000000000..7d5f749475 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-builtins/src/colon.rs @@ -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 { + match content_type { + builtins::ContentType::DetailedHelp => { + Ok("Null command; always returns success.".into()) + } + builtins::ContentType::ShortUsage => Ok(":: :".into()), + builtins::ContentType::ShortDescription => Ok(": - Null command".into()), + builtins::ContentType::ManPage => error::unimp("man page not yet implemented"), + } + } + + fn execute, S: AsRef>( + _context: brush_core::ExecutionContext<'_, SE>, + _args: I, + ) -> Result { + Ok(ExecutionResult::success()) + } +} diff --git a/local/recipes/shells/brush/source/brush-builtins/src/command.rs b/local/recipes/shells/brush/source/brush-builtins/src/command.rs new file mode 100644 index 0000000000..5401181b03 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-builtins/src/command.rs @@ -0,0 +1,160 @@ +use clap::Parser; +use std::{fmt::Display, io::Write, path::Path}; + +use brush_core::{ + ExecutionResult, builtins, commands, pathsearch, + sys::{self, fs::PathExt}, +}; + +/// Directly invokes an external command, without going through typical search order. +#[derive(Default, Parser)] +pub(crate) struct CommandCommand { + /// Use default PATH value. + #[arg(short = 'p')] + pub use_default_path: bool, + + /// Display a short description of the command. + #[arg(short = 'v')] + pub print_description: bool, + + /// Display a more verbose description of the command. + #[arg(short = 'V')] + pub print_verbose_description: bool, + + /// Command and arguments. + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + pub command_and_args: Vec, +} + +impl CommandCommand { + fn command(&self) -> Option<&str> { + self.command_and_args.first().map(|s| s.as_str()) + } +} + +impl builtins::Command for CommandCommand { + type Error = brush_core::Error; + + async fn execute( + &self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + // Silently exit if no command was provided. + if let Some(command_name) = self.command() { + if self.print_description || self.print_verbose_description { + if let Some(found_cmd) = + Self::try_find_command(context.shell, command_name, self.use_default_path) + { + if self.print_description { + writeln!(context.stdout(), "{found_cmd}")?; + } else { + match found_cmd { + FoundCommand::Builtin(_name) => { + writeln!(context.stdout(), "{command_name} is a shell builtin")?; + } + FoundCommand::External(path) => { + writeln!(context.stdout(), "{command_name} is {path}")?; + } + } + } + Ok(ExecutionResult::success()) + } else { + if self.print_verbose_description { + writeln!(context.stderr(), "command: {command_name}: not found")?; + } + Ok(ExecutionResult::general_error()) + } + } else { + self.execute_command(context, command_name, self.use_default_path) + .await + } + } else { + Ok(ExecutionResult::success()) + } + } +} + +enum FoundCommand { + Builtin(String), + External(String), +} + +impl Display for FoundCommand { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Builtin(name) => write!(f, "{name}"), + Self::External(path) => write!(f, "{path}"), + } + } +} + +impl CommandCommand { + fn try_find_command( + shell: &mut brush_core::Shell, + command_name: &str, + use_default_path: bool, + ) -> Option { + // Look in path. + if 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 { + command_name.clone_into(&mut context.command_name); + let command_and_args = self + .command_and_args + .iter() + .map(brush_core::CommandArg::from); + + let path_dirs = if use_default_path { + Some(sys::fs::get_default_standard_utils_paths()) + } else { + None + }; + + let mut cmd = commands::SimpleCommand::new( + commands::ShellForCommand::ParentShell(context.shell), + context.params, + context.command_name, + command_and_args, + ); + cmd.use_functions = false; + cmd.path_dirs = path_dirs; + + let spawn_result = cmd.execute().await?; + let wait_result = spawn_result.wait().await?; + + Ok(wait_result.into()) + } +} diff --git a/local/recipes/shells/brush/source/brush-builtins/src/complete.rs b/local/recipes/shells/brush/source/brush-builtins/src/complete.rs new file mode 100644 index 0000000000..32cec71b0e --- /dev/null +++ b/local/recipes/shells/brush/source/brush-builtins/src/complete.rs @@ -0,0 +1,647 @@ +use clap::Parser; +use std::collections::HashMap; +use std::fmt::Write as _; +use std::io::Write; + +use brush_core::completion::{self, CompleteAction, CompleteOption, Spec}; +use brush_core::{ExecutionExitCode, ExecutionResult, builtins, error, escape}; + +#[derive(Parser)] +struct CommonCompleteCommandArgs { + /// Options governing the behavior of completions. + #[arg(short = 'o')] + options: Vec, + + /// Actions to apply to generate completions. + #[arg(short = 'A')] + actions: Vec, + + /// File glob pattern to be expanded to generate completions. + #[arg(short = 'G', allow_hyphen_values = true, value_name = "GLOB")] + glob_pattern: Option, + + /// List of words that will be considered as completions. + #[arg(short = 'W', allow_hyphen_values = true)] + word_list: Option, + + /// Name of a shell function to invoke to generate completions. + #[arg(short = 'F', allow_hyphen_values = true, value_name = "FUNC_NAME")] + function_name: Option, + + /// Command to execute to generate completions. + #[arg(short = 'C', allow_hyphen_values = true)] + command: Option, + + /// Pattern used as filter for completions. + #[arg(short = 'X', allow_hyphen_values = true, value_name = "PATTERN")] + filter_pattern: Option, + + /// Prefix pattern used as filter for completions. + #[arg(short = 'P', allow_hyphen_values = true)] + prefix: Option, + + /// Suffix pattern used as filter for completions. + #[arg(short = 'S', allow_hyphen_values = true)] + suffix: Option, + + /// Complete with valid aliases. + #[arg(short = 'a')] + action_alias: bool, + + /// Complete with names of shell builtins. + #[arg(short = 'b')] + action_builtin: bool, + + /// Complete with names of executable commands. + #[arg(short = 'c')] + action_command: bool, + + /// Complete with directory names. + #[arg(short = 'd')] + action_directory: bool, + + /// Complete with names of exported shell variables. + #[arg(short = 'e')] + action_exported: bool, + + /// Complete with filenames. + #[arg(short = 'f')] + action_file: bool, + + /// Complete with valid user groups. + #[arg(short = 'g')] + action_group: bool, + + /// Complete with job specs. + #[arg(short = 'j')] + action_job: bool, + + /// Complete with keywords. + #[arg(short = 'k')] + action_keyword: bool, + + /// Complete with names of system services. + #[arg(short = 's')] + action_service: bool, + + /// Complete with valid usernames. + #[arg(short = 'u')] + action_user: bool, + + /// Complete with names of shell variables. + #[arg(short = 'v')] + action_variable: bool, +} + +impl CommonCompleteCommandArgs { + fn create_spec(&self, extglob_enabled: bool) -> completion::Spec { + let filter_pattern_excludes; + let filter_pattern = if let Some(filter_pattern) = self.filter_pattern.as_ref() { + // If the pattern starts with a '!' that's not the start of an extglob pattern, + // then we invert. + if let Some(remaining_pattern) = filter_pattern.strip_prefix('!') { + if !extglob_enabled || !remaining_pattern.starts_with('(') { + filter_pattern_excludes = false; + Some(remaining_pattern.to_owned()) + } else { + filter_pattern_excludes = true; + Some(filter_pattern.to_owned()) + } + } else { + filter_pattern_excludes = true; + Some(filter_pattern.clone()) + } + } else { + filter_pattern_excludes = false; + None + }; + + let mut spec = completion::Spec { + options: completion::GenerationOptions::default(), + actions: self.resolve_actions(), + glob_pattern: self.glob_pattern.clone(), + word_list: self.word_list.clone(), + function_name: self.function_name.clone(), + command: self.command.clone(), + filter_pattern, + filter_pattern_excludes, + prefix: self.prefix.clone(), + suffix: self.suffix.clone(), + }; + + for option in &self.options { + match option { + CompleteOption::BashDefault => spec.options.bash_default = true, + CompleteOption::Default => spec.options.default = true, + CompleteOption::DirNames => spec.options.dir_names = true, + CompleteOption::FileNames => spec.options.file_names = true, + CompleteOption::NoQuote => spec.options.no_quote = true, + CompleteOption::NoSort => spec.options.no_sort = true, + CompleteOption::NoSpace => spec.options.no_space = true, + CompleteOption::PlusDirs => spec.options.plus_dirs = true, + } + } + + spec + } + + fn resolve_actions(&self) -> Vec { + let mut actions = self.actions.clone(); + + actions.extend( + [ + (self.action_alias, CompleteAction::Alias), + (self.action_builtin, CompleteAction::Builtin), + (self.action_command, CompleteAction::Command), + (self.action_directory, CompleteAction::Directory), + (self.action_exported, CompleteAction::Export), + (self.action_file, CompleteAction::File), + (self.action_group, CompleteAction::Group), + (self.action_job, CompleteAction::Job), + (self.action_keyword, CompleteAction::Keyword), + (self.action_service, CompleteAction::Service), + (self.action_user, CompleteAction::User), + (self.action_variable, CompleteAction::Variable), + ] + .into_iter() + .filter_map(|(enabled, action)| enabled.then_some(action)), + ); + + actions + } +} + +/// Configure programmable command completion. +#[derive(Parser)] +pub(crate) struct CompleteCommand { + /// Display registered completion settings. + #[arg(short = 'p')] + print: bool, + + /// Remove the completion settings associated with the given command. + #[arg(short = 'r')] + remove: bool, + + /// Apply these settings to the default completion scenario. + #[arg(short = 'D')] + use_as_default: bool, + + /// Apply these settings to completion of empty lines. + #[arg(short = 'E')] + use_for_empty_line: bool, + + /// Apply these settings to completion of the initial word of the input line. + #[arg(short = 'I')] + use_for_initial_word: bool, + + #[clap(flatten)] + common_args: CommonCompleteCommandArgs, + + names: Vec, +} + +impl builtins::Command for CompleteCommand { + type Error = brush_core::Error; + + async fn execute( + &self, + mut context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + let mut result = ExecutionResult::success(); + + // If -D, -E, or -I are specified, then any names provided are ignored. + if self.use_as_default + || self.use_for_empty_line + || self.use_for_initial_word + || self.names.is_empty() + { + self.process_global(&mut context)?; + } else { + for name in &self.names { + if !self.try_process_for_command(&mut context, name.as_str())? { + result = ExecutionResult::general_error(); + } + } + } + + Ok(result) + } +} + +impl CompleteCommand { + fn process_global( + &self, + context: &mut brush_core::ExecutionContext<'_, 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 { + 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 { + 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, +} + +impl builtins::Command for CompGenCommand { + type Error = brush_core::Error; + + async fn execute( + &self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + let mut spec = self + .common_args + .create_spec(context.shell.options().extended_globbing); + spec.options.no_sort = true; + + let token_to_complete = self.word.as_deref().unwrap_or_default(); + + // We unquote the token-to-be-completed before passing it to the completion system. + let unquoted_token = brush_parser::unquote_str(token_to_complete); + + let completion_context = completion::Context { + token_to_complete: unquoted_token.as_str(), + preceding_token: None, + command_name: None, + token_index: 0, + tokens: &[&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, + #[arg(long = concat!("+o"), hide = true)] + disabled_options: Vec, + + /// If specified, scopes updates to completions of the named commands. + names: Vec, +} + +impl builtins::Command for CompOptCommand { + type Error = brush_core::Error; + + async fn execute( + &self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + let mut options = + HashMap::with_capacity(self.disabled_options.len() + self.enabled_options.len()); + for option in &self.disabled_options { + options.insert(option.clone(), false); + } + for option in &self.enabled_options { + options.insert(option.clone(), true); + } + + if !self.names.is_empty() { + if self.update_default || self.update_empty || self.update_initial_word { + writeln!( + context.stderr(), + "compopt: cannot specify names with -D, -E, or -I" + )?; + return Ok(ExecutionExitCode::InvalidUsage.into()); + } + + for name in &self.names { + let spec = context.shell.completion_config_mut().get_or_add_mut(name); + Self::set_options_for_spec(spec, &options); + } + } else if self.update_default { + if let Some(spec) = &mut context.shell.completion_config_mut().default { + Self::set_options_for_spec(spec, &options); + } else { + let mut spec = Spec::default(); + Self::set_options_for_spec(&mut spec, &options); + context.shell.completion_config_mut().default = Some(spec); + } + } else if self.update_empty { + if let Some(spec) = &mut context.shell.completion_config_mut().empty_line { + Self::set_options_for_spec(spec, &options); + } else { + let mut spec = Spec::default(); + Self::set_options_for_spec(&mut spec, &options); + context.shell.completion_config_mut().empty_line = Some(spec); + } + } else if self.update_initial_word { + if let Some(spec) = &mut context.shell.completion_config_mut().initial_word { + Self::set_options_for_spec(spec, &options); + } else { + let mut spec = Spec::default(); + Self::set_options_for_spec(&mut spec, &options); + context.shell.completion_config_mut().initial_word = Some(spec); + } + } else { + // If we got here, then we need to apply to any completion actively in-flight. + if let Some(in_flight_options) = context + .shell + .completion_config_mut() + .current_completion_options + .as_mut() + { + Self::set_options(in_flight_options, &options); + } + } + + Ok(ExecutionResult::success()) + } +} + +impl CompOptCommand { + fn set_options_for_spec<'a, I>(spec: &mut Spec, options: I) + where + I: IntoIterator, + { + Self::set_options(&mut spec.options, options); + } + + fn set_options<'a, I>(target_options: &mut completion::GenerationOptions, options: I) + where + I: IntoIterator, + { + for (option, value) in options { + match option { + CompleteOption::BashDefault => target_options.bash_default = *value, + CompleteOption::Default => target_options.default = *value, + CompleteOption::DirNames => target_options.dir_names = *value, + CompleteOption::FileNames => target_options.file_names = *value, + CompleteOption::NoQuote => target_options.no_quote = *value, + CompleteOption::NoSort => target_options.no_sort = *value, + CompleteOption::NoSpace => target_options.no_space = *value, + CompleteOption::PlusDirs => target_options.plus_dirs = *value, + } + } + } +} diff --git a/local/recipes/shells/brush/source/brush-builtins/src/continue_.rs b/local/recipes/shells/brush/source/brush-builtins/src/continue_.rs new file mode 100644 index 0000000000..8634097a04 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-builtins/src/continue_.rs @@ -0,0 +1,34 @@ +use clap::Parser; + +use brush_core::{ExecutionControlFlow, ExecutionExitCode, ExecutionResult, builtins}; + +/// Continue to the next iteration of a control-flow loop. +#[derive(Parser)] +pub(crate) struct ContinueCommand { + /// If specified, indicates which nested loop to continue to the next iteration of. + #[clap(default_value_t = 1)] + which_loop: i8, +} + +impl builtins::Command for ContinueCommand { + type Error = brush_core::Error; + + async fn execute( + &self, + _context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + // If specified, which_loop needs to be positive. + if self.which_loop <= 0 { + return Ok(ExecutionExitCode::InvalidUsage.into()); + } + + let mut result = ExecutionResult::success(); + + result.next_control_flow = ExecutionControlFlow::ContinueLoop { + #[expect(clippy::cast_sign_loss)] + levels: (self.which_loop - 1) as usize, + }; + + Ok(result) + } +} diff --git a/local/recipes/shells/brush/source/brush-builtins/src/declare.rs b/local/recipes/shells/brush/source/brush-builtins/src/declare.rs new file mode 100644 index 0000000000..11ee10427a --- /dev/null +++ b/local/recipes/shells/brush/source/brush-builtins/src/declare.rs @@ -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, +} + +#[derive(Clone, Copy)] +enum DeclareVerb { + Declare, + Local, + Readonly, +} + +impl builtins::DeclarationCommand for DeclareCommand { + fn set_declarations(&mut self, declarations: Vec) { + self.declarations = declarations; + } +} + +impl builtins::Command for DeclareCommand { + fn takes_plus_options() -> bool { + true + } + + type Error = brush_core::Error; + + async fn execute( + &self, + mut context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + let verb = match context.command_name.as_str() { + "local" => DeclareVerb::Local, + "readonly" => DeclareVerb::Readonly, + _ => DeclareVerb::Declare, + }; + + if matches!(verb, DeclareVerb::Local) && !context.shell.in_function() { + writeln!(context.stderr(), "can only be used in a function")?; + return Ok(ExecutionResult::general_error()); + } + + if self.locals_inherit_from_prev_scope { + return error::unimp("declare -I"); + } + + let mut result = ExecutionResult::success(); + if !self.declarations.is_empty() { + for declaration in &self.declarations { + if self.print && !matches!(verb, DeclareVerb::Readonly) { + if !self.try_display_declaration(&context, declaration, verb)? { + result = ExecutionResult::general_error(); + } + } else { + if !self.process_declaration(&mut context, declaration, verb)? { + result = ExecutionResult::general_error(); + } + } + } + } else { + // Display matching declarations from the variable environment. + if !self.function_names_only && !self.function_names_or_defs_only { + self.display_matching_env_declarations(&context, verb)?; + } + + // Do the same for functions. + if !matches!(verb, DeclareVerb::Local | DeclareVerb::Readonly) + && (!self.print || self.function_names_only || self.function_names_or_defs_only) + { + self.display_matching_functions(&context)?; + } + } + + Ok(result) + } +} + +impl DeclareCommand { + fn try_display_declaration( + &self, + context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, + declaration: &brush_core::CommandArg, + verb: DeclareVerb, + ) -> Result { + let name = match declaration { + brush_core::CommandArg::String(s) => s, + brush_core::CommandArg::Assignment(_) => { + writeln!(context.stderr(), "declare: {declaration}: not found")?; + return Ok(false); + } + }; + + let lookup = if matches!(verb, DeclareVerb::Local) { + EnvironmentLookup::OnlyInCurrentLocal + } else { + EnvironmentLookup::Anywhere + }; + + if self.function_names_only || self.function_names_or_defs_only { + if let Some(func_registration) = context.shell.funcs().get(name) { + if self.function_names_only { + if self.print { + writeln!(context.stdout(), "declare -f {name}")?; + } else { + writeln!(context.stdout(), "{name}")?; + } + } else { + writeln!(context.stdout(), "{}", func_registration.definition())?; + } + Ok(true) + } else { + // For some reason, bash does not print an error message in this case. + Ok(false) + } + } else if let Some(variable) = context.shell.env().get_using_policy(name, lookup) { + let mut cs = variable.attribute_flags(context.shell); + if cs.is_empty() { + cs.push('-'); + } + + let resolved_value = variable.resolve_value(context.shell); + let separator_str = if matches!(resolved_value, ShellValue::Unset(_)) { + "" + } else { + "=" + }; + + writeln!( + context.stdout(), + "declare -{cs} {name}{separator_str}{}", + resolved_value.format(variables::FormatStyle::DeclarePrint, context.shell)? + )?; + + Ok(true) + } else { + writeln!(context.stderr(), "declare: {name}: not found")?; + Ok(false) + } + } + + fn process_declaration( + &self, + context: &mut brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, + declaration: &brush_core::CommandArg, + verb: DeclareVerb, + ) -> Result { + 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, Option, bool), brush_core::Error> { + let name; + let assigned_index; + let initial_value; + let name_is_array; + + match declaration { + brush_core::CommandArg::String(s) => { + // We need to handle the case of someone invoking `declare array[index]`. + // In such case, we ignore the index and treat it as a declaration of + // the array. + #[allow( + clippy::unwrap_in_result, + clippy::unwrap_used, + reason = "regex is valid and should not fail" + )] + static ARRAY_AND_INDEX_RE: LazyLock = + LazyLock::new(|| fancy_regex::Regex::new(r"^(.*?)\[(.*?)\]$").unwrap()); + + if let Some(captures) = ARRAY_AND_INDEX_RE.captures(s)? { + name = captures + .get(1) + .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 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(()) + } +} diff --git a/local/recipes/shells/brush/source/brush-builtins/src/dirs.rs b/local/recipes/shells/brush/source/brush-builtins/src/dirs.rs new file mode 100644 index 0000000000..790310226a --- /dev/null +++ b/local/recipes/shells/brush/source/brush-builtins/src/dirs.rs @@ -0,0 +1,101 @@ +use clap::Parser; +use std::io::Write; + +use brush_core::{ExecutionResult, builtins}; + +#[derive(Debug, thiserror::Error)] +pub(crate) enum DirError { + /// Directory stack is empty. + #[error("directory stack is empty")] + DirStackEmpty, + + /// A shell error occurred. + #[error(transparent)] + ShellError(#[from] brush_core::Error), +} + +impl From<&DirError> for brush_core::ExecutionExitCode { + fn from(value: &DirError) -> Self { + match value { + DirError::DirStackEmpty => Self::GeneralError, + DirError::ShellError(e) => e.into(), + } + } +} + +impl brush_core::BuiltinError for DirError {} + +/// Manage the current directory stack. +#[derive(Default, Parser)] +pub(crate) struct DirsCommand { + /// Clear the directory stack. + #[arg(short = 'c')] + clear: bool, + + /// Don't tilde-shorten paths. + #[arg(short = 'l')] + tilde_long: bool, + + /// Print one directory per line instead of all on one line. + #[arg(short = 'p')] + print_one_per_line: bool, + + /// Print one directory per line with its index. + #[arg(short = 'v')] + print_one_per_line_with_index: bool, + // + // TODO(dirs): implement +N and -N +} + +impl builtins::Command for DirsCommand { + type Error = brush_core::Error; + + async fn execute( + &self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + 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::>(); + + let one_per_line = self.print_one_per_line || self.print_one_per_line_with_index; + + for (i, dir) in dirs.iter().enumerate() { + if !one_per_line && i > 0 { + write!(context.stdout(), " ")?; + } + + if self.print_one_per_line_with_index { + write!(context.stdout(), "{i:2} ")?; + } + + let mut dir_str = dir.to_string_lossy().to_string(); + + if !self.tilde_long { + dir_str = context.shell.tilde_shorten(dir_str); + } + + write!(context.stdout(), "{dir_str}")?; + + if one_per_line || i == dirs.len() - 1 { + writeln!(context.stdout())?; + } + } + + return Ok(ExecutionResult::success()); + } + + Ok(ExecutionResult::success()) + } +} diff --git a/local/recipes/shells/brush/source/brush-builtins/src/dot.rs b/local/recipes/shells/brush/source/brush-builtins/src/dot.rs new file mode 100644 index 0000000000..7bf5a4b59d --- /dev/null +++ b/local/recipes/shells/brush/source/brush-builtins/src/dot.rs @@ -0,0 +1,34 @@ +use std::path::Path; + +use brush_core::builtins; +use clap::Parser; + +/// Evaluate the provided script in the current shell environment. +#[derive(Parser)] +pub(crate) struct DotCommand { + /// Path to the script to evaluate. + script_path: String, + + /// Any arguments to be passed as positional parameters to the script. + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + script_args: Vec, +} + +impl builtins::Command for DotCommand { + type Error = brush_core::Error; + + async fn execute( + &self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + // TODO(dot): Handle trap inheritance. + context + .shell + .source_script( + Path::new(&self.script_path), + self.script_args.iter(), + &context.params, + ) + .await + } +} diff --git a/local/recipes/shells/brush/source/brush-builtins/src/echo.rs b/local/recipes/shells/brush/source/brush-builtins/src/echo.rs new file mode 100644 index 0000000000..03da65a5a8 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-builtins/src/echo.rs @@ -0,0 +1,81 @@ +use clap::Parser; +use std::io::Write; + +use brush_core::{ExecutionResult, builtins, escape}; + +/// Echo text to standard output. +#[derive(Parser)] +#[clap(disable_help_flag = true, disable_version_flag = true)] +pub(crate) struct EchoCommand { + /// Suppress the trailing newline from the output. + #[arg(short = 'n')] + no_trailing_newline: bool, + + /// Interpret backslash escapes in the provided text. + #[arg(short = 'e')] + interpret_backslash_escapes: bool, + + /// Do not interpret backslash escapes in the provided text. + #[arg(short = 'E')] + no_interpret_backslash_escapes: bool, + + /// Tokens to echo to standard output. + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, +} + +impl builtins::Command for EchoCommand { + type Error = brush_core::Error; + + /// Override the default [`builtins::Command::new`] function to handle clap's limitation related + /// to `--`. See [`builtins::parse_known`] for more information + /// TODO(echo): we can safely remove this after the issue is resolved + fn new(args: I) -> Result + where + I: IntoIterator, + { + let (mut this, rest_args) = brush_core::builtins::try_parse_known::(args)?; + if let Some(args) = rest_args { + this.args.extend(args); + } + Ok(this) + } + + async fn execute( + &self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + let mut trailing_newline = !self.no_trailing_newline; + let mut s; + if self.interpret_backslash_escapes { + s = String::new(); + for (i, arg) in self.args.iter().enumerate() { + if i > 0 { + s.push(' '); + } + + let (expanded_arg, keep_going) = escape::expand_backslash_escapes( + arg.as_str(), + escape::EscapeExpansionMode::EchoBuiltin, + )?; + s.push_str(&String::from_utf8_lossy(expanded_arg.as_slice())); + + if !keep_going { + trailing_newline = false; + break; + } + } + } else { + s = self.args.join(" "); + } + + if trailing_newline { + s.push('\n'); + } + + write!(context.stdout(), "{s}")?; + context.stdout().flush()?; + + Ok(ExecutionResult::success()) + } +} diff --git a/local/recipes/shells/brush/source/brush-builtins/src/enable.rs b/local/recipes/shells/brush/source/brush-builtins/src/enable.rs new file mode 100644 index 0000000000..48dc7d14fd --- /dev/null +++ b/local/recipes/shells/brush/source/brush-builtins/src/enable.rs @@ -0,0 +1,96 @@ +use brush_core::ExecutionResult; +use clap::Parser; +use itertools::Itertools; +use std::io::Write; + +use brush_core::builtins; +use brush_core::error; + +/// Enable, disable, or display built-in commands. +#[derive(Parser)] +pub(crate) struct EnableCommand { + /// Print a list of built-in commands. + #[arg(short = 'a')] + print_list: bool, + + /// Disables the specified built-in commands. + #[arg(short = 'n')] + disable: bool, + + /// Print a list of built-in commands with reusable output. + #[arg(short = 'p')] + print_reusably: bool, + + /// Only operate on special built-in commands. + #[arg(short = 's')] + special_only: bool, + + /// Path to a shared object from which built-in commands will be loaded. + #[arg(short = 'f', value_name = "PATH")] + shared_object_path: Option, + + /// Remove the built-in commands loaded from the indicated object path. + #[arg(short = 'd')] + remove_loaded_builtin: bool, + + /// Names of built-in commands to operate on. + names: Vec, +} + +impl builtins::Command for EnableCommand { + type Error = brush_core::Error; + + async fn execute( + &self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + let mut result = ExecutionResult::success(); + + if self.shared_object_path.is_some() { + return error::unimp("enable -f"); + } + if self.remove_loaded_builtin { + return error::unimp("enable -d"); + } + + if !self.names.is_empty() { + for name in &self.names { + if let Some(builtin) = context.shell.builtin_mut(name) { + builtin.disabled = self.disable; + } else { + writeln!(context.stderr(), "{name}: not a shell builtin")?; + result = ExecutionResult::general_error(); + } + } + } else { + let builtins: Vec<_> = context + .shell + .builtins() + .iter() + .sorted_by_key(|(name, _reg)| *name) + .collect(); + + for (builtin_name, builtin) in builtins { + if self.disable { + if !builtin.disabled { + continue; + } + } else if self.print_list { + if builtin.disabled { + continue; + } + } + + if self.special_only && !builtin.special_builtin { + continue; + } + + let prefix = if builtin.disabled { "-n " } else { "" }; + + writeln!(context.stdout(), "enable {prefix}{builtin_name}")?; + } + } + + Ok(result) + } +} diff --git a/local/recipes/shells/brush/source/brush-builtins/src/eval.rs b/local/recipes/shells/brush/source/brush-builtins/src/eval.rs new file mode 100644 index 0000000000..0526fc965a --- /dev/null +++ b/local/recipes/shells/brush/source/brush-builtins/src/eval.rs @@ -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, +} + +impl builtins::Command for EvalCommand { + type Error = brush_core::Error; + + async fn execute( + &self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + 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()) + } + } +} diff --git a/local/recipes/shells/brush/source/brush-builtins/src/exec.rs b/local/recipes/shells/brush/source/brush-builtins/src/exec.rs new file mode 100644 index 0000000000..d3ade049d6 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-builtins/src/exec.rs @@ -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, + + /// Exec command with an empty environment. + #[arg(short = 'c')] + empty_environment: bool, + + /// Exec command as a login shell. + #[arg(short = 'l')] + exec_as_login: bool, + + /// Command and args. + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, +} + +impl builtins::Command for ExecCommand { + type Error = brush_core::Error; + + async fn execute( + &self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + if self.args.is_empty() { + // When no arguments are present, then there's nothing for us to execute -- but we need + // to ensure that any redirections setup for this builtin get applied to the calling + // shell instance. + #[allow(clippy::needless_collect)] + let fds: Vec<_> = context.iter_fds().collect(); + + context.shell.replace_open_files(fds.into_iter()); + return Ok(ExecutionResult::success()); + } + + // 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()) + } + } +} diff --git a/local/recipes/shells/brush/source/brush-builtins/src/exit.rs b/local/recipes/shells/brush/source/brush-builtins/src/exit.rs new file mode 100644 index 0000000000..31786f3c8b --- /dev/null +++ b/local/recipes/shells/brush/source/brush-builtins/src/exit.rs @@ -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, +} + +impl builtins::Command for ExitCommand { + type Error = brush_core::Error; + + async fn execute( + &self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + #[expect(clippy::cast_sign_loss)] + let code_8bit = if let Some(code_32bit) = &self.code { + (code_32bit & 0xFF) as u8 + } else { + context.shell.last_exit_status() + }; + + let mut result = ExecutionResult::new(code_8bit); + result.next_control_flow = ExecutionControlFlow::ExitShell; + + Ok(result) + } +} diff --git a/local/recipes/shells/brush/source/brush-builtins/src/export.rs b/local/recipes/shells/brush/source/brush-builtins/src/export.rs new file mode 100644 index 0000000000..5e906bd2bf --- /dev/null +++ b/local/recipes/shells/brush/source/brush-builtins/src/export.rs @@ -0,0 +1,174 @@ +use clap::Parser; +use itertools::Itertools; +use std::io::Write; + +use brush_core::{ + ExecutionExitCode, ExecutionResult, builtins, + env::{EnvironmentLookup, EnvironmentScope}, + parser::ast, + variables, +}; + +/// Add or update exported shell variables. +#[derive(Parser)] +pub(crate) struct ExportCommand { + /// Names are treated as function names. + #[arg(short = 'f')] + names_are_functions: bool, + + /// Un-export the names. + #[arg(short = 'n')] + unexport: bool, + + /// Display all exported names. + #[arg(short = 'p')] + display_exported_names: bool, + + // + // Declarations + // + // N.B. These are skipped by clap, but filled in by the BuiltinDeclarationCommand trait. + #[clap(skip)] + declarations: Vec, +} + +impl builtins::DeclarationCommand for ExportCommand { + fn set_declarations(&mut self, declarations: Vec) { + self.declarations = declarations; + } +} + +impl builtins::Command for ExportCommand { + type Error = brush_core::Error; + + async fn execute( + &self, + mut context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + if self.declarations.is_empty() { + display_all_exported_vars(&context)?; + return Ok(ExecutionResult::success()); + } + + let mut result = ExecutionResult::success(); + for decl in &self.declarations { + let current_result = self.process_decl(&mut context, decl)?; + if !current_result.is_success() { + result = current_result; + } + } + + Ok(result) + } +} + +impl ExportCommand { + fn process_decl( + &self, + context: &mut brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, + decl: &brush_core::CommandArg, + ) -> Result { + match decl { + brush_core::CommandArg::String(s) => { + // See if this is supposed to be a function name. + if self.names_are_functions { + // Try to find the function already present; if we find it, then mark it + // exported. + if let Some(func) = context.shell.func_mut(s) { + if self.unexport { + func.unexport(); + } else { + func.export(); + } + } else { + writeln!(context.stderr(), "{s}: not a function")?; + return Ok(ExecutionExitCode::InvalidUsage.into()); + } + } + // Try to find the variable already present; if we find it, then mark it + // exported. + else if let Some((_, variable)) = context.shell.env_mut().get_mut(s) { + if self.unexport { + variable.unexport(); + } else { + variable.export(); + } + } + } + brush_core::CommandArg::Assignment(assignment) => { + let name = match &assignment.name { + ast::AssignmentName::VariableName(name) => name, + ast::AssignmentName::ArrayElementName(_, _) => { + writeln!(context.stderr(), "not a valid variable name")?; + return Ok(ExecutionExitCode::InvalidUsage.into()); + } + }; + + let value = match &assignment.value { + ast::AssignmentValue::Scalar(s) => { + variables::ShellValueLiteral::Scalar(s.flatten()) + } + ast::AssignmentValue::Array(a) => { + variables::ShellValueLiteral::Array(variables::ArrayLiteral( + a.iter() + .map(|(k, v)| (k.as_ref().map(|k| k.flatten()), v.flatten())) + .collect(), + )) + } + }; + + // `export name+=value` appends to the existing value, exactly like a + // bare `name+=value`. update_or_add always replaces, so when the + // variable already exists honor the append here. A missing variable + // falls through: appending to nothing is a plain assignment. + if assignment.append + && let Some((_, variable)) = context.shell.env_mut().get_mut(name) + { + variable.assign(value, true)?; + if self.unexport { + variable.unexport(); + } else { + variable.export(); + } + return Ok(ExecutionResult::success()); + } + + // Update the variable with the provided value and then mark it exported. + context.shell.env_mut().update_or_add( + name, + value, + |var| { + if self.unexport { + var.unexport(); + } else { + var.export(); + } + Ok(()) + }, + EnvironmentLookup::Anywhere, + EnvironmentScope::Global, + )?; + } + } + + Ok(ExecutionResult::success()) + } +} + +fn display_all_exported_vars( + context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, +) -> Result<(), brush_core::Error> { + // Enumerate variables, sorted by key. + for (name, variable) in context.shell.env().iter().sorted_by_key(|v| v.0) { + if variable.is_exported() { + let value = variable.value().try_get_cow_str(context.shell); + if let Some(value) = value { + writeln!(context.stdout(), "declare -x {name}=\"{value}\"")?; + } else { + writeln!(context.stdout(), "declare -x {name}")?; + } + } + } + + Ok(()) +} diff --git a/local/recipes/shells/brush/source/brush-builtins/src/factory.rs b/local/recipes/shells/brush/source/brush-builtins/src/factory.rs new file mode 100644 index 0000000000..99eeb8e44e --- /dev/null +++ b/local/recipes/shells/brush/source/brush-builtins/src/factory.rs @@ -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( + set: BuiltinSet, +) -> HashMap> { + let mut m = HashMap::>::new(); + + // + // POSIX special builtins + // + // N.B. There seems to be some inconsistency as to whether 'times' + // should be a special built-in. + // + + #[cfg(feature = "builtin.break")] + m.insert( + "break".into(), + builtin::().special(), + ); + #[cfg(feature = "builtin.colon")] + m.insert( + ":".into(), + simple_builtin::().special(), + ); + #[cfg(feature = "builtin.continue")] + m.insert( + "continue".into(), + builtin::().special(), + ); + #[cfg(feature = "builtin.dot")] + m.insert(".".into(), builtin::().special()); + #[cfg(feature = "builtin.eval")] + m.insert("eval".into(), builtin::().special()); + #[cfg(all(feature = "builtin.exec", unix))] + m.insert("exec".into(), builtin::().special()); + #[cfg(feature = "builtin.exit")] + m.insert("exit".into(), builtin::().special()); + #[cfg(feature = "builtin.export")] + m.insert( + "export".into(), + decl_builtin::().special(), + ); + #[cfg(feature = "builtin.return")] + m.insert( + "return".into(), + builtin::().special(), + ); + #[cfg(feature = "builtin.set")] + m.insert("set".into(), builtin::().special()); + #[cfg(feature = "builtin.shift")] + m.insert( + "shift".into(), + builtin::().special(), + ); + #[cfg(feature = "builtin.trap")] + m.insert("trap".into(), builtin::().special()); + #[cfg(feature = "builtin.unset")] + m.insert( + "unset".into(), + builtin::().special(), + ); + + #[cfg(feature = "builtin.declare")] + m.insert( + "readonly".into(), + decl_builtin::().special(), + ); + #[cfg(feature = "builtin.times")] + m.insert( + "times".into(), + builtin::().special(), + ); + + // + // Non-special builtins + // + + #[cfg(feature = "builtin.alias")] + m.insert("alias".into(), builtin::()); // TODO(alias): should be exec_declaration_builtin + #[cfg(feature = "builtin.bg")] + m.insert("bg".into(), builtin::()); + #[cfg(feature = "builtin.cd")] + m.insert("cd".into(), builtin::()); + #[cfg(feature = "builtin.command")] + m.insert("command".into(), builtin::()); + #[cfg(feature = "builtin.false")] + m.insert("false".into(), simple_builtin::()); + #[cfg(feature = "builtin.fg")] + m.insert("fg".into(), builtin::()); + #[cfg(feature = "builtin.getopts")] + m.insert("getopts".into(), builtin::()); + #[cfg(feature = "builtin.hash")] + m.insert("hash".into(), builtin::()); + #[cfg(feature = "builtin.help")] + m.insert("help".into(), builtin::()); + #[cfg(feature = "builtin.jobs")] + m.insert("jobs".into(), builtin::()); + #[cfg(all(feature = "builtin.kill", unix))] + m.insert("kill".into(), builtin::()); + #[cfg(feature = "builtin.declare")] + m.insert( + "local".into(), + decl_builtin::(), + ); + #[cfg(feature = "builtin.pwd")] + m.insert("pwd".into(), builtin::()); + #[cfg(feature = "builtin.read")] + m.insert("read".into(), builtin::()); + #[cfg(feature = "builtin.true")] + m.insert("true".into(), simple_builtin::()); + #[cfg(feature = "builtin.type")] + m.insert("type".into(), builtin::()); + #[cfg(all(feature = "builtin.ulimit", unix))] + m.insert("ulimit".into(), builtin::()); + #[cfg(all(feature = "builtin.umask", unix))] + m.insert("umask".into(), builtin::()); + #[cfg(feature = "builtin.unalias")] + m.insert("unalias".into(), builtin::()); + #[cfg(feature = "builtin.wait")] + m.insert("wait".into(), builtin::()); + + #[cfg(feature = "builtin.fc")] + m.insert("fc".into(), builtin::()); + + if matches!(set, BuiltinSet::BashMode) { + #[cfg(feature = "builtin.builtin")] + m.insert( + "builtin".into(), + raw_arg_builtin::(), + ); + #[cfg(feature = "builtin.declare")] + m.insert( + "declare".into(), + decl_builtin::(), + ); + #[cfg(feature = "builtin.echo")] + m.insert("echo".into(), builtin::()); + #[cfg(feature = "builtin.enable")] + m.insert("enable".into(), builtin::()); + #[cfg(feature = "builtin.let")] + m.insert("let".into(), builtin::()); + #[cfg(feature = "builtin.mapfile")] + m.insert("mapfile".into(), builtin::()); + #[cfg(feature = "builtin.mapfile")] + m.insert("readarray".into(), builtin::()); + #[cfg(all(feature = "builtin.printf", any(unix, windows)))] + m.insert("printf".into(), builtin::()); + #[cfg(feature = "builtin.shopt")] + m.insert("shopt".into(), builtin::()); + #[cfg(feature = "builtin.dot")] + m.insert("source".into(), builtin::().special()); + #[cfg(all(feature = "builtin.suspend", unix))] + m.insert("suspend".into(), builtin::()); + #[cfg(feature = "builtin.test")] + m.insert("test".into(), builtin::()); + #[cfg(feature = "builtin.test")] + m.insert("[".into(), builtin::()); + #[cfg(feature = "builtin.declare")] + m.insert( + "typeset".into(), + decl_builtin::(), + ); + + // Completion builtins + #[cfg(feature = "builtin.complete")] + m.insert( + "complete".into(), + builtin::(), + ); + #[cfg(feature = "builtin.compgen")] + m.insert("compgen".into(), builtin::()); + #[cfg(feature = "builtin.compopt")] + m.insert("compopt".into(), builtin::()); + + // Dir stack builtins + #[cfg(feature = "builtin.dirs")] + m.insert("dirs".into(), builtin::()); + #[cfg(feature = "builtin.popd")] + m.insert("popd".into(), builtin::()); + #[cfg(feature = "builtin.pushd")] + m.insert("pushd".into(), builtin::()); + + // Input configuration builtins + #[cfg(feature = "builtin.bind")] + m.insert("bind".into(), builtin::()); + + // History + #[cfg(feature = "builtin.history")] + m.insert("history".into(), builtin::()); + + #[cfg(feature = "builtin.caller")] + m.insert("caller".into(), builtin::()); + + // TODO(disown): implement disown builtin + m.insert( + "disown".into(), + builtin::(), + ); + + // TODO(logout): implement logout builtin + m.insert( + "logout".into(), + builtin::(), + ); + } + + m +} diff --git a/local/recipes/shells/brush/source/brush-builtins/src/false_.rs b/local/recipes/shells/brush/source/brush-builtins/src/false_.rs new file mode 100644 index 0000000000..25fa678c7b --- /dev/null +++ b/local/recipes/shells/brush/source/brush-builtins/src/false_.rs @@ -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 { + 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, S: AsRef>( + _context: brush_core::ExecutionContext<'_, SE>, + _args: I, + ) -> Result { + Ok(ExecutionResult::general_error()) + } +} diff --git a/local/recipes/shells/brush/source/brush-builtins/src/fc.rs b/local/recipes/shells/brush/source/brush-builtins/src/fc.rs new file mode 100644 index 0000000000..9f4695efdd --- /dev/null +++ b/local/recipes/shells/brush/source/brush-builtins/src/fc.rs @@ -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, + + /// First command in range (number or string prefix). + #[arg(value_name = "FIRST", allow_hyphen_values = true)] + first: Option, + + /// Last command in range (number or string prefix). + #[arg(value_name = "LAST", allow_hyphen_values = true)] + last: Option, +} + +impl builtins::Command for FcCommand { + type Error = brush_core::Error; + + async fn execute( + &self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + if self.substitute { + return self.do_execute(context).await; + } + + if self.list { + return self.do_list(&context); + } + + error::unimp("fc editor mode is not yet implemented") + } +} + +impl FcCommand { + fn do_list( + &self, + context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, + ) -> Result { + let history = context + .shell + .history() + .ok_or_else(|| brush_core::Error::from(brush_core::ErrorKind::HistoryNotEnabled))?; + + let (first_idx, last_idx, reverse) = self.resolve_range(history)?; + + // Determine the order of iteration + let indices: Vec = if reverse { + (first_idx..=last_idx).rev().collect() + } else { + (first_idx..=last_idx).collect() + }; + + for idx in indices { + if let Some(item) = history.get(idx) { + if self.no_line_numbers { + // With -n, bash still outputs a tab before the command + writeln!(context.stdout(), "\t {}", item.command_line)?; + } else { + // Match bash's fc format: number, tab, command + writeln!(context.stdout(), "{}\t {}", idx + 1, item.command_line)?; + } + } + } + + Ok(ExecutionResult::success()) + } + + async fn do_execute( + &self, + context: brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, + ) -> Result { + let history = context + .shell + .history() + .ok_or_else(|| brush_core::Error::from(brush_core::ErrorKind::HistoryNotEnabled))?; + + // Parse the first argument for pattern=replacement + let (pattern, replacement) = self + .first + .as_ref() + .and_then(|s| s.split_once('=')) + .map_or((None, None), |(p, r)| (Some(p), Some(r))); + + // Determine which command to re-execute + let cmd_spec = if pattern.is_some() { + // If we have a pattern, the command spec is in 'last' if present + self.last.as_deref() + } else { + // Otherwise, it's in 'first' + self.first.as_deref() + }; + + // Find the command + let cmd_line = if let Some(spec) = cmd_spec { + Self::find_command_by_specifier(history, spec)? + } else { + // No spec means use the previous command (excluding the fc command itself) + let effective_count = effective_history_count(history); + history + .get(effective_count.saturating_sub(1)) + .map(|item| item.command_line.clone()) + .ok_or_else(|| brush_core::Error::from(error::ErrorKind::HistoryItemNotFound))? + }; + + // Apply substitution if present + let final_cmd = if let (Some(pat), Some(rep)) = (pattern, replacement) { + cmd_line.replace(pat, rep) + } else { + cmd_line + }; + + // Echo the command to stderr. + writeln!(context.stderr(), "{final_cmd}")?; + + // Remove the fc command from history before executing the substituted command + // This matches bash behavior where the fc command is replaced by the executed command + let history_mut = context + .shell + .history_mut() + .ok_or_else(|| brush_core::Error::from(brush_core::ErrorKind::HistoryNotEnabled))?; + history_mut.remove_nth_item(history_mut.count().saturating_sub(1)); + + 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 { + // Try to parse it as a number. If it's not parseable, then we need to assume + // it's a string prefix we need to search for. + let Ok(num) = spec.parse::() else { + // Not a number, treat as string prefix + return Self::find_command_by_prefix(history, spec); + }; + + let effective_count = effective_history_count(history); + + #[expect(clippy::cast_sign_loss)] + #[expect(clippy::cast_possible_truncation)] + let result = match num.cmp(&0) { + std::cmp::Ordering::Equal => { + // 0 means -1 for listing (relative to effective count) + effective_count.saturating_sub(1) + } + std::cmp::Ordering::Greater => { + // Positive: 1-based index + let idx = (num - 1) as usize; + if idx < effective_count { + idx + } else { + // Out of range - use 0 (first item) + 0 + } + } + std::cmp::Ordering::Less => { + // Negative: offset from end (relative to effective count) + let offset = (-num) as usize; + effective_count.saturating_sub(offset) + } + }; + + Ok(result) + } + + /// Finds the command matching the given specifier (number or string prefix). Returns + /// the command line. Returns an error if no such command can be found in the history. + /// + /// # Arguments + /// + /// * `history` - The history to search. + /// * `spec` - The position spec + fn find_command_by_specifier( + history: &history::History, + spec: &str, + ) -> Result { + let idx = Self::resolve_position(history, spec)?; + history + .get(idx) + .map(|item| item.command_line.clone()) + .ok_or_else(|| brush_core::Error::from(error::ErrorKind::HistoryItemNotFound)) + } + + /// Finds the most recent command starting with the given prefix. Returns + /// the index of the command in the history. Returns an error if no such + /// command can be found in the history. + /// + /// # Arguments + /// + /// * `history` - The history to search. + /// * `prefix` - The command prefix to search for. + fn find_command_by_prefix( + history: &history::History, + prefix: &str, + ) -> Result { + // Search backwards for a command starting with the prefix (excluding fc command itself) + let effective_count = effective_history_count(history); + + for idx in (0..effective_count).rev() { + if let Some(item) = history.get(idx) { + if item.command_line.starts_with(prefix) { + return Ok(idx); + } + } + } + + Err(brush_core::Error::from( + error::ErrorKind::HistoryItemNotFound, + )) + } +} + +/// Returns the effective history count (excluding the fc command itself). +fn effective_history_count(history: &history::History) -> usize { + history.count().saturating_sub(1) +} diff --git a/local/recipes/shells/brush/source/brush-builtins/src/fg.rs b/local/recipes/shells/brush/source/brush-builtins/src/fg.rs new file mode 100644 index 0000000000..ec5796fe3e --- /dev/null +++ b/local/recipes/shells/brush/source/brush-builtins/src/fg.rs @@ -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, +} + +impl builtins::Command for FgCommand { + type Error = brush_core::Error; + + async fn execute( + &self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + 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()) + } + } + } +} diff --git a/local/recipes/shells/brush/source/brush-builtins/src/getopts.rs b/local/recipes/shells/brush/source/brush-builtins/src/getopts.rs new file mode 100644 index 0000000000..e57247fa52 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-builtins/src/getopts.rs @@ -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, +} + +// 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, + /// 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, + /// 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::::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(args: I) -> Result + where + I: IntoIterator, + { + let (mut this, rest_args) = brush_core::builtins::try_parse_known::(args)?; + if let Some(args) = rest_args { + this.args.extend(args); + } + Ok(this) + } + + async fn execute( + &self, + mut context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + // 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::(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::().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( + context: &mut brush_core::ExecutionContext<'_, SE>, + spec: &OptionSpec, + args_to_parse: &[String], + mut next_index: usize, +) -> Result { + // 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( + 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, 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( + context: &brush_core::ExecutionContext<'_, SE>, + spec: &OptionSpec, + c: char, +) -> Result<(String, Option), 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( + context: &mut brush_core::ExecutionContext<'_, SE>, + variable_name: &str, + result: GetOptsResult, +) -> Result { + // 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( + context: &brush_core::ExecutionContext<'_, SE>, +) -> bool { + context + .shell + .env_str("OPTERR") + .is_none_or(|s| s.parse::().unwrap_or(1) != 0) +} diff --git a/local/recipes/shells/brush/source/brush-builtins/src/hash.rs b/local/recipes/shells/brush/source/brush-builtins/src/hash.rs new file mode 100644 index 0000000000..b8dda4289c --- /dev/null +++ b/local/recipes/shells/brush/source/brush-builtins/src/hash.rs @@ -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, + + /// Remove all entries. + #[arg(short = 'r')] + remove_all: bool, + + /// Display the paths associated with the names. + #[arg(short = 't')] + display_paths: bool, + + /// Names to process. + names: Vec, +} + +impl builtins::Command for HashCommand { + type Error = brush_core::Error; + + async fn execute( + &self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + 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) + } +} diff --git a/local/recipes/shells/brush/source/brush-builtins/src/help.rs b/local/recipes/shells/brush/source/brush-builtins/src/help.rs new file mode 100644 index 0000000000..bde2bdaed7 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-builtins/src/help.rs @@ -0,0 +1,160 @@ +use brush_core::{ExecutionResult, builtins}; +use clap::Parser; +use itertools::Itertools; +use std::io::Write; + +/// Display command help. +#[derive(Parser)] +pub(crate) struct HelpCommand { + /// Display a short description for the commands. + #[arg(short = 'd')] + short_description: bool, + + /// Display a man-style page of documentation for the commands. + #[arg(short = 'm')] + man_page_style: bool, + + /// Display a short usage summary for the commands. + #[arg(short = 's')] + short_usage: bool, + + /// Patterns of topics to display help for. + topic_patterns: Vec, +} + +impl builtins::Command for HelpCommand { + type Error = brush_core::Error; + + async fn execute( + &self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + if self.topic_patterns.is_empty() { + Self::display_general_help(&context)?; + return Ok(ExecutionResult::success()); + } + + // Match bash: succeed if at least one requested topic pattern matched + // something; return a non-zero exit code only when none of them matched. + let mut any_matched = false; + for topic_pattern in &self.topic_patterns { + if self.display_help_for_topic_pattern(&context, topic_pattern)? { + any_matched = true; + } + } + + if any_matched { + Ok(ExecutionResult::success()) + } else { + Ok(ExecutionResult::general_error()) + } + } +} + +impl HelpCommand { + fn display_general_help( + context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, + ) -> Result<(), brush_core::Error> { + const COLUMN_COUNT: usize = 3; + + if let Some(display_str) = context.shell.product_display_str() { + writeln!(context.stdout(), "{display_str}\n")?; + } + + writeln!( + context.stdout(), + "The following commands are implemented as shell built-ins:" + )?; + + let builtins = get_builtins_sorted_by_name(context); + let items_per_column = builtins.len().div_ceil(COLUMN_COUNT); + + for i in 0..items_per_column { + for j in 0..COLUMN_COUNT { + if let Some((name, builtin)) = builtins.get(i + j * items_per_column) { + let prefix = if builtin.disabled { "*" } else { " " }; + write!(context.stdout(), " {prefix}{name:<20}")?; // adjust 20 to the desired + // column width + } + } + writeln!(context.stdout())?; + } + + Ok(()) + } + + /// Displays help for the builtins matching `topic_pattern`, returning whether + /// at least one topic matched. + fn display_help_for_topic_pattern( + &self, + context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, + topic_pattern: &str, + ) -> Result { + let pattern = brush_core::patterns::Pattern::from(topic_pattern) + .set_extended_globbing(context.shell.options().extended_globbing) + .set_case_insensitive(context.shell.options().case_insensitive_pathname_expansion); + + let mut matched = false; + for (builtin_name, builtin_registration) in get_builtins_sorted_by_name(context) { + if pattern.exactly_matches(builtin_name.as_str())? { + self.display_help_for_builtin( + context, + builtin_name.as_str(), + builtin_registration, + )?; + matched = true; + } + } + + if !matched { + writeln!(context.stderr(), "No help topics match '{topic_pattern}'")?; + } + + Ok(matched) + } + + fn display_help_for_builtin( + &self, + context: &brush_core::ExecutionContext<'_, SE>, + name: &str, + registration: &builtins::Registration, + ) -> Result<(), brush_core::Error> { + let content_type = if self.short_description { + builtins::ContentType::ShortDescription + } else if self.man_page_style { + builtins::ContentType::ManPage + } else if self.short_usage { + builtins::ContentType::ShortUsage + } else { + builtins::ContentType::DetailedHelp + }; + + let 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)> { + context + .shell + .builtins() + .iter() + .sorted_by_key(|(name, _)| *name) + .collect() +} diff --git a/local/recipes/shells/brush/source/brush-builtins/src/history.rs b/local/recipes/shells/brush/source/brush-builtins/src/history.rs new file mode 100644 index 0000000000..8d703ae696 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-builtins/src/history.rs @@ -0,0 +1,246 @@ +use brush_core::{ExecutionExitCode, ExecutionResult, builtins, error, history}; +use clap::Parser; +use std::{ + io::Write, + path::{Path, PathBuf}, +}; + +/// Query or manipulate the shell's command history. +// TODO(history): Evaluate which of the options conflict with each other. +#[derive(Parser)] +#[expect(clippy::option_option)] +pub(crate) struct HistoryCommand { + /// Clears all history. + #[arg(short = 'c')] + clear_history: bool, + + /// Deletes the history entry at the given offset. Positive offsets are relative to the + /// beginning of the history, while negative offsets are relative to the end of the history. + #[arg(short = 'd', value_name = "OFFSET")] + delete_offset: Option, + + /// 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>, + + /// 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>, + + /// 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>, + + /// 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>, + + /// History-expands positional arguments and displays them. + #[arg(short = 'p', num_args = 0.., value_name = "ARG")] + expand_args: Option>, + + /// Appends positional arguments as an entry in the current session. + #[arg(short = 's', num_args = 0.., value_name = "ARG")] + append_args_to_session: Option>, + + /// Arguments. + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, +} + +struct HistoryConfig { + default_history_file_path: Option, + time_format: Option, +} + +impl builtins::Command for HistoryCommand { + type Error = brush_core::Error; + + async fn execute( + &self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + // 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 { + if self.clear_history { + history.clear()?; + } + + if let Some(offset) = self.delete_offset { + if offset == 0 { + writeln!(stderr, "cannot delete history item at offset 0")?; + return Ok(ExecutionExitCode::InvalidUsage.into()); + } + + if offset > 0 { + // Convert to 0-based index. + let index = (offset - 1) as usize; + if !history.remove_nth_item(index) { + writeln!(stderr, "index past end of history")?; + return Ok(ExecutionExitCode::InvalidUsage.into()); + } + } else { + let count = history.count() as i64; + let index = count + offset; + if index < 0 { + writeln!(stderr, "index before beginning of history")?; + return Ok(ExecutionExitCode::InvalidUsage.into()); + } + + let _ = history.remove_nth_item(index as usize); + } + + return Ok(ExecutionResult::success()); + } + + if let Some(append_option) = &self.append_session_to_file { + if let Some(file_path) = get_effective_history_file_path( + config.default_history_file_path.as_deref(), + append_option.as_deref(), + ) { + history.flush( + file_path, + true, /* append? */ + true, /* unsaved items only */ + config.time_format.is_some(), /* write timestamps? */ + )?; + } + + return Ok(ExecutionResult::success()); + } + + if self.append_rest_of_file_to_session.is_some() { + return error::unimp("history -n is not yet implemented"); + } + + if self.append_file_to_session.is_some() { + return error::unimp("history -r is not yet implemented"); + } + + if let Some(write_option) = &self.write_session_to_file { + if let Some(file_path) = get_effective_history_file_path( + config.default_history_file_path.as_deref(), + write_option.as_deref(), + ) { + history.flush( + file_path, + false, /* append? */ + false, /* unsaved items only? */ + config.time_format.is_some(), /* write timestamps? */ + )?; + } + + return Ok(ExecutionResult::success()); + } + + if self.expand_args.is_some() { + return error::unimp("history -p is not yet implemented"); + } + + if let Some(args) = &self.append_args_to_session { + history.add(history::Item::new(args.join(" ")))?; + return Ok(ExecutionResult::success()); + } + + let max_entries: Option = 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, + mut stdout: impl Write, + _stderr: impl Write, +) -> Result<(), brush_core::Error> { + let item_count = history.count(); + let skip_count = item_count - max_entries.unwrap_or(item_count); + + for (i, item) in history.iter().skip(skip_count).enumerate() { + let mut formatted_timestamp = String::new(); + + if let Some(timestamp) = item.timestamp { + let local_timestamp = timestamp.with_timezone(&chrono::Local); + if let Some(time_format) = &config.time_format { + let fmt_items = chrono::format::StrftimeItems::new(time_format); + formatted_timestamp = local_timestamp.format_with_items(fmt_items).to_string(); + } + } + + // Output format is something like: + // 1 echo hello world + std::writeln!( + stdout, + "{:>5} {formatted_timestamp}{}", + skip_count + i + 1, + item.command_line + )?; + } + + Ok(()) +} + +fn get_effective_history_file_path<'a>( + default_history_file_path: Option<&'a Path>, + option: Option<&'a str>, +) -> Option<&'a Path> { + option.map(Path::new).or(default_history_file_path) +} + +#[cfg(test)] +mod tests { + use super::*; + use anyhow::Result; + use pretty_assertions::{assert_eq, assert_matches}; + + #[test] + fn test_parse_dash_a() -> Result<()> { + let cmd = HistoryCommand::try_parse_from(["history", "5"])?; + assert_matches!(cmd.append_session_to_file, None); + + let cmd = HistoryCommand::try_parse_from(["history", "-a"])?; + assert_matches!(cmd.append_session_to_file, Some(None)); + + let cmd = HistoryCommand::try_parse_from(["history", "-a", "token"])?; + assert_eq!( + cmd.append_session_to_file, + Some(Some(String::from("token"))) + ); + + Ok(()) + } +} diff --git a/local/recipes/shells/brush/source/brush-builtins/src/jobs.rs b/local/recipes/shells/brush/source/brush-builtins/src/jobs.rs new file mode 100644 index 0000000000..c3686a96a3 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-builtins/src/jobs.rs @@ -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, +} + +impl builtins::Command for JobsCommand { + type Error = brush_core::Error; + + async fn execute( + &self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + 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(()) + } +} diff --git a/local/recipes/shells/brush/source/brush-builtins/src/kill.rs b/local/recipes/shells/brush/source/brush-builtins/src/kill.rs new file mode 100644 index 0000000000..f69b9fa824 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-builtins/src/kill.rs @@ -0,0 +1,179 @@ +use clap::Parser; +use std::io::Write; + +use brush_core::traps::TrapSignal; +use brush_core::{ExecutionExitCode, ExecutionResult, builtins, sys}; + +/// Signal a job or process. +#[derive(Parser)] +pub(crate) struct KillCommand { + /// Name of the signal to send. + #[arg(short = 's', value_name = "SIG_NAME")] + signal_name: Option, + + /// Number of the signal to send. + #[arg(short = 'n', value_name = "SIG_NUM")] + signal_number: Option, + + // + // 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, +} + +impl builtins::Command for KillCommand { + type Error = brush_core::Error; + + async fn execute( + &self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + // Default signal is SIGKILL. + let mut trap_signal = TrapSignal::Signal(nix::sys::signal::Signal::SIGKILL); + + // Try parsing the signal name (if specified). + if let Some(signal_name) = &self.signal_name { + if let Ok(parsed_trap_signal) = TrapSignal::try_from(signal_name.as_str()) { + trap_signal = parsed_trap_signal; + } else { + writeln!( + context.stderr(), + "{}: invalid signal name: {}", + context.command_name, + signal_name + )?; + return Ok(ExecutionExitCode::InvalidUsage.into()); + } + } + + // Try parsing the signal number (if specified). + if let Some(signal_number) = &self.signal_number { + #[expect(clippy::cast_possible_truncation)] + #[expect(clippy::cast_possible_wrap)] + if let Ok(parsed_trap_signal) = TrapSignal::try_from(*signal_number as i32) { + trap_signal = parsed_trap_signal; + } else { + writeln!( + context.stderr(), + "{}: invalid signal number: {}", + context.command_name, + signal_number + )?; + return Ok(ExecutionExitCode::InvalidUsage.into()); + } + } + + // Look through the remaining args for a pid/job spec or a -sigspec style option. + let mut pid_or_job_spec = None; + for arg in &self.args { + if let Some(possible_sigspec) = arg.strip_prefix("-") { + // See if this is -sigspec syntax. The sigspec may be a signal name + // (e.g., -TERM) or a signal number (e.g., -9). + if let Ok(parsed_trap_signal) = possible_sigspec.parse::() { + trap_signal = parsed_trap_signal; + } else { + writeln!( + context.stderr(), + "{}: {}: invalid signal specification", + context.command_name, + possible_sigspec + )?; + return Ok(ExecutionResult::general_error()); + } + } else if pid_or_job_spec.is_none() { + pid_or_job_spec = Some(arg); + } else { + writeln!( + context.stderr(), + "{}: too many jobs or processes specified", + context.command_name + )?; + return Ok(ExecutionExitCode::InvalidUsage.into()); + } + } + + if self.list_signals { + return print_signals(&context, self.args.as_ref()); + } else { + let Some(pid_or_job_spec) = pid_or_job_spec else { + writeln!(context.stderr(), "{}: invalid usage", context.command_name)?; + return Ok(ExecutionExitCode::InvalidUsage.into()); + }; + + if pid_or_job_spec.starts_with('%') { + // It's a job spec. + if let Some(job) = context.shell.jobs_mut().resolve_job_spec(pid_or_job_spec) { + job.kill(trap_signal)?; + } else { + writeln!( + context.stderr(), + "{}: {}: no such job", + context.command_name, + pid_or_job_spec + )?; + return Ok(ExecutionResult::general_error()); + } + } else { + let pid = brush_core::int_utils::parse(pid_or_job_spec.as_str(), 10)?; + + // It's a pid. + sys::signal::kill_process(pid, trap_signal)?; + } + } + Ok(ExecutionResult::success()) + } +} + +fn print_signals( + context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, + signals: &[String], +) -> Result { + 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::() { + // 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) +} diff --git a/local/recipes/shells/brush/source/brush-builtins/src/let_.rs b/local/recipes/shells/brush/source/brush-builtins/src/let_.rs new file mode 100644 index 0000000000..b87a705ecf --- /dev/null +++ b/local/recipes/shells/brush/source/brush-builtins/src/let_.rs @@ -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, +} + +impl builtins::Command for LetCommand { + type Error = brush_core::Error; + + async fn execute( + &self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + 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) + } +} diff --git a/local/recipes/shells/brush/source/brush-builtins/src/lib.rs b/local/recipes/shells/brush/source/brush-builtins/src/lib.rs new file mode 100644 index 0000000000..fa3d79a49b --- /dev/null +++ b/local/recipes/shells/brush/source/brush-builtins/src/lib.rs @@ -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 { + 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 { + match (self._enable, self._disable) { + (true, false) => Some(true), + (false, true) => Some(false), + _ => None, + } + } + } + }; +} diff --git a/local/recipes/shells/brush/source/brush-builtins/src/mapfile.rs b/local/recipes/shells/brush/source/brush-builtins/src/mapfile.rs new file mode 100644 index 0000000000..4d72258649 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-builtins/src/mapfile.rs @@ -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, + + /// 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, + + /// 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, + + /// 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( + &self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + 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 { + 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, 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) +} diff --git a/local/recipes/shells/brush/source/brush-builtins/src/popd.rs b/local/recipes/shells/brush/source/brush-builtins/src/popd.rs new file mode 100644 index 0000000000..3ed150bdf1 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-builtins/src/popd.rs @@ -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( + &self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + 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) + } + } +} diff --git a/local/recipes/shells/brush/source/brush-builtins/src/printf.rs b/local/recipes/shells/brush/source/brush-builtins/src/printf.rs new file mode 100644 index 0000000000..f5a527bb36 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-builtins/src/printf.rs @@ -0,0 +1,206 @@ +use clap::Parser; +use std::{ffi::OsString, io::Write, ops::ControlFlow}; +use uucore::format; + +use brush_core::{Error, ErrorKind, ExecutionResult, builtins, escape, expansion}; + +/// Format a string. +#[derive(Parser)] +#[clap(disable_help_flag = true, disable_version_flag = true)] +pub(crate) struct PrintfCommand { + /// If specified, the output of the command is assigned to this variable. + #[arg(short = 'v')] + output_variable: Option, + + /// Format string + arguments to the format string. + /// + /// N.B. We intentionally do *not* enable `allow_hyphen_values` here. Doing so would + /// cause an attached short-option value such as `-va` (i.e. `-v a`) to be misparsed as + /// a positional argument. With it disabled, a format string that genuinely needs to + /// start with a hyphen must be preceded by `--`, matching other shells' behavior. + #[arg(trailing_var_arg = true, required = true)] + format_and_args: Vec, +} + +impl builtins::Command for PrintfCommand { + type Error = brush_core::Error; + + async fn execute( + &self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + if let Some(variable_name) = &self.output_variable { + // Format to a u8 vector. + let mut result: Vec = 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>, + mut writer: impl Write, +) -> Result<(), brush_core::Error> { + // Convert string arguments to FormatArgument::Unparsed + let format_args: Vec<_> = args + .map(|s| format::FormatArgument::Unparsed(s.into())) + .collect(); + + // Parse format string once. + let format_items = parse_format_string(format_string)?; + + // Wrap the format arguments. + let mut format_args_wrapper = format::FormatArguments::new(&format_args); + + // Determine whether the format string contains any specifiers that consume arguments. If it + // doesn't, then we must only run through it once -- even when extra arguments are provided -- + // since otherwise we'd loop forever waiting for arguments that will never be consumed. This + // matches the behavior of other shells, which print such a format string exactly once. + let format_consumes_args = format_items + .iter() + .any(|item| matches!(item, format::FormatItem::Spec(_))); + + // Keep going until we've exhausted all format arguments. Also make sure to run at least once + // even if there's no format arguments. + while format_args.is_empty() || !format_args_wrapper.is_exhausted() { + // Process all format items, in order. We'll bail when we're told to stop. + for item in &format_items { + let control_flow = item + .write(&mut writer, &mut format_args_wrapper) + .map_err(|e| match e { + // Propagate I/O errors directly so they can be handled appropriately + format::FormatError::IoError(io_err) => Error::from(io_err), + // Wrap other format errors + other => Error::from(ErrorKind::PrintfInvalidUsage(std::format!( + "printf formatting error: {other}" + ))), + })?; + + if control_flow == ControlFlow::Break(()) { + break; + } + } + + // If the format string doesn't consume any arguments, stop now; otherwise we'd reprocess + // it forever since no arguments will ever be consumed. + if !format_consumes_args { + break; + } + + // Start next batch if not exhausted + if !format_args_wrapper.is_exhausted() { + format_args_wrapper.start_next_batch(); + } + + if format_args.is_empty() { + break; + } + } + + Ok(()) +} + +fn parse_format_string( + format_string: &str, +) -> Result>, brush_core::Error> { + let format_items: Result, _> = + 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>, + ) -> Result { + 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(()) + } +} diff --git a/local/recipes/shells/brush/source/brush-builtins/src/pushd.rs b/local/recipes/shells/brush/source/brush-builtins/src/pushd.rs new file mode 100644 index 0000000000..279bdc3cc1 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-builtins/src/pushd.rs @@ -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( + &self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + 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()) + } +} diff --git a/local/recipes/shells/brush/source/brush-builtins/src/pwd.rs b/local/recipes/shells/brush/source/brush-builtins/src/pwd.rs new file mode 100644 index 0000000000..09f9cadc5a --- /dev/null +++ b/local/recipes/shells/brush/source/brush-builtins/src/pwd.rs @@ -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( + &self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + 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()) + } +} diff --git a/local/recipes/shells/brush/source/brush-builtins/src/read.rs b/local/recipes/shells/brush/source/brush-builtins/src/read.rs new file mode 100644 index 0000000000..56809c60d8 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-builtins/src/read.rs @@ -0,0 +1,838 @@ +use clap::Parser; +use itertools::Itertools; +use std::collections::VecDeque; +use std::time::{Duration, Instant}; + +use brush_core::{ErrorKind, builtins, env, error, variables}; + +use std::io::{Read, Write}; + +/// Exit code returned when `read` times out. +/// This is 128 + SIGALRM (14) = 142, matching bash behavior. +const TIMEOUT_EXIT_CODE: u8 = 142; + +/// ASCII control character for Ctrl+C (ETX - End of Text). +const CTRL_C: char = '\x03'; +/// ASCII control character for Ctrl+D (EOT - End of Transmission). +const CTRL_D: char = '\x04'; +/// Backslash character used for escape processing. +const BACKSLASH: char = '\\'; +/// Default line delimiter (newline). +const DEFAULT_DELIMITER: char = '\n'; +/// NUL character used as delimiter when `-d ''` is specified. +const NUL_DELIMITER: char = '\0'; + +/// Parse standard input. +#[derive(Parser)] +pub(crate) struct ReadCommand { + /// Optionally, name of an array variable to receive read words + /// of input. + #[clap(short = 'a', value_name = "VAR_NAME")] + array_variable: Option, + + /// Optionally, a delimiter to use other than a newline character. + #[clap(short = 'd')] + delimiter: Option, + + /// 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, + + /// 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, + + /// Read exactly N characters, ignoring any specified delimiter. + #[clap(short = 'N', value_name = "COUNT")] + return_after_n_chars_no_delimiter: Option, + + /// Prompt to display before reading. + #[clap(short = 'p')] + prompt: Option, + + /// 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, + + /// File descriptor to read from instead of stdin. + #[clap(short = 'u', name = "FD")] + fd_num_to_read: Option, + + /// Optionally, names of variables to receive read input. + variable_names: Vec, +} + +impl builtins::Command for ReadCommand { + type Error = brush_core::Error; + + async fn execute( + &self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + 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, + 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, + 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)> { + 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 { + match input_line { + Some(line) if skip_ifs_splitting => { + // With -N, don't split - put entire input in first variable. + VecDeque::from([line.to_string()]) + } + Some(line) => split_line_by_ifs(ifs, line, Some(num_variables)), + None => VecDeque::new(), + } +} + +/// Result of a `read` operation. +/// +/// This enum clearly represents all possible outcomes of `read_line()`, +/// making the contract with callers explicit. +enum ReadResult { + /// Successfully read a complete line (delimiter or char limit reached). + Line(String), + /// Reached end of input. Contains any partial content read before EOF. + Eof(Option), + /// 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), + /// 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, + /// 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, +} + +/// 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, + term_mode: Option, + ) -> 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 { + // 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, + /// Maximum characters to read (for -n or -N). + char_limit: Option, + /// 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 { + 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, + ) -> Result { + 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, 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, 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) -> VecDeque { + let ifs_chars: Vec = 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::::new()); + } + + #[test] + fn test_split_line_by_ifs_whitespace_only() { + let result = split_line_by_ifs(" ", " ", None); + assert_equal(result, VecDeque::::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()); + } +} diff --git a/local/recipes/shells/brush/source/brush-builtins/src/return_.rs b/local/recipes/shells/brush/source/brush-builtins/src/return_.rs new file mode 100644 index 0000000000..f6040a56d1 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-builtins/src/return_.rs @@ -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, +} + +impl builtins::Command for ReturnCommand { + type Error = brush_core::Error; + + async fn execute( + &self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + #[expect(clippy::cast_sign_loss)] + let code_8bit = if let Some(code_32bit) = &self.code { + (code_32bit & 0xFF) as u8 + } else { + context.shell.last_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()) + } + } +} diff --git a/local/recipes/shells/brush/source/brush-builtins/src/set.rs b/local/recipes/shells/brush/source/brush-builtins/src/set.rs new file mode 100644 index 0000000000..95cf752eef --- /dev/null +++ b/local/recipes/shells/brush/source/brush-builtins/src/set.rs @@ -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>, + #[arg(long = concat!("+o"), name = "setopt_disable", hide = true, num_args=0..=1)] + disable: Option>, +} + +/// 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, + + #[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, +} + +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(args: I) -> Result + where + I: IntoIterator, + { + // + // 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::(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( + &self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + 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 = 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(()) +} diff --git a/local/recipes/shells/brush/source/brush-builtins/src/shift.rs b/local/recipes/shells/brush/source/brush-builtins/src/shift.rs new file mode 100644 index 0000000000..77dd087492 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-builtins/src/shift.rs @@ -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, +} + +impl builtins::Command for ShiftCommand { + type Error = brush_core::Error; + + async fn execute( + &self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + 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()) + } +} diff --git a/local/recipes/shells/brush/source/brush-builtins/src/shopt.rs b/local/recipes/shells/brush/source/brush-builtins/src/shopt.rs new file mode 100644 index 0000000000..8069e3c6ed --- /dev/null +++ b/local/recipes/shells/brush/source/brush-builtins/src/shopt.rs @@ -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, +} + +impl builtins::Command for ShoptCommand { + type Error = brush_core::Error; + + #[allow(clippy::too_many_lines)] + async fn execute( + &self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + 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) + } + } +} diff --git a/local/recipes/shells/brush/source/brush-builtins/src/suspend.rs b/local/recipes/shells/brush/source/brush-builtins/src/suspend.rs new file mode 100644 index 0000000000..5134085990 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-builtins/src/suspend.rs @@ -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( + &self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + 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()) + } +} diff --git a/local/recipes/shells/brush/source/brush-builtins/src/test.rs b/local/recipes/shells/brush/source/brush-builtins/src/test.rs new file mode 100644 index 0000000000..e9846b24fb --- /dev/null +++ b/local/recipes/shells/brush/source/brush-builtins/src/test.rs @@ -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, +} + +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(args: I) -> Result + where + I: IntoIterator, + { + let (mut this, rest_args) = brush_core::builtins::try_parse_known::(args)?; + if let Some(args) = rest_args { + this.args.extend(args); + } + Ok(this) + } + + async fn execute( + &self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + 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, + params: &ExecutionParameters, + args: &[String], +) -> Result { + let test_command = + brush_parser::test_command::parse(args).map_err(ErrorKind::TestCommandParseError)?; + tests::eval_expr(&test_command, shell, params) +} diff --git a/local/recipes/shells/brush/source/brush-builtins/src/times.rs b/local/recipes/shells/brush/source/brush-builtins/src/times.rs new file mode 100644 index 0000000000..f6e39648e2 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-builtins/src/times.rs @@ -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( + &self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + 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()) + } +} diff --git a/local/recipes/shells/brush/source/brush-builtins/src/trap.rs b/local/recipes/shells/brush/source/brush-builtins/src/trap.rs new file mode 100644 index 0000000000..1b9acf0e09 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-builtins/src/trap.rs @@ -0,0 +1,118 @@ +use clap::Parser; +use std::io::Write; + +use brush_core::traps::TrapSignal; +use brush_core::{ExecutionResult, builtins}; + +/// Manage signal traps. +#[derive(Parser)] +pub(crate) struct TrapCommand { + /// List all signal names. + #[arg(short = 'l')] + list_signals: bool, + + /// Print registered trap commands. + #[arg(short = 'p')] + print_trap_commands: bool, + + args: Vec, +} + +impl builtins::Command for TrapCommand { + type Error = brush_core::Error; + + async fn execute( + &self, + mut context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + if self.list_signals { + brush_core::traps::format_signals(context.stdout(), TrapSignal::iterator()) + .map(|()| ExecutionResult::success()) + } else if self.print_trap_commands || self.args.is_empty() { + if !self.args.is_empty() { + for signal_type in &self.args { + Self::display_handlers_for(&context, signal_type.parse()?)?; + } + } else { + Self::display_all_handlers(&context)?; + } + Ok(ExecutionResult::success()) + } else if self.args.len() == 1 { + // When only a single argument is given, it is assumed to be a signal name + // and an indication to remove the handlers for that signal. + let signal = self.args[0].as_str(); + Self::remove_all_handlers(&mut context, signal.parse()?); + Ok(ExecutionResult::success()) + } else if self.args[0] == "-" { + // "-" as the first argument indicates that the remaining + // arguments are signal names and we need to remove the handlers for them. + for signal in &self.args[1..] { + Self::remove_all_handlers(&mut context, signal.parse()?); + } + Ok(ExecutionResult::success()) + } else { + let handler = &self.args[0]; + + let mut signal_types = Vec::with_capacity(self.args.len() - 1); + for signal in &self.args[1..] { + signal_types.push(signal.parse()?); + } + + Self::register_handler(&mut context, signal_types, handler.as_str()); + Ok(ExecutionResult::success()) + } + } +} + +impl TrapCommand { + fn display_all_handlers( + context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, + ) -> Result<(), brush_core::Error> { + for (signal, _) in context.shell.traps().iter_handlers() { + Self::display_handlers_for(context, signal)?; + } + Ok(()) + } + + fn display_handlers_for( + context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, + signal_type: TrapSignal, + ) -> Result<(), brush_core::Error> { + if let Some(handler) = context.shell.traps().get_handler(signal_type) { + writeln!( + context.stdout(), + "trap -- '{}' {signal_type}", + handler.command + )?; + } + Ok(()) + } + + fn remove_all_handlers( + context: &mut brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, + signal: TrapSignal, + ) { + context.shell.traps_mut().remove_handlers(signal); + } + + fn register_handler( + context: &mut brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, + signals: I, + handler: &str, + ) where + I: IntoIterator, + { + // 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(), + ); + } + } +} diff --git a/local/recipes/shells/brush/source/brush-builtins/src/true_.rs b/local/recipes/shells/brush/source/brush-builtins/src/true_.rs new file mode 100644 index 0000000000..b7592551a6 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-builtins/src/true_.rs @@ -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 { + 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, S: AsRef>( + _context: brush_core::ExecutionContext<'_, SE>, + _args: I, + ) -> Result { + Ok(ExecutionResult::success()) + } +} diff --git a/local/recipes/shells/brush/source/brush-builtins/src/type_.rs b/local/recipes/shells/brush/source/brush-builtins/src/type_.rs new file mode 100644 index 0000000000..bcf9840a2b --- /dev/null +++ b/local/recipes/shells/brush/source/brush-builtins/src/type_.rs @@ -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, +} + +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( + &self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + 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, + name: &str, + ) -> Vec> { + 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 + } +} diff --git a/local/recipes/shells/brush/source/brush-builtins/src/ulimit.rs b/local/recipes/shells/brush/source/brush-builtins/src/ulimit.rs new file mode 100644 index 0000000000..7dd5f4ff28 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-builtins/src/ulimit.rs @@ -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 { + 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 for ResourceDescription { + fn into_resettable(self) -> clap::builder::Resettable { + 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 = ::Err; + fn from_str(s: &str) -> Result { + 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, + /// the maximum size of core files created + #[arg(short = 'c', default_missing_value = "", num_args(0..=1), help = ResourceDescription::CORE)] + core: Option, + /// the maximum size of a process's data segment + #[arg(short = 'd', default_missing_value = "", num_args(0..=1), help = ResourceDescription::DATA)] + data: Option, + /// the maximum scheduling priority (`nice`) + #[arg(short = 'e', default_missing_value = "", num_args(0..=1), help = ResourceDescription::NICE)] + nice: Option, + /// 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, + /// the maximum number of pending signals + #[arg(short = 'i', default_missing_value = "", num_args(0..=1), help = ResourceDescription::SIGPENDING)] + sigpending: Option, + /// the maximum size a process may lock into memory + #[arg(short = 'l', default_missing_value = "", num_args(0..=1), help = ResourceDescription::MEMLOCK)] + memlock: Option, + /// the maximum number of kqueues allocated for this process + #[arg(short = 'k', default_missing_value = "", num_args(0..=1), help = ResourceDescription::KQUEUES)] + kqueues: Option, + /// the maximum resident set size + #[arg(short = 'm', default_missing_value = "", num_args(0..=1), help = ResourceDescription::RSS)] + rss: Option, + /// the maximum number of open file descriptors + #[arg(short = 'n', default_missing_value = "", num_args(0..=1), help = ResourceDescription::NOFILE)] + file_open: Option, + /// the pipe buffer size + #[arg(short = 'p', default_missing_value = "", num_args(0..=1), help = ResourceDescription::PIPE)] + pipe: Option, + /// the maximum number of bytes in POSIX message queues + #[arg(short = 'q', default_missing_value = "", num_args(0..=1), help = ResourceDescription::MSGQUEUE)] + msgqueue: Option, + /// the maximum real-time scheduling priority + #[arg(short = 'r', default_missing_value = "", num_args(0..=1), help = ResourceDescription::RTPRIO)] + rtprio: Option, + /// the maximum stack size + #[arg(short = 's', default_missing_value = "", num_args(0..=1), help = ResourceDescription::STACK)] + stack: Option, + /// the maximum amount of cpu time in seconds + #[arg(short = 't', default_missing_value = "", num_args(0..=1), help = ResourceDescription::CPU)] + cpu: Option, + /// the size of virtual memory + #[arg(short = 'u', default_missing_value = "", num_args(0..=1), help = ResourceDescription::NPROC)] + nproc: Option, + /// the size of virtual memory + #[arg(short = 'v', default_missing_value = "", num_args(0..=1), help = ResourceDescription::VMEM)] + vmem: Option, + /// the maximum number of file locks + #[arg(short = 'x', default_missing_value = "", num_args(0..=1), help = ResourceDescription::LOCKS)] + file_lock: Option, + /// the maximum number of pseudoterminals + #[arg(short = 'P', default_missing_value = "", num_args(0..=1), help = ResourceDescription::NPTS)] + npts: Option, + /// real-time non-blocking time + #[arg(short = 'R', default_missing_value = "", num_args(0..=1), help = ResourceDescription::RTTIME)] + rttime: Option, + /// the maximum number of threads + #[arg(short = 'T', default_missing_value = "", num_args(0..=1), help = ResourceDescription::THREADS)] + threads: Option, + + /// argument for the implicit limit (`-f`) + limit: Option, +} + +impl builtins::Command for ULimitCommand { + type Error = brush_core::Error; + + async fn execute( + &self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + 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) + } +} diff --git a/local/recipes/shells/brush/source/brush-builtins/src/umask.rs b/local/recipes/shells/brush/source/brush-builtins/src/umask.rs new file mode 100644 index 0000000000..7f69f0bffd --- /dev/null +++ b/local/recipes/shells/brush/source/brush-builtins/src/umask.rs @@ -0,0 +1,100 @@ +use brush_core::{ErrorKind, ExecutionResult, builtins}; +use cfg_if::cfg_if; +use clap::Parser; +#[cfg(not(any(target_os = "linux", target_os = "android")))] +use nix::sys::stat::Mode; +use std::io::Write; + +/// Manage the process umask. +#[derive(Parser)] +pub(crate) struct UmaskCommand { + /// If MODE is omitted, output in a form that may be reused as input. + #[arg(short = 'p')] + print_roundtrippable: bool, + + /// Makes the output symbolic; otherwise an octal number is given. + #[arg(short = 'S')] + symbolic_output: bool, + + /// Mode mask. + mode: Option, +} + +impl builtins::Command for UmaskCommand { + type Error = brush_core::Error; + + async fn execute( + &self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + 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 { + 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 { + let u = nix::sys::stat::umask(Mode::empty()); + nix::sys::stat::umask(u); + // mode_t is signed (c_int) on Redox and some other targets, so + // `u32::from` (widening only) does not apply; a value-preserving cast + // covers signed and unsigned mode_t alike. + Ok(u.bits() as u32) + } + } +} + +fn set_umask(value: nix::sys::stat::mode_t) -> Result<(), brush_core::Error> { + // value of mode_t can be platform dependent + let mode = nix::sys::stat::Mode::from_bits(value).ok_or_else(|| ErrorKind::InvalidUmask)?; + nix::sys::stat::umask(mode); + Ok(()) +} + +fn symbolic_mask_from_bits(bits: u32) -> String { + let mut result = String::new(); + + if (bits & 0b100) != 0 { + result.push('r'); + } + if (bits & 0b010) != 0 { + result.push('w'); + } + if (bits & 0b001) != 0 { + result.push('x'); + } + + result +} diff --git a/local/recipes/shells/brush/source/brush-builtins/src/unalias.rs b/local/recipes/shells/brush/source/brush-builtins/src/unalias.rs new file mode 100644 index 0000000000..c2cd283fac --- /dev/null +++ b/local/recipes/shells/brush/source/brush-builtins/src/unalias.rs @@ -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, +} + +impl builtins::Command for UnaliasCommand { + type Error = brush_core::Error; + + async fn execute( + &self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + 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) + } +} diff --git a/local/recipes/shells/brush/source/brush-builtins/src/unimp.rs b/local/recipes/shells/brush/source/brush-builtins/src/unimp.rs new file mode 100644 index 0000000000..d9d19b7e5d --- /dev/null +++ b/local/recipes/shells/brush/source/brush-builtins/src/unimp.rs @@ -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, + + #[clap(skip)] + declarations: Vec, +} + +impl builtins::Command for UnimplementedCommand { + type Error = brush_core::Error; + + async fn execute( + &self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + 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) { + self.declarations = declarations; + } +} diff --git a/local/recipes/shells/brush/source/brush-builtins/src/unset.rs b/local/recipes/shells/brush/source/brush-builtins/src/unset.rs new file mode 100644 index 0000000000..5650c4496e --- /dev/null +++ b/local/recipes/shells/brush/source/brush-builtins/src/unset.rs @@ -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, +} + +#[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( + &self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + // + // 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, + name: &str, + index: &str, +) -> Result { + // 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()) +} diff --git a/local/recipes/shells/brush/source/brush-builtins/src/wait.rs b/local/recipes/shells/brush/source/brush-builtins/src/wait.rs new file mode 100644 index 0000000000..05b2009752 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-builtins/src/wait.rs @@ -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, + + /// Process IDs or job specs to wait for. + ids: Vec, +} + +impl builtins::Command for WaitCommand { + type Error = brush_core::Error; + + async fn execute( + &self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + 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) + } +} diff --git a/local/recipes/shells/brush/source/brush-core/Cargo.toml b/local/recipes/shells/brush/source/brush-core/Cargo.toml new file mode 100644 index 0000000000..d3f59d2895 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/Cargo.toml @@ -0,0 +1,89 @@ +[package] +name = "brush-core" +description = "Reusable core of a POSIX/bash shell (used by brush-shell)" +version = "0.5.0" +categories.workspace = true +edition.workspace = true +keywords.workspace = true +license.workspace = true +readme.workspace = true +repository.workspace = true +rust-version.workspace = true + +[lib] +bench = false + +[lints] +workspace = true + +[features] +default = [] +serde = ["dep:serde", "brush-parser/serde", "rpds/serde", "chrono/serde"] +experimental-parser = ["brush-parser/winnow-parser"] + +[dependencies] +async-recursion = "1.1.1" +async-trait = "0.1.89" +brush-parser = { version = "^0.4.0", path = "../brush-parser" } +bon = "3.9.1" +cached = "0.59.0" +cfg-if = "1.0.4" +chrono = "0.4.44" +clap = { version = "4.6.0", features = ["derive", "wrap_help"] } +color-print = "0.3.7" +fancy-regex = "0.18.0" +futures = "0.3.32" +inherent = "1.0.13" +itertools = "0.14.0" +normalize-path = "0.2.1" +rand = "0.10.0" +rpds = "1.2.0" +serde = { version = "1.0.228", optional = true, features = ["derive"] } +strum = "0.28.0" +strum_macros = "0.28.0" +thiserror = "2.0.18" +tracing = "0.1.44" + +[target.'cfg(target_family = "wasm")'.dependencies] +tokio = { version = "1.52.3", features = ["io-util", "macros", "rt", "sync"] } + +[target.'cfg(any(unix, windows))'.dependencies] +hostname = "0.4.2" +tokio = { version = "1.52.3", features = [ + "io-util", + "macros", + "net", + "process", + "rt", + "rt-multi-thread", + "signal", + "sync", +] } + +[target.'cfg(windows)'.dependencies] +check_elevation = "0.2.7" +whoami = "2.1.1" + +[target.'cfg(unix)'.dependencies] +command-fds = "0.3.2" +libc = "0.2.183" +nix = { version = "0.31.2", features = [ + "fs", + "poll", + "process", + "resource", + "signal", + "term", + "user", +] } +terminfo = "0.9.0" +uzers = "0.12.2" + +[target.wasm32-unknown-unknown.dependencies] +getrandom = { version = "0.4.2", features = ["wasm_js"] } +uuid = { version = "1.23.1", features = ["js"] } + +[dev-dependencies] +anyhow = "1.0.102" +pretty_assertions = { version = "1.4.1", features = ["unstable"] } +tempfile = "3.27.0" diff --git a/local/recipes/shells/brush/source/brush-core/LICENSE b/local/recipes/shells/brush/source/brush-core/LICENSE new file mode 120000 index 0000000000..ea5b60640b --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/LICENSE @@ -0,0 +1 @@ +../LICENSE \ No newline at end of file diff --git a/local/recipes/shells/brush/source/brush-core/examples/call-func.rs b/local/recipes/shells/brush/source/brush-core/examples/call-func.rs new file mode 100644 index 0000000000..10dab59d13 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/examples/call-func.rs @@ -0,0 +1,71 @@ +//! Example of instantiating a shell and calling a shell function in it. + +use anyhow::Result; + +async fn instantiate_shell() -> Result { + 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(()) +} diff --git a/local/recipes/shells/brush/source/brush-core/examples/custom-builtin.rs b/local/recipes/shells/brush/source/brush-core/examples/custom-builtin.rs new file mode 100644 index 0000000000..c32de40413 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/examples/custom-builtin.rs @@ -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( + &self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + // 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::()) + .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(()) +} diff --git a/local/recipes/shells/brush/source/brush-core/src/arithmetic.rs b/local/recipes/shells/brush/source/brush-core/src/arithmetic.rs new file mode 100644 index 0000000000..cca76f668d --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/arithmetic.rs @@ -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, + params: &ExecutionParameters, + trace_if_needed: bool, + ) -> Result; +} + +impl ExpandAndEvaluate for ast::UnexpandedArithmeticExpr { + async fn eval( + &self, + shell: &mut Shell, + params: &ExecutionParameters, + trace_if_needed: bool, + ) -> Result { + expand_and_eval(shell, params, self.value.as_str(), trace_if_needed).await + } +} + +/// Evaluate the given arithmetic expression, returning the resulting numeric value. +/// +/// # Arguments +/// +/// * `shell` - The shell to use for evaluation. +/// * `expr` - The unexpanded arithmetic expression to evaluate. +/// * `trace_if_needed` - Whether to trace the evaluation. +pub(crate) async fn expand_and_eval( + shell: &mut Shell, + params: &ExecutionParameters, + expr: &str, + trace_if_needed: bool, +) -> Result { + // 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) -> Result; +} + +impl Evaluatable for ast::ArithmeticExpr { + fn eval(&self, shell: &mut Shell) -> Result { + eval_expr_impl(self, shell, 0) + } +} + +fn eval_expr_impl( + expr: &ast::ArithmeticExpr, + shell: &mut Shell, + depth: u32, +) -> Result { + 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, + name: &str, +) -> Result, 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, + lvalue: &ast::ArithmeticTarget, + depth: u32, +) -> Result { + 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, + op: ast::UnaryOperator, + operand: &ast::ArithmeticExpr, + depth: u32, +) -> Result { + 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, + op: ast::BinaryOperator, + left: &ast::ArithmeticExpr, + right: &ast::ArithmeticExpr, + depth: u32, +) -> Result { + // 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, + lvalue: &ast::ArithmeticTarget, + op: ast::UnaryAssignmentOperator, + depth: u32, +) -> Result { + 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, + lvalue: &ast::ArithmeticTarget, + value: i64, + depth: u32, +) -> Result { + 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 +} diff --git a/local/recipes/shells/brush/source/brush-core/src/braceexpansion.rs b/local/recipes/shells/brush/source/brush-core/src/braceexpansion.rs new file mode 100644 index 0000000000..59a3f26700 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/braceexpansion.rs @@ -0,0 +1,89 @@ +use brush_parser::word; +use itertools::Itertools; + +pub(crate) fn generate_and_combine_brace_expansions( + pieces: Vec, +) -> impl IntoIterator { + let expansions: Vec> = 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> { + 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> { + 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()) + } + } +} diff --git a/local/recipes/shells/brush/source/brush-core/src/builtins.rs b/local/recipes/shells/brush/source/brush-core/src/builtins.rs new file mode 100644 index 0000000000..66692b9c34 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/builtins.rs @@ -0,0 +1,572 @@ +//! Facilities for implementing and managing builtins + +use clap::builder::styling; +pub use futures::future::BoxFuture; +use std::io::Write; + +use crate::{BuiltinError, CommandArg, commands, error, extensions, results}; + +/// Type of a function implementing a built-in command. +/// +/// # Arguments +/// +/// * The context in which the command is being executed. +/// * The arguments to the command. +#[allow(type_alias_bounds)] +pub type CommandExecuteFunc = + fn( + commands::ExecutionContext<'_, SE>, + Vec, + ) -> BoxFuture<'_, Result>; + +/// 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; + +/// 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(args: I) -> Result + where + I: IntoIterator, + { + if !Self::takes_plus_options() { + Self::try_parse_from(args) + } else { + let args = args.into_iter(); + + let (lower, _) = args.size_hint(); + + // N.B. clap doesn't support named options like '+x'. To work around this, we + // establish a pattern of renaming them. + let mut updated_args = Vec::with_capacity(lower); + for arg in args { + if let Some(plus_options) = arg.strip_prefix("+") { + updated_args.extend(plus_options.chars().map(|c| format!("--+{c}"))); + } else { + updated_args.push(arg); + } + } + + Self::try_parse_from(updated_args) + } + } + + /// Returns whether or not the command takes options with a leading '+' or '-' character. + fn takes_plus_options() -> bool { + false + } + + /// Executes the built-in command in the provided context. + /// + /// # Arguments + /// + /// * `context` - The context in which the command is being executed. + // NOTE: we use desugared async here because we need a Send marker + fn execute( + &self, + context: commands::ExecutionContext<'_, SE>, + ) -> impl std::future::Future> + + 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 { + 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); +} + +/// 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 { + /// Function to execute the builtin. + pub execute_func: CommandExecuteFunc, + + /// Function to retrieve the builtin's content/help text. + pub content_func: CommandContentFunc, + + /// Has this registration been disabled? + pub disabled: bool, + + /// Is the builtin classified as "special" by specification? + pub special_builtin: bool, + + /// Is this builtin one that takes specially handled declarations? + pub declaration_builtin: bool, +} + +impl Registration { + /// Updates the given registration to mark it for a special builtin. + #[must_use] + pub const fn special(self) -> Self { + Self { + special_builtin: true, + ..self + } + } +} + +fn get_builtin_man_page(_name: &str, _command: &clap::Command) -> Result { + 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, +/// } +/// +/// let (mut parsed_args, raw_args) = +/// brush_core::builtins::parse_known::(std::env::args()); +/// if raw_args.is_some() { +/// parsed_args.script_args = raw_args.unwrap().collect(); +/// } +/// ``` +pub fn parse_known( + args: impl IntoIterator, +) -> (T, Option>) +where + S: Into + 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( + args: impl IntoIterator, +) -> Result<(T, Option>), 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; + + /// Executes the built-in command. + fn execute, S: AsRef>( + context: commands::ExecutionContext<'_, SE>, + args: I, + ) -> Result; +} + +/// Returns a built-in command registration, given an implementation of the +/// `SimpleCommand` trait. +pub fn simple_builtin() +-> Registration { + Registration { + execute_func: exec_simple_builtin::, + 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() -> Registration { + Registration { + execute_func: exec_builtin::, + content_func: get_builtin_content::, + 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() +-> Registration { + Registration { + execute_func: exec_declaration_builtin::, + content_func: get_builtin_content::, + 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 { + Registration { + execute_func: exec_raw_arg_builtin::, + content_func: get_builtin_content::, + disabled: false, + special_builtin: false, + declaration_builtin: true, + } +} + +fn get_builtin_content( + name: &str, + content_type: ContentType, + options: &ContentOptions, +) -> Result { + T::get_content(name, content_type, options) +} + +fn exec_simple_builtin( + context: commands::ExecutionContext<'_, SE>, + args: Vec, +) -> BoxFuture<'_, Result> { + Box::pin(async move { exec_simple_builtin_impl::(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, +) -> Result { + 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( + context: commands::ExecutionContext<'_, SE>, + args: Vec, +) -> BoxFuture<'_, Result> { + Box::pin(async move { exec_builtin_impl::(context, args).await }) +} + +async fn exec_builtin_impl( + context: commands::ExecutionContext<'_, SE>, + args: Vec, +) -> Result { + 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, +) -> BoxFuture<'_, Result> { + Box::pin(async move { exec_declaration_builtin_impl::(context, args).await }) +} + +async fn exec_declaration_builtin_impl< + T: DeclarationCommand + Send + Sync, + SE: extensions::ShellExtensions, +>( + context: commands::ExecutionContext<'_, SE>, + args: Vec, +) -> Result { + 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, +) -> BoxFuture<'_, Result> { + Box::pin(async move { exec_raw_arg_builtin_impl::(context, args).await }) +} + +async fn exec_raw_arg_builtin_impl< + T: DeclarationCommand + Default + Send + Sync, + SE: extensions::ShellExtensions, +>( + context: commands::ExecutionContext<'_, SE>, + args: Vec, +) -> Result { + 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 { + 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) +} diff --git a/local/recipes/shells/brush/source/brush-core/src/callstack.rs b/local/recipes/shells/brush/source/brush-core/src/callstack.rs new file mode 100644 index 0000000000..33b9bf7c55 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/callstack.rs @@ -0,0 +1,787 @@ +//! Call stack representations. + +use crate::{functions, traps}; +use std::{ + borrow::Cow, + collections::{HashSet, VecDeque}, + sync::Arc, +}; + +use brush_parser::ast::SourceLocation; + +/// Encapsulates info regarding a script call. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct ScriptCall { + /// The type of script call. + pub call_type: ScriptCallType, + /// The source info for the script called. + pub source_info: crate::SourceInfo, +} + +impl ScriptCall { + /// Returns the name of the script that was called. + pub fn name(&self) -> Cow<'_, str> { + self.source_info.source.as_str().into() + } +} + +/// The type of script call. +#[derive(Clone, Copy, Debug)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum ScriptCallType { + /// A script was sourced. + Source, + /// A script was executed. + Run, +} + +impl std::fmt::Display for ScriptCall { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self.call_type { + ScriptCallType::Source => write!(f, "source({})", self.source_info), + ScriptCallType::Run => write!(f, "script({})", self.source_info), + } + } +} + +/// Represents the type of a frame, indicating how it was invoked from +/// a different source context. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum FrameType { + /// A script was called (sourced or executed). + Script(ScriptCall), + /// A function was called. + Function(FunctionCall), + /// A trap handler was invoked. + TrapHandler(traps::TrapSignal), + /// A string was eval'd. + Eval, + /// A command-line string (i.e., -c) was executed. + CommandString, + /// An interactive command session was started. + InteractiveSession, +} + +impl FrameType { + /// Returns a name for the frame (i.e., script path or function name). + pub fn name(&self) -> Cow<'_, str> { + match self { + Self::Script(call) => call.name(), + Self::Function(call) => call.name(), + Self::TrapHandler(_) => "trap".into(), + Self::Eval => "eval".into(), + Self::CommandString => "-c".into(), + Self::InteractiveSession => "interactive".into(), + } + } + + /// Returns `true` if the frame is for a function call. + pub const fn is_function(&self) -> bool { + matches!(self, Self::Function(..)) + } + + /// Returns `true` if the frame is for a script call. + pub const fn is_script(&self) -> bool { + matches!(self, Self::Script(..)) + } + + /// Returns `true` if the frame is for a trap handler. + pub const fn is_trap_handler(&self) -> bool { + matches!(self, Self::TrapHandler(_)) + } + + /// Returns `true` if the frame is for an interactive session. + pub const fn is_interactive_session(&self) -> bool { + matches!(self, Self::InteractiveSession) + } + + /// Returns `true` if the frame is for a command string being executed. + pub const fn is_command_string(&self) -> bool { + matches!(self, Self::CommandString) + } + + /// Returns `true` if the frame is for a sourced script. + pub const fn is_sourced_script(&self) -> bool { + matches!(self, Self::Script(call) if matches!(call.call_type, ScriptCallType::Source)) + } + + /// Returns `true` if the frame is for a run script. + pub const fn is_run_script(&self) -> bool { + matches!(self, Self::Script(call) if matches!(call.call_type, ScriptCallType::Run)) + } +} + +impl std::fmt::Display for FrameType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Script(call) => call.fmt(f), + Self::Function(call) => call.fmt(f), + Self::TrapHandler(_) => write!(f, "trap"), + Self::Eval => write!(f, "eval"), + Self::CommandString => write!(f, "-c"), + Self::InteractiveSession => write!(f, "interactive"), + } + } +} + +/// Describes the target of a function call. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct FunctionCall { + /// The name of the function invoked. + pub function_name: String, + /// The invoked function. + pub function: functions::Registration, +} + +impl FunctionCall { + /// Returns the name of the function that was called. + pub fn name(&self) -> Cow<'_, str> { + self.function_name.as_str().into() + } +} + +impl std::fmt::Display for FunctionCall { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "func({})", self.function_name) + } +} + +/// Represents a single frame in a `CallStack`. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Frame { + /// The type of frame. + pub frame_type: FrameType, + /// The source information for the frame. The locations associated with AST nodes + /// executed in this frame should be interpreted as being relative to this + /// source info. + pub source_info: crate::SourceInfo, + /// The location of the entry point into this frame, within the frame of + /// reference of `source_info`. May be `None` if the entry point is not known. + pub entry: Option>, + /// Information about the currently executing location. For the topmost frame on + /// the stack, this represents the current execution location. For older frames, + /// this represents the site from which a control transfer was made to the next + /// younger frame. May be `None` if the current location is not known. When present, + /// it is relative to the frame of reference of `source_info`. + pub current: Option>, + /// Positional arguments (not including $0). May not be present for all frames. + pub args: Vec, + /// Optionally, indicates an additional line offset within the current source context. + pub current_line_offset: usize, +} + +impl Frame { + /// Returns the adjusted source info for this frame, combining the + /// frame's `source_info` and `current_line_offset`, if present. + pub fn adjusted_source_info(&self) -> crate::SourceInfo { + self.pos_as_source_info(None) + } + + /// Returns the current position as a new `SourceInfo`, combining the + /// frame's `source_info` and `current` position. + pub fn current_pos_as_source_info(&self) -> crate::SourceInfo { + self.pos_as_source_info(self.current.as_ref()) + } + + fn pos_as_source_info(&self, pos: Option<&Arc>) -> crate::SourceInfo { + let mut new_start = if let Some(existing_start) = &self.source_info.start { + if let Some(current) = pos { + Some(Arc::new(crate::SourcePosition { + index: existing_start.index + current.index, + line: existing_start.line + (current.line - 1), + column: if current.line <= 1 { + existing_start.column + (current.column - 1) + } else { + current.column + }, + })) + } else { + Some(existing_start.clone()) + } + } else { + pos.cloned() + }; + + if self.current_line_offset > 0 { + new_start = if let Some(new_start) = new_start { + let mut pos = (*new_start).clone(); + pos.line += self.current_line_offset; + + Some(Arc::new(pos)) + } else { + Some(Arc::new(crate::SourcePosition { + index: 0, + line: self.current_line_offset + 1, + column: 1, + })) + }; + } + + crate::SourceInfo { + source: self.source_info.source.clone(), + start: new_start, + } + } + + /// Returns the current line number. + pub fn current_line(&self) -> Option { + let start_line = self.source_info.start.as_ref().map_or(1, |pos| pos.line); + let current_line = self.current.as_ref().map(|pos| pos.line)?; + + Some(start_line.saturating_sub(1) + current_line + self.current_line_offset) + } + + /// Returns the current line number, relative to the frame's entry. + pub fn current_frame_relative_line(&self) -> Option { + let current_line = self.current.as_ref().map(|pos| pos.line)?; + let entry_line = self.entry.as_ref().map_or(1, |pos| pos.line); + + Some(current_line.saturating_sub(entry_line) + self.current_line_offset + 1) + } +} + +/// Options for formatting a call stack. +#[derive(Default)] +pub struct FormatOptions { + /// Whether or not to show args. + pub show_args: bool, + /// Whether or not to show frame entry points. + pub show_entry_points: bool, +} + +/// Helper struct for formatting a call stack with custom options. +/// +/// This struct implements `Display` and can be used to write a formatted +/// call stack to any type that implements `io::Write`. +pub struct FormatCallStack<'a> { + stack: &'a CallStack, + options: &'a FormatOptions, +} + +impl<'a> FormatCallStack<'a> { + /// Creates a new formatter for the given call stack with the specified options. + /// + /// # Arguments + /// + /// * `stack` - The call stack to format. + /// * `options` - The formatting options to use. + pub const fn new(stack: &'a CallStack, options: &'a FormatOptions) -> Self { + Self { stack, options } + } +} + +impl std::fmt::Display for FormatCallStack<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.stack.fmt_with_options(f, self.options) + } +} + +/// Encapsulates a script call stack. +#[derive(Clone, Debug, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct CallStack { + frames: VecDeque, + func_call_depth: usize, + script_source_depth: usize, + active_trap_signals: HashSet, + trap_delivery_suppress_count: usize, +} + +impl CallStack { + /// Creates a formatter for this call stack with the given options. + /// + /// # Arguments + /// + /// * `options` - The formatting options to use. + pub const fn format<'a>(&'a self, options: &'a FormatOptions) -> FormatCallStack<'a> { + FormatCallStack::new(self, options) + } + + /// Formats the call stack with the given options. + /// + /// # Arguments + /// + /// * `f` - The formatter to write to. + /// * `options` - The formatting options. + fn fmt_with_options( + &self, + f: &mut std::fmt::Formatter<'_>, + options: &FormatOptions, + ) -> std::fmt::Result { + if self.is_empty() { + return Ok(()); + } + + color_print::cwriteln!(f, "Call stack (most recent first):")?; + + for (index, frame) in self.iter().enumerate() { + let si = frame.current_pos_as_source_info(); + + color_print::cwrite!( + f, + " #{index}| {}", + si.source + )?; + + if let Some(pos) = &si.start { + color_print::cwrite!(f, ":{},{}", pos.line, pos.column)?; + } + + color_print::cwrite!(f, " ({}", frame.frame_type)?; + + if options.show_entry_points { + if let Some(entry) = &frame.entry { + let entry_si = frame.pos_as_source_info(Some(entry)); + if let Some(entry_start) = &entry_si.start { + color_print::cwrite!( + f, + " entered at {}:{}", + entry_si.source, + entry_start + )?; + } + } + } + + color_print::cwriteln!(f, ")")?; + + if !frame.args.is_empty() && options.show_args { + for (i, arg) in frame.args.iter().enumerate() { + color_print::cwriteln!( + f, + " ${}: {}", + i + 1, + arg + )?; + } + } + } + + Ok(()) + } +} + +impl std::fmt::Display for CallStack { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.fmt_with_options(f, &FormatOptions::default()) + } +} + +impl std::ops::Index for CallStack { + type Output = Frame; + + fn index(&self, index: usize) -> &Self::Output { + &self.frames[index] + } +} + +impl CallStack { + /// Creates a new empty script call stack. + pub fn new() -> Self { + Self::default() + } + + /// Removes the top from from the stack. If the stack is empty, does nothing and + /// returns `None`; otherwise, returns the removed call frame. + pub fn pop(&mut self) -> Option { + let frame = self.frames.pop_front()?; + + if frame.frame_type.is_function() { + self.func_call_depth = self.func_call_depth.saturating_sub(1); + } + + if frame.frame_type.is_sourced_script() { + self.script_source_depth = self.script_source_depth.saturating_sub(1); + } + + if let FrameType::TrapHandler(signal) = &frame.frame_type { + self.active_trap_signals.remove(signal); + } + + Some(frame) + } + + /// Returns a reference to the current (topmost) call frame in the stack. + /// Returns `None` if the stack is empty. + pub fn current_frame(&self) -> Option<&Frame> { + self.frames.front() + } + + /// Returns the position in the current (topmost) call frame in the stack, + /// expressed as a new `SourceInfo`. Note that this may not be identical + /// to that frame's `SourceInfo` since it may include an offset representing + /// the current execution position within that source. + pub fn current_pos_as_source_info(&self) -> crate::SourceInfo { + let Some(frame) = self.frames.front() else { + return crate::SourceInfo::default(); + }; + + frame.current_pos_as_source_info() + } + + /// Updates the currently executing position in the top stack frame. + pub fn set_current_pos(&mut self, position: Option>) { + if let Some(frame) = self.frames.front_mut() { + frame.current = position; + } + } + + /// Increments the current line offset in the top stack frame by the given delta. + /// + /// # Arguments + /// + /// * `delta` - The number of lines to increment the current line offset by. + pub(crate) fn increment_current_line_offset(&mut self, delta: usize) { + let Some(frame) = self.frames.front_mut() else { + return; + }; + + frame.current_line_offset += delta; + } + + /// Pushes a new script call frame onto the stack. + /// + /// # Arguments + /// + /// * `call_type` - The type of script call (sourced or executed). + /// * `source_info` - The source of the script. + /// * `args` - The positional arguments for the script call. + pub fn push_script( + &mut self, + call_type: ScriptCallType, + source_info: &crate::SourceInfo, + args: impl IntoIterator, + ) { + self.frames.push_front(Frame { + frame_type: FrameType::Script(ScriptCall { + call_type, + source_info: source_info.to_owned(), + }), + args: args.into_iter().collect(), + source_info: source_info.to_owned(), + current_line_offset: 0, + current: None, // TODO(source-info): fill this out + entry: None, // TODO(source-info): fill this out + }); + + if matches!(call_type, ScriptCallType::Source) { + self.script_source_depth += 1; + } + } + + /// Pushes a new trap handler frame onto the stack. + /// + /// # Arguments + /// + /// * `signal` - The signal being handled. + /// * `handler` - The trap handler being invoked, if any. + pub fn push_trap_handler( + &mut self, + signal: traps::TrapSignal, + handler: Option<&traps::TrapHandler>, + ) { + let source_info = + handler.map_or_else(crate::SourceInfo::default, |h| h.source_info.clone()); + + self.frames.push_front(Frame { + frame_type: FrameType::TrapHandler(signal), + args: vec![], + source_info, + current_line_offset: 0, + current: None, // TODO(source-info): fill this out + entry: None, // TODO(source-info): fill this out + }); + + self.active_trap_signals.insert(signal); + } + + /// Pushes a new eval frame onto the stack. + pub fn push_eval(&mut self) { + self.frames.push_front(Frame { + frame_type: FrameType::Eval, + args: vec![], + source_info: crate::SourceInfo::from("eval"), // TODO(source-info): fill this out + current_line_offset: 0, + current: None, // TODO(source-info): fill this out + entry: None, // TODO(source-info): fill this out + }); + } + + /// Pushes a new command string frame onto the stack. + pub fn push_command_string(&mut self) { + self.frames.push_front(Frame { + frame_type: FrameType::CommandString, + args: vec![], + source_info: crate::SourceInfo::from("environment"), + current_line_offset: 0, + current: None, // TODO(source-info): fill this out + entry: None, // TODO(source-info): fill this out + }); + } + + /// Pushes a new interactive session frame onto the stack. + pub fn push_interactive_session(&mut self) { + self.frames.push_front(Frame { + frame_type: FrameType::InteractiveSession, + args: vec![], + current_line_offset: 0, + source_info: crate::SourceInfo::from("main"), + current: None, // TODO(source-info): fill this out + entry: None, // TODO(source-info): fill this out + }); + } + + /// Pushes a new function call frame onto the stack. + /// + /// # Arguments + /// + /// * `name` - The name of the function being called. + /// * `function` - The function being called. + /// * `args` - The positional arguments for the function call. + pub fn push_function( + &mut self, + name: impl Into, + function: &functions::Registration, + args: impl IntoIterator, + ) { + self.frames.push_front(Frame { + frame_type: FrameType::Function(FunctionCall { + function_name: name.into(), + function: function.to_owned(), + }), + args: args.into_iter().collect(), + source_info: function.source().clone(), + entry: function.definition().location().map(|span| span.start), + current: None, // TODO(source-info): fill this out + current_line_offset: 0, + }); + + self.func_call_depth += 1; + } + + /// Iterates through the function calls on the stack. + pub fn iter_function_calls(&self) -> impl Iterator { + self.iter().filter_map(|frame| { + if let FrameType::Function(call) = &frame.frame_type { + Some(call) + } else { + None + } + }) + } + + /// Iterates through the script calls on the stack. + pub fn iter_script_calls(&self) -> impl Iterator { + self.iter().filter_map(|frame| { + if let FrameType::Script(call) = &frame.frame_type { + Some(call) + } else { + None + } + }) + } + + /// Returns whether or not the current script stack frame is a sourced script. + pub fn in_sourced_script(&self) -> bool { + self.iter_script_calls() + .next() + .is_some_and(|call| matches!(call.call_type, ScriptCallType::Source)) + } + + /// Returns the current depth of function calls in the call stack. + pub const fn function_call_depth(&self) -> usize { + self.func_call_depth + } + + /// Returns the current depth of sourced script calls in the call stack. + pub const fn script_source_depth(&self) -> usize { + self.script_source_depth + } + + /// Returns whether the given trap signal is currently being handled + /// (i.e., there is an active frame on the stack for this signal). + pub fn is_trap_signal_active(&self, signal: traps::TrapSignal) -> bool { + self.active_trap_signals.contains(&signal) + } + + /// Clears the set of active trap signals. This should be called when + /// creating subshells so they start with fresh trap execution state + /// independent of the parent shell's currently-executing traps. + pub fn clear_active_trap_signals(&mut self) { + self.active_trap_signals.clear(); + } + + /// Returns whether the given trap signal is currently suppressed. + pub const fn is_trap_delivery_suppressed(&self) -> bool { + self.trap_delivery_suppress_count > 0 + } + + /// Acquires a block on trap delivery, preventing traps from being delivered until + /// the block is released. Multiple blocks may be acquired, and trap delivery will + /// remain suppressed until all blocks have been released. + pub const fn acquire_trap_delivery_block(&mut self) { + self.trap_delivery_suppress_count += 1; + } + + /// Releases a block on trap delivery; note that trap delivery will remain + /// suppressed until all blocks have been released. + pub const fn release_trap_delivery_block(&mut self) { + self.trap_delivery_suppress_count = self.trap_delivery_suppress_count.saturating_sub(1); + } + + /// Returns whether or not the shell is actively executing in a shell function. + pub fn in_function(&self) -> bool { + self.iter_function_calls().next().is_some() + } + + /// Returns the current depth of the call stack. + pub fn depth(&self) -> usize { + self.frames.len() + } + + /// Returns whether or not the call stack is empty. + pub fn is_empty(&self) -> bool { + self.frames.is_empty() + } + + /// Returns an iterator over the call frames, starting from the most + /// recent. + pub fn iter(&self) -> impl Iterator { + self.frames.iter() + } + + /// Returns a mutable iterator over the call frames, starting from the most + /// recent. + pub fn iter_mut(&mut self) -> impl Iterator { + self.frames.iter_mut() + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + use crate::SourceInfo; + use pretty_assertions::assert_matches; + + #[test] + fn test_call_stack_new() { + let stack = CallStack::new(); + assert!(stack.is_empty()); + assert_eq!(stack.depth(), 0); + } + + #[test] + fn test_call_stack_default() { + let stack = CallStack::default(); + assert!(stack.is_empty()); + assert_eq!(stack.depth(), 0); + } + + #[test] + fn test_call_stack_push_pop() { + let mut stack = CallStack::new(); + + stack.push_script( + ScriptCallType::Source, + &SourceInfo::from(PathBuf::from("script1.sh")), + vec![], + ); + assert!(!stack.is_empty()); + assert_eq!(stack.depth(), 1); + + stack.push_script( + ScriptCallType::Run, + &SourceInfo::from(PathBuf::from("script2.sh")), + vec![], + ); + assert_eq!(stack.depth(), 2); + + let frame = stack.pop().unwrap(); + assert_matches!( + frame.frame_type, + FrameType::Script(ScriptCall { + call_type: ScriptCallType::Run, + source_info: SourceInfo { + source: file_path, + .. + }, + }) if &file_path == "script2.sh" + ); + assert_eq!(stack.depth(), 1); + + let frame = stack.pop().unwrap(); + assert_matches!( + frame.frame_type, + FrameType::Script(ScriptCall { + call_type: ScriptCallType::Source, + source_info: SourceInfo { + source: file_path, + .. + }, + }) if &file_path == "script1.sh" + ); + assert_eq!(stack.depth(), 0); + assert!(stack.is_empty()); + } + + #[test] + fn test_call_stack_pop_empty() { + let mut stack = CallStack::new(); + assert!(stack.pop().is_none()); + } + + #[test] + fn test_in_sourced_script() { + let mut stack = CallStack::new(); + assert!(!stack.in_sourced_script()); + + stack.push_script( + ScriptCallType::Run, + &SourceInfo::from(PathBuf::from("script1.sh")), + vec![], + ); + assert!(!stack.in_sourced_script()); + + stack.push_script( + ScriptCallType::Source, + &SourceInfo::from(PathBuf::from("script2.sh")), + vec![], + ); + assert!(stack.in_sourced_script()); + + stack.pop(); + assert!(!stack.in_sourced_script()); + } + + #[test] + fn test_call_stack_iter() { + let mut stack = CallStack::new(); + stack.push_script( + ScriptCallType::Source, + &SourceInfo::from(PathBuf::from("script1.sh")), + vec![], + ); + stack.push_script( + ScriptCallType::Run, + &SourceInfo::from(PathBuf::from("script2.sh")), + vec![], + ); + stack.push_script( + ScriptCallType::Source, + &SourceInfo::from(PathBuf::from("script3.sh")), + vec![], + ); + + let frames: Vec<_> = stack.iter().collect(); + assert_eq!(frames.len(), 3); + assert_matches!(&frames[0].frame_type, FrameType::Script(ScriptCall { source_info: SourceInfo { source: file_path, .. }, .. }) if file_path == "script3.sh"); + assert_matches!(&frames[1].frame_type, FrameType::Script(ScriptCall { source_info: SourceInfo { source: file_path, .. }, .. }) if file_path == "script2.sh"); + assert_matches!(&frames[2].frame_type, FrameType::Script(ScriptCall { source_info: SourceInfo { source: file_path, .. }, .. }) if file_path == "script1.sh"); + } +} diff --git a/local/recipes/shells/brush/source/brush-core/src/commands.rs b/local/recipes/shells/brush/source/brush-core/src/commands.rs new file mode 100644 index 0000000000..64e0505a81 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/commands.rs @@ -0,0 +1,873 @@ +//! Command execution + +use std::{ + borrow::Cow, + ffi::OsStr, + fmt::Display, + path::{Path, PathBuf}, + process::Stdio, +}; + +use brush_parser::ast; +use itertools::Itertools; +use sys::commands::{CommandExt, CommandFdInjectionExt, CommandFgControlExt}; + +use crate::{ + ErrorKind, ExecutionControlFlow, ExecutionExitCode, ExecutionParameters, ExecutionResult, + Shell, ShellFd, builtins, commands, env, error, escape, + extensions::{self, ShellExtensions}, + functions, + interp::{self, Execute, ProcessGroupPolicy}, + openfiles::{self, OpenFile, OpenFiles}, + pathsearch, processes, + results::ExecutionSpawnResult, + sys, trace_categories, traps, variables, +}; + +/// Encapsulates the result of waiting for a command to complete. +pub enum CommandWaitResult { + /// The command completed. + CommandCompleted(ExecutionResult), + /// The command was stopped before it completed. + CommandStopped(ExecutionResult, processes::ChildProcess), +} + +/// Represents the context for executing a command. +pub struct ExecutionContext<'a, SE: ShellExtensions = extensions::DefaultShellExtensions> { + /// The shell in which the command is being executed. + pub shell: &'a mut Shell, + /// The name of the command being executed. + pub command_name: String, + /// The parameters for the execution. + pub params: ExecutionParameters, +} + +impl ExecutionContext<'_, SE> { + /// Returns the standard input file; usable with `write!` et al. + pub fn stdin(&self) -> impl std::io::Read + 'static { + self.params.stdin(self.shell) + } + + /// Returns the standard output file; usable with `write!` et al. + pub fn stdout(&self) -> impl std::io::Write + 'static { + self.params.stdout(self.shell) + } + + /// Returns the standard error file; usable with `write!` et al. + pub fn stderr(&self) -> impl std::io::Write + 'static { + self.params.stderr(self.shell) + } + + /// Returns the file descriptor with the given number. Returns `None` + /// if the file descriptor is not open. + /// + /// # Arguments + /// + /// * `fd` - The file descriptor number to retrieve. + pub fn try_fd(&self, fd: ShellFd) -> Option { + self.params.try_fd(self.shell, fd) + } + + /// Iterates over all open file descriptors. + pub fn iter_fds(&self) -> impl Iterator { + self.params.iter_fds(self.shell) + } +} + +/// An argument to a command. +#[derive(Clone, Debug)] +pub enum CommandArg { + /// A simple string argument. + String(String), + /// An assignment/declaration; typically treated as a string, but will + /// be specially handled by a limited set of built-in commands. + Assignment(ast::Assignment), +} + +impl Display for CommandArg { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::String(s) => f.write_str(s), + Self::Assignment(a) => write!(f, "{a}"), + } + } +} + +impl From for CommandArg { + fn from(s: String) -> Self { + Self::String(s) + } +} + +impl From<&String> for CommandArg { + fn from(value: &String) -> Self { + Self::String(value.clone()) + } +} + +impl CommandArg { + pub(crate) fn quote_for_tracing(&self) -> Cow<'_, str> { + match self { + Self::String(s) => escape::quote_if_needed(s, escape::QuoteMode::SingleQuote), + Self::Assignment(a) => { + let mut s = a.name.to_string(); + let op = if a.append { "+=" } else { "=" }; + s.push_str(op); + s.push_str(&escape::quote_if_needed( + a.value.to_string().as_str(), + escape::QuoteMode::SingleQuote, + )); + s.into() + } + } + } +} + +/// Encapsulates a possibly-owned reference to a `Shell` for command execution. +pub enum ShellForCommand<'a, SE: extensions::ShellExtensions> { + /// The command is run in the same shell as its parent; the provided + /// mutable reference allows modifying the parent shell. + ParentShell(&'a mut Shell), + /// The command is run in its own owned shell (which is also provided). + OwnedShell { + /// The owned shell. + target: Box>, + /// The parent shell. + parent: &'a mut Shell, + }, +} + +impl std::ops::Deref for ShellForCommand<'_, SE> { + type Target = Shell; + + fn deref(&self) -> &Self::Target { + match self { + ShellForCommand::ParentShell(shell) => shell, + ShellForCommand::OwnedShell { target, .. } => target, + } + } +} + +impl std::ops::DerefMut for ShellForCommand<'_, SE> { + fn deref_mut(&mut self) -> &mut Self::Target { + match self { + ShellForCommand::ParentShell(shell) => shell, + ShellForCommand::OwnedShell { target, .. } => target, + } + } +} + +/// Composes a `std::process::Command` to execute the given command. Appropriately +/// configures the command name and arguments, redirections, injected file +/// descriptors, environment variables, etc. +/// +/// # Arguments +/// +/// * `context` - The execution context in which the command is being composed. +/// * `command_name` - The name of the command to execute. +/// * `argv0` - The value to use for `argv[0]` (may be different from the command). +/// * `args` - The arguments to pass to the command. +/// * `empty_env` - If true, the command will be executed with an empty environment; if false, the +/// command will inherit environment variables marked as exported in the provided `Shell`. +#[allow(unused_variables, reason = "argv0 is only used on unix platforms")] +pub fn compose_std_command, SE: extensions::ShellExtensions>( + context: &ExecutionContext<'_, SE>, + command_name: &str, + argv0: &str, + args: &[S], + empty_env: bool, +) -> Result { + let mut cmd = std::process::Command::new(command_name); + + // Override argv[0]. + // NOTE: Not supported on all platforms. + cmd.arg0(argv0); + + // Pass through args. + cmd.args(args); + + // Use the shell's current working dir. + cmd.current_dir(context.shell.working_dir()); + + // Start with a clear environment. + cmd.env_clear(); + + // Add in exported variables. + if !empty_env { + for (k, v) in context.shell.env().iter_exported() { + // NOTE: To match bash behavior, we only include exported variables + // that are set (i.e., have a value). This means a variable that + // shows up in `declare -p` but has no *set* value will be omitted. + if v.value().is_set() { + cmd.env(k.as_str(), v.value().to_cow_str(context.shell).as_ref()); + } + } + // Set _ to the resolved command path for external commands. + cmd.env("_", command_name); + } + + // Add in exported functions. + if !empty_env { + for (func_name, registration) in context.shell.funcs().iter() { + if registration.is_exported() { + let var_name = std::format!("BASH_FUNC_{func_name}%%"); + let value = std::format!("() {}", registration.definition().body); + cmd.env(var_name, value); + } + } + } + + // Redirect stdin, if applicable. + match context.try_fd(OpenFiles::STDIN_FD) { + Some(OpenFile::Stdin(_)) | None => (), + Some(stdin_file) => { + let as_stdio: Stdio = stdin_file.try_into()?; + cmd.stdin(as_stdio); + } + } + + // Redirect stdout, if applicable. + match context.try_fd(OpenFiles::STDOUT_FD) { + Some(OpenFile::Stdout(_)) | None => (), + Some(stdout_file) => { + let as_stdio: Stdio = stdout_file.try_into()?; + cmd.stdout(as_stdio); + } + } + + // Redirect stderr, if applicable. + match context.try_fd(OpenFiles::STDERR_FD) { + Some(OpenFile::Stderr(_)) | None => {} + Some(stderr_file) => { + let as_stdio: Stdio = stderr_file.try_into()?; + cmd.stderr(as_stdio); + } + } + + // Inject any other fds. + let other_files = context.iter_fds().filter(|(fd, _)| { + *fd != OpenFiles::STDIN_FD && *fd != OpenFiles::STDOUT_FD && *fd != OpenFiles::STDERR_FD + }); + cmd.inject_fds(other_files)?; + + Ok(cmd) +} + +pub(crate) async fn on_preexecute( + cmd: &mut commands::SimpleCommand<'_, impl extensions::ShellExtensions>, +) -> Result<(), error::Error> { + // Set BASH_COMMAND before invoking the DEBUG trap (and generally before + // executing commands). + let full_cmd = cmd.args.iter().map(|arg| arg.to_string()).join(" "); + cmd.shell.env_mut().update_or_add( + "BASH_COMMAND", + variables::ShellValueLiteral::Scalar(full_cmd), + |_| Ok(()), + env::EnvironmentLookup::Anywhere, + env::EnvironmentScope::Global, + )?; + + // Fire the DEBUG trap if one is registered. + if cmd.shell.traps().handles(traps::TrapSignal::Debug) { + let _ = cmd + .shell + .invoke_trap_handler(traps::TrapSignal::Debug, &cmd.params) + .await?; + } + + Ok(()) +} + +/// Represents a simple command to be executed. +pub struct SimpleCommand<'a, SE: extensions::ShellExtensions> { + /// The shell to run the command in. + shell: ShellForCommand<'a, SE>, + + /// The execution parameters for the command. + pub params: ExecutionParameters, + + /// The name of the command to execute. + pub command_name: String, + + /// The arguments to the command, including the command itself. + pub args: Vec, + + /// Whether to consider shell functions when looking up the command name. + /// If true, shell functions will be checked; if false, they will be ignored. + pub use_functions: bool, + + /// Optional list of directories to search for external commands. If left + /// `None`, the default search logic will be used. + pub path_dirs: Option>, + + /// The process group ID to use for externally executed commands. This may be + /// `None`, in which case the default behavior will be used. + pub process_group_id: Option, + + /// Optional override for the `argv[0]` value presented to an externally + /// spawned process. When `None`, `command_name` is used. + pub argv0: Option, + + /// Optionally provides a function that can run after execution occurs. Note + /// that it is *not* invoked if the shell is discarded during the execution + /// process. + #[allow(clippy::type_complexity)] + pub post_execute: Option) -> Result<(), error::Error>>, +} + +impl<'a, SE: extensions::ShellExtensions> SimpleCommand<'a, SE> { + /// Creates a new `SimpleCommand` instance. + /// + /// # Arguments + /// + /// * `shell` - The shell in which to execute the command. + /// * `params` - The execution parameters for the command. + /// * `command_name` - The name of the command to execute. + /// * `args` - The arguments to the command, including the command itself. + pub fn new( + shell: ShellForCommand<'a, SE>, + params: ExecutionParameters, + command_name: String, + args: I, + ) -> Self + where + I: IntoIterator, + { + Self { + shell, + params, + command_name, + args: args.into_iter().collect(), + use_functions: true, + path_dirs: None, + process_group_id: None, + argv0: None, + post_execute: None, + } + } + + /// Executes the simple command. + /// + /// The command may be a builtin, a shell function, or an externally + /// executed command. This function's implementation is responsible for + /// dispatching it appropriately according to the context provided. + #[allow( + clippy::missing_panics_doc, + reason = "these unwrap calls should not panic" + )] + pub async fn execute(mut self) -> Result { + // First see if it's the name of a builtin. + let builtin = self.shell.builtins().get(&self.command_name).cloned(); + + // If we're in POSIX mode and found a special builtin (that's not disabled), then invoke it + // without considering functions. + if self.shell.options().posix_mode + && builtin + .as_ref() + .is_some_and(|r| !r.disabled && r.special_builtin) + { + #[allow(clippy::unwrap_used, reason = "we just checked that builtin is Some")] + let builtin = builtin.unwrap(); + return self.execute_via_builtin(builtin).await; + } + + // Assuming we weren't requested not to do so, check if it's the name of + // a shell function. + if self.use_functions { + if let Some(func_registration) = + self.shell.funcs().get(self.command_name.as_str()).cloned() + { + return self.execute_via_function(func_registration).await; + } + } + + // If we haven't yet resolved the command name and found a builtin that's not disabled, + // then invoke it. + if let Some(builtin) = builtin { + if !builtin.disabled { + return self.execute_via_builtin(builtin).await; + } + } + + // We still haven't found a command to invoke. We'll need to look for an external command. + if !sys::fs::contains_path_separator(&self.command_name) { + // All else failed; if we were given path directories to search, try to look through + // them for a matching executable. Otherwise, use our default search logic. + let path = if let Some(path_dirs) = &self.path_dirs { + pathsearch::search_for_executable(path_dirs.iter(), self.command_name.as_str()) + .next() + } else { + self.shell + .find_first_executable_in_path_using_cache(&self.command_name) + }; + + if let Some(path) = path { + self.execute_via_external(&path) + } else { + // Bash updates $_ even when the command is not found, so mirror + // that here before reporting the error. + let last_arg = Self::take_last_arg(&self.args); + self.shell.update_last_arg_variable(last_arg); + + if let Some(post_execute) = self.post_execute { + let _ = post_execute(&mut self.shell); + } + + Err(ErrorKind::CommandNotFound(self.command_name).into()) + } + } else { + let command_name = PathBuf::from(self.command_name.clone()); + self.execute_via_external(command_name.as_path()) + } + } + + /// Extracts the owned string representation of the last argument of a + /// command, suitable for recording into `$_`. + fn take_last_arg(args: &[CommandArg]) -> Option { + args.last().map(ToString::to_string) + } + + async fn execute_via_builtin( + self, + builtin: builtins::Registration, + ) -> Result { + match self.shell { + ShellForCommand::OwnedShell { target, .. } => { + Ok(Self::execute_via_builtin_in_owned_shell( + *target, + self.params, + builtin, + self.command_name, + self.args, + )) + } + ShellForCommand::ParentShell(..) => { + self.execute_via_builtin_in_parent_shell(builtin).await + } + } + } + + fn execute_via_builtin_in_owned_shell( + mut shell: Shell, + params: ExecutionParameters, + builtin: builtins::Registration, + command_name: String, + args: Vec, + ) -> ExecutionSpawnResult { + let last_arg = Self::take_last_arg(&args); + let join_handle = tokio::task::spawn_blocking(move || { + let cmd_context = ExecutionContext { + shell: &mut shell, + command_name, + params, + }; + + let rt = tokio::runtime::Handle::current(); + let result = rt.block_on(execute_builtin_command(&builtin, cmd_context, args)); + + // Update $_ after command execution. + shell.update_last_arg_variable(last_arg); + + result + }); + + ExecutionSpawnResult::StartedTask(join_handle) + } + + async fn execute_via_builtin_in_parent_shell( + self, + builtin: builtins::Registration, + ) -> Result { + let mut shell = self.shell; + let last_arg = Self::take_last_arg(&self.args); + + let cmd_context = ExecutionContext { + shell: &mut shell, + command_name: self.command_name, + params: self.params, + }; + + let result = execute_builtin_command(&builtin, cmd_context, self.args).await; + + // Update $_ after command execution. + shell.update_last_arg_variable(last_arg); + + if let Some(post_execute) = self.post_execute { + let _ = post_execute(&mut shell); + } + + let result = result?; + + Ok(result.into()) + } + + async fn execute_via_function( + self, + func_registration: functions::Registration, + ) -> Result { + let mut shell = self.shell; + let last_arg = Self::take_last_arg(&self.args); + + let cmd_context = ExecutionContext { + shell: &mut shell, + command_name: self.command_name, + params: self.params, + }; + + // Strip the function name off args. + let result = invoke_shell_function(func_registration, cmd_context, &self.args[1..]).await; + + // $_ is reset *after* the function body runs, to the last argument of + // the invocation (or the function name itself if zero args). Any + // mutations made inside the body are overwritten — this matches bash, + // where the caller observes only the invocation's last argument. + shell.update_last_arg_variable(last_arg); + + if let Some(post_execute) = self.post_execute { + let _ = post_execute(&mut shell); + } + + result + } + + fn execute_via_external(self, path: &Path) -> Result { + let mut shell = self.shell; + let last_arg = Self::take_last_arg(&self.args); + + let cmd_context = ExecutionContext { + shell: &mut shell, + command_name: self.command_name, + params: self.params, + }; + + let resolved_path = path.to_string_lossy(); + let result = execute_external_command( + cmd_context, + resolved_path.as_ref(), + self.process_group_id, + self.argv0.as_deref(), + &self.args[1..], + ); + + // Update $_ after command execution. + shell.update_last_arg_variable(last_arg); + + if let Some(post_execute) = self.post_execute { + let _ = post_execute(&mut shell); + } + + result + } +} + +pub(crate) fn execute_external_command( + context: ExecutionContext<'_, impl extensions::ShellExtensions>, + executable_path: &str, + process_group_id: Option, + argv0_override: Option<&str>, + args: &[CommandArg], +) -> Result { + // Filter out the args; we only want strings. + let cmd_args = args + .iter() + .filter_map(|e| { + if let CommandArg::String(s) = e { + Some(s) + } else { + None + } + }) + .collect::>(); + + // Before we lose ownership of the open files, figure out if stdin will be a terminal. + let child_stdin_is_terminal = context + .try_fd(openfiles::OpenFiles::STDIN_FD) + .is_some_and(|f| f.is_terminal()); + + // Figure out if we should be setting up a new process group. + let new_pg = matches!( + context.params.process_group_policy, + ProcessGroupPolicy::NewProcessGroup + ); + + // Compose the std::process::Command that encapsulates what we want to launch. + // argv[0] defaults to context.command_name (the user-facing name of the + // command) unless the caller specified an explicit override. + let argv0 = argv0_override.unwrap_or(context.command_name.as_str()); + #[allow(unused_mut, reason = "only mutated on unix platforms")] + let mut cmd = compose_std_command( + &context, + executable_path, + argv0, + cmd_args.as_slice(), + false, /* empty environment? */ + )?; + + // Set up process group state. + if new_pg { + // Check if we'll be doing terminal control setup (which includes setsid) + if child_stdin_is_terminal && context.shell.options().external_cmd_leads_session { + // Don't set process_group(0) - setsid() in pre_exec will handle it + cmd.lead_session(); + } else { + // Normal case: create new process group in current session + cmd.process_group(0); + if child_stdin_is_terminal { + cmd.take_foreground(); + } + } + } else { + // We need to join an established process group. + if let Some(pgid) = process_group_id { + cmd.process_group(pgid); + } + } + + // When tracing is enabled, report. + tracing::debug!( + target: trace_categories::COMMANDS, + "Spawning: cmd='{} {}'", + cmd.get_program().to_string_lossy().to_string(), + cmd.get_args() + .map(|a| a.to_string_lossy().to_string()) + .join(" ") + ); + + match sys::process::spawn(cmd, context.shell.options().kill_external_commands_on_drop) { + Ok(child) => { + // Retrieve the pid. + #[expect(clippy::cast_possible_wrap)] + let pid = child.id().map(|id| id as i32); + let mut actual_pgid = process_group_id; + if let Some(pid) = &pid { + if new_pg { + actual_pgid = Some(*pid); + } + } else { + tracing::warn!("could not retrieve pid for child process"); + } + + Ok(ExecutionSpawnResult::StartedProcess( + processes::ChildProcess::new(child, pid, actual_pgid), + )) + } + Err(spawn_err) => { + if context.shell.options().interactive { + sys::terminal::move_self_to_foreground()?; + } + + if spawn_err.kind() == std::io::ErrorKind::NotFound { + if !context.shell.working_dir().exists() { + Err( + error::ErrorKind::WorkingDirMissing(context.shell.working_dir().to_owned()) + .into(), + ) + } else { + Err(error::ErrorKind::CommandNotFound(context.command_name).into()) + } + } else { + Err( + error::ErrorKind::FailedToExecuteCommand(context.command_name, spawn_err) + .into(), + ) + } + } + } +} + +async fn execute_builtin_command( + builtin: &builtins::Registration, + context: ExecutionContext<'_, SE>, + args: Vec, +) -> Result { + // In POSIX mode, special builtins that return errors are to be treated as fatal. + let mark_errors_fatal = builtin.special_builtin && context.shell.options().posix_mode; + + match (builtin.execute_func)(context, args).await { + Ok(result) => Ok(result), + Err(e) => { + // Broken pipe errors should silently return the appropriate exit code + if let Some(io_err) = e.as_io_error() { + if io_err.kind() == std::io::ErrorKind::BrokenPipe { + return Ok(ExecutionExitCode::from(io_err).into()); + } + } + + Err(if mark_errors_fatal { e.into_fatal() } else { e }) + } + } +} + +pub(crate) async fn invoke_shell_function( + function: functions::Registration, + mut context: ExecutionContext<'_, impl extensions::ShellExtensions>, + args: &[CommandArg], +) -> Result { + let ast::FunctionBody(body, redirects) = &function.definition().body; + + // Apply any redirects specified at function definition-time. + if let Some(redirects) = redirects { + for redirect in &redirects.0 { + interp::setup_redirect(context.shell, &mut context.params, redirect).await?; + } + } + + let positional_args = args.iter().map(|a| a.to_string()); + + // Note that we're going deeper. Once we do this, we need to make sure we don't bail early + // before "exiting" the function. + context.shell.enter_function( + context.command_name.as_str(), + &function, + positional_args, + &context.params, + )?; + + // A function executes within the current shell process and shares its caller's open files, + // so the parameters are passed through by shared reference rather than cloned. This prevents + // direct mutation of the caller's `ExecutionParameters` open-file table, though the function + // may still change the shell's persistent open files via builtins (e.g. `exec`). + let result = body.execute(context.shell, &context.params).await; + + // We've come back out, reflect it. + context.shell.leave_function()?; + + // Get the actual execution result from the body of the function. + let mut result = result?; + + // Handle control-flow. + match result.next_control_flow { + ExecutionControlFlow::BreakLoop { .. } | ExecutionControlFlow::ContinueLoop { .. } => { + return error::unimp("break or continue returned from function invocation"); + } + ExecutionControlFlow::ReturnFromFunctionOrScript => { + // It's now been handled. + result.next_control_flow = ExecutionControlFlow::Normal; + } + _ => {} + } + + Ok(result.into()) +} + +pub(crate) async fn invoke_command_in_subshell_and_get_output( + shell: &mut Shell, + params: &ExecutionParameters, + s: String, +) -> Result { + // Instantiate a subshell to run the command in. + let mut subshell = shell.clone(); + + // Command substitutions don't inherit errexit by default. Only inherit it when + // command_subst_inherits_errexit is enabled, otherwise disable errexit in the subshell. + if !shell.options().command_subst_inherits_errexit { + subshell.options_mut().exit_on_nonzero_command_exit = false; + } + + // Get our own set of parameters we can customize and use. + let mut params = params.clone(); + params.process_group_policy = ProcessGroupPolicy::SameProcessGroup; + + // Set up pipe so we can read the output. + let (reader, writer) = std::io::pipe()?; + params.set_fd(OpenFiles::STDOUT_FD, writer.into()); + + let mut async_reader = sys::async_pipe::AsyncPipeReader::new(reader)?; + + let cmd_join_handle = tokio::spawn(run_substitution_command(subshell, params, s)); + + let output_str = async_reader.read_to_string().await?; + + // Now observe the command's completion. + let run_result = cmd_join_handle.await?; + let cmd_result = run_result?; + + // Store the status. + shell.set_last_exit_status(cmd_result.exit_code.into()); + + // Note: $_ is naturally isolated from the parent because we cloned the + // shell to run the substitution. + + Ok(output_str) +} + +async fn run_substitution_command( + mut shell: Shell, + mut params: ExecutionParameters, + command: String, +) -> Result { + // Parse the string into a whole shell program. + let parse_result = shell.parse_string(command); + + // Check for a command that is only an input redirection ("< file"). + // If detected, emulate `cat file` to stdout and return immediately. + // If we failed to parse, then we'll fall below and handle it there. + if let Ok(program) = &parse_result { + if let Some(redir) = try_unwrap_bare_input_redir_program(program) { + interp::setup_redirect(&mut shell, &mut params, redir).await?; + std::io::copy(&mut params.stdin(&shell), &mut params.stdout(&shell))?; + return Ok(ExecutionResult::new(0)); + } + } + + // TODO(source-info): review this + let source_info = crate::SourceInfo::from("main"); + + // Handle the parse result using default shell behavior. + shell + .run_parsed_result(parse_result, &source_info, ¶ms) + .await +} + +// Detects a subshell command that consists solely of a single input redirection +// (e.g., "< file"), returning the IoRedirect when present. +fn try_unwrap_bare_input_redir_program(program: &ast::Program) -> Option<&ast::IoRedirect> { + // We're looking for exactly one complete command... + let [complete] = program.complete_commands.as_slice() else { + return None; + }; + + // ...a single list item... + let ast::CompoundList(items) = complete; + let [item] = items.as_slice() else { + return None; + }; + + // ...with a single pipeline (no && or || chaining)... + let and_or = &item.0; + if !and_or.additional.is_empty() { + return None; + } + + // ...not negated... + let pipeline = &and_or.first; + if pipeline.bang { + return None; + } + + // ...with a single command in the pipeline... + let [ast::Command::Simple(simple_cmd)] = pipeline.seq.as_slice() else { + return None; + }; + + // ...with no program word/name and no suffix... + if simple_cmd.word_or_name.is_some() || simple_cmd.suffix.is_some() { + return None; + } + + // ...and exactly one prefix containing an I/O redirect... + let prefix = simple_cmd.prefix.as_ref()?; + let [ast::CommandPrefixOrSuffixItem::IoRedirect(redir)] = prefix.0.as_slice() else { + return None; + }; + + // ...that is a file input redirection to a filename, targeting stdin. + match redir { + ast::IoRedirect::File( + fd, + ast::IoFileRedirectKind::Read, + ast::IoFileRedirectTarget::Filename(..), + ) if fd.is_none_or(|fd| fd == openfiles::OpenFiles::STDIN_FD) => Some(redir), + _ => None, + } +} diff --git a/local/recipes/shells/brush/source/brush-core/src/completion.rs b/local/recipes/shells/brush/source/brush-core/src/completion.rs new file mode 100644 index 0000000000..762be8cea1 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/completion.rs @@ -0,0 +1,1691 @@ +//! Implements programmable command completion support. + +use clap::ValueEnum; +use std::{ + borrow::Cow, + collections::HashMap, + path::{Path, PathBuf}, +}; +use strum::IntoEnumIterator; + +use crate::{ + Shell, commands, env, error, escape, expansion, extensions, interfaces, jobs, namedoptions, + patterns, + sys::{self, users}, + trace_categories, traps, + variables::{self, ShellValueLiteral}, +}; +use brush_parser::unquote_str; + +/// Type of action to take to generate completion candidates. +#[derive(Clone, Debug, ValueEnum)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum CompleteAction { + /// Complete with valid aliases. + #[clap(name = "alias")] + Alias, + /// Complete with names of array shell variables. + #[clap(name = "arrayvar")] + ArrayVar, + /// Complete with names of key bindings. + #[clap(name = "binding")] + Binding, + /// Complete with names of shell builtins. + #[clap(name = "builtin")] + Builtin, + /// Complete with names of executable commands. + #[clap(name = "command")] + Command, + /// Complete with directory names. + #[clap(name = "directory")] + Directory, + /// Complete with names of disabled shell builtins. + #[clap(name = "disabled")] + Disabled, + /// Complete with names of enabled shell builtins. + #[clap(name = "enabled")] + Enabled, + /// Complete with names of exported shell variables. + #[clap(name = "export")] + Export, + /// Complete with filenames. + #[clap(name = "file")] + File, + /// Complete with names of shell functions. + #[clap(name = "function")] + Function, + /// Complete with valid user groups. + #[clap(name = "group")] + Group, + /// Complete with names of valid shell help topics. + #[clap(name = "helptopic")] + HelpTopic, + /// Complete with the system's hostname(s). + #[clap(name = "hostname")] + HostName, + /// Complete with the command names of shell-managed jobs. + #[clap(name = "job")] + Job, + /// Complete with valid shell keywords. + #[clap(name = "keyword")] + Keyword, + /// Complete with the command names of running shell-managed jobs. + #[clap(name = "running")] + Running, + /// Complete with names of system services. + #[clap(name = "service")] + Service, + /// Complete with the names of options settable via shopt. + #[clap(name = "setopt")] + SetOpt, + /// Complete with the names of options settable via set -o. + #[clap(name = "shopt")] + ShOpt, + /// Complete with the names of trappable signals. + #[clap(name = "signal")] + Signal, + /// Complete with the command names of stopped shell-managed jobs. + #[clap(name = "stopped")] + Stopped, + /// Complete with valid usernames. + #[clap(name = "user")] + User, + /// Complete with names of shell variables. + #[clap(name = "variable")] + Variable, +} + +/// Options influencing how command completions are generated. +#[derive(Clone, Debug, Eq, Hash, PartialEq, ValueEnum)] +pub enum CompleteOption { + /// Perform rest of default completions if no completions are generated. + #[clap(name = "bashdefault")] + BashDefault, + /// Use default filename completion if no completions are generated. + #[clap(name = "default")] + Default, + /// Treat completions as directory names. + #[clap(name = "dirnames")] + DirNames, + /// Treat completions as filenames. + #[clap(name = "filenames")] + FileNames, + /// Suppress default auto-quotation of completions. + #[clap(name = "noquote")] + NoQuote, + /// Do not sort completions. + #[clap(name = "nosort")] + NoSort, + /// Do not append a trailing space to completions at the end of the input line. + #[clap(name = "nospace")] + NoSpace, + /// Also generate directory completions. + #[clap(name = "plusdirs")] + PlusDirs, +} + +/// Encapsulates the shell's programmable command completion configuration. +#[derive(Clone, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Config { + commands: HashMap, + + /// Optionally, a completion spec to be used as a default, when earlier + /// matches yield no candidates. + pub default: Option, + /// Optionally, a completion spec to be used when the command line is empty. + pub empty_line: Option, + /// Optionally, a completion spec to be used for the initial word of a command line. + pub initial_word: Option, + + /// Optionally, stores the current completion options in effect. May be mutated + /// while a completion generation is in-flight. + pub current_completion_options: Option, + + /// Fallback options to use when 'default' completions are requested (not to be + /// confused with the 'default' completion spec, nor 'bashdefault' completions). + pub fallback_options: FallbackOptions, +} + +/// Options for fallback completions. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct FallbackOptions { + /// If true, mark directory completions with a trailing slash. + pub mark_directories: bool, + /// If true, mark symlinked directory completions with a trailing slash. + pub mark_symlinked_directories: bool, +} + +impl Default for FallbackOptions { + fn default() -> Self { + Self { + mark_directories: true, + mark_symlinked_directories: false, + } + } +} + +/// Options for generating completions. +#[derive(Clone, Debug, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct GenerationOptions { + // + // Options + /// Perform rest of default completions if no completions are generated. + pub bash_default: bool, + /// Use default readline-style filename completion if no completions are generated. + pub default: bool, + /// Treat completions as directory names. + pub dir_names: bool, + /// Treat completions as filenames. + pub file_names: bool, + /// Do not add usual quoting for completions. + pub no_quote: bool, + /// Do not sort completions. + pub no_sort: bool, + /// Do not append typical space to a completion at the end of the input line. + pub no_space: bool, + /// Also complete with directory names. + pub plus_dirs: bool, +} + +/// Encapsulates a command completion specification; provides policy for how to +/// generate completions for a given input. +#[derive(Clone, Debug, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Spec { + // + // Options + /// Options to use for completion. + pub options: GenerationOptions, + + // + // Generators + /// Actions to take to generate completions. + pub actions: Vec, + /// Optionally, a glob pattern whose expansion will be used as completions. + pub glob_pattern: Option, + /// Optionally, a list of words to use as completions. + pub word_list: Option, + /// Optionally, the name of a shell function to invoke to generate completions. + pub function_name: Option, + /// Optionally, the name of a command to execute to generate completions. + pub command: Option, + + // + // Filters + /// Optionally, a pattern to filter completions. + pub filter_pattern: Option, + /// If true, completion candidates matching `filter_pattern` are removed; + /// otherwise, those not matching it are removed. + pub filter_pattern_excludes: bool, + + // + // Transformers + /// Optionally, provides a prefix to be prepended to all completion candidates. + pub prefix: Option, + /// Optionally, provides a suffix to be prepended to all completion candidates. + pub suffix: Option, +} + +/// Describes what triggered the completion process. +#[derive(Clone, Copy, Debug, Default)] +pub enum CompletionTrigger { + /// Interactive completion triggered by Tab key (normal completion). + #[default] + InteractiveComplete, + /// Programmatic generation via the `compgen` builtin. + Programmatic, +} + +impl CompletionTrigger { + /// Returns the `COMP_TYPE` value for this trigger. + pub const fn comp_type(self) -> i32 { + match self { + Self::InteractiveComplete => 9, // TAB = normal completion + Self::Programmatic => 0, + } + } + + /// Returns the `COMP_KEY` value for this trigger. + pub const fn comp_key(self) -> i32 { + match self { + Self::InteractiveComplete => 9, // TAB key + Self::Programmatic => 0, + } + } +} + +/// Encapsulates context used during completion generation. +#[derive(Debug)] +pub struct Context<'a> { + /// The token to complete. + pub token_to_complete: &'a str, + + /// If available, the name of the command being invoked. + pub command_name: Option<&'a str>, + /// If there was one, the token preceding the one being completed. + pub preceding_token: Option<&'a str>, + + /// The 0-based index of the token to complete. + pub token_index: usize, + + /// The input line. + pub input_line: &'a str, + /// The 0-based index of the cursor in the input line. + pub cursor_index: usize, + /// The tokens in the input line. + pub tokens: &'a [&'a CompletionToken<'a>], + + /// What triggered the completion. + pub trigger: CompletionTrigger, +} + +impl Spec { + /// Generates completion candidates using this specification. + /// + /// # Arguments + /// + /// * `shell` - The shell instance to use for completion generation. + /// * `context` - The context in which completion is being generated. + #[expect(clippy::too_many_lines)] + pub async fn get_completions( + &self, + shell: &mut Shell, + context: &Context<'_>, + ) -> Result { + // Store the current options in the shell; this is needed since the compopt + // built-in has the ability of modifying the options for an in-flight + // completion process. + shell.completion_config_mut().current_completion_options = Some(self.options.clone()); + + // Generate completions based on any provided actions (and on words). + let mut candidates = self.generate_action_completions(shell, context).await?; + if let Some(word_list) = &self.word_list { + let params = shell.default_exec_params(); + // Per POSIX / bash docs, -W word list is subject to shell expansion + // and field splitting but NOT pathname expansion (globbing). + let options = crate::expansion::ExpanderOptions { + pathname_expand: false, + ..Default::default() + }; + let words = crate::expansion::full_expand_and_split_word_with_options( + shell, ¶ms, word_list, &options, + ) + .await?; + for word in words { + if word.starts_with(context.token_to_complete) { + candidates.push(word); + } + } + } + + if let Some(glob_pattern) = &self.glob_pattern { + let pattern = patterns::Pattern::from(glob_pattern.as_str()) + .set_extended_globbing(shell.options().extended_globbing) + .set_case_insensitive(shell.options().case_insensitive_pathname_expansion); + + let expansions = pattern + .expand( + shell.working_dir(), + Some(&patterns::Pattern::accept_all_expand_filter), + &patterns::FilenameExpansionOptions::default(), + )? + .into_paths(); + + for expansion in expansions { + candidates.push(expansion); + } + } + if let Some(function_name) = &self.function_name { + let call_result = self + .call_completion_function(shell, function_name.as_str(), context) + .await?; + + match call_result { + Answer::RestartCompletionProcess => return Ok(call_result), + Answer::Candidates(mut new_candidates, _options) => { + candidates.append(&mut new_candidates); + } + } + } + if let Some(command) = &self.command { + let mut new_candidates = self + .call_completion_command(shell, command.as_str(), context) + .await?; + candidates.append(&mut new_candidates); + } + + // Apply filter pattern, if present. Anything the filter selects gets removed. + if let Some(filter_pattern) = &self.filter_pattern + && !filter_pattern.is_empty() + { + let mut updated = Vec::new(); + + for candidate in candidates { + let matches = completion_filter_pattern_matches( + filter_pattern.as_str(), + candidate.as_str(), + context.token_to_complete, + shell, + )?; + + if self.filter_pattern_excludes != matches { + updated.push(candidate); + } + } + + candidates = updated; + } + + // Add prefix and/or suffix, if present. + if self.prefix.is_some() || self.suffix.is_some() { + let empty = String::new(); + let prefix = self.prefix.as_ref().unwrap_or(&empty); + let suffix = self.suffix.as_ref().unwrap_or(&empty); + + let mut updated = Vec::with_capacity(candidates.len() * (prefix.len() + suffix.len())); + for candidate in candidates { + updated.push(std::format!("{prefix}{candidate}{suffix}")); + } + + candidates = updated; + } + + // + // Now apply options + // + + let options = if let Some(options) = &shell.completion_config().current_completion_options { + options + } else { + &self.options + }; + + let mut processing_options = ProcessingOptions { + treat_as_filenames: options.file_names, + no_autoquote_filenames: options.no_quote, + no_trailing_space_at_end_of_line: options.no_space, + }; + + if options.plus_dirs || options.dir_names { + // Also add dir name completion. + let mut dir_candidates = get_file_completions( + shell, + context.token_to_complete, + /* must_be_dir */ true, + ) + .await; + candidates.append(&mut dir_candidates); + } + + // If we still have no candidates, and bashdefault completions were requested, then generate + // those. + if candidates.is_empty() && options.bash_default { + // TODO(completions): it's not clear what default "bash" completions means. From basic + // testing, this doesn't seem to include basic file and directory name + // completion. + tracing::debug!(target: trace_categories::COMPLETION, "unimplemented: complete -o bashdefault"); + } + + // If we still have no candidates, and default completions were requested, then generate + // those. + if candidates.is_empty() && options.default { + // N.B. We approximate "default" readline completion behavior by getting file and + // dir completions. + let must_be_dir = options.dir_names; + + let mut default_candidates = + get_file_completions(shell, context.token_to_complete, must_be_dir).await; + candidates.append(&mut default_candidates); + + if shell.completion_config().fallback_options.mark_directories { + processing_options.treat_as_filenames = true; + } + } + + // Sort, unless blocked by options. + if !self.options.no_sort { + candidates.sort(); + } + + Ok(Answer::Candidates(candidates, processing_options)) + } + + #[expect(clippy::too_many_lines)] + async fn generate_action_completions( + &self, + shell: &Shell, + context: &Context<'_>, + ) -> Result, error::Error> { + let mut candidates = Vec::new(); + + let token = context.token_to_complete; + + for action in &self.actions { + match action { + CompleteAction::Alias => { + for name in shell.aliases().keys() { + if name.starts_with(token) { + candidates.push(name.clone()); + } + } + } + CompleteAction::ArrayVar => { + for (name, var) in shell.env().iter() { + if var.value().is_array() && name.starts_with(token) { + candidates.push(name.to_owned()); + } + } + } + CompleteAction::Binding => { + for input_func in interfaces::InputFunction::iter() { + let name: &'static str = input_func.into(); + if name.starts_with(token) { + candidates.push(name.to_string()); + } + } + } + CompleteAction::Builtin => { + for name in shell.builtins().keys() { + if name.starts_with(token) { + candidates.push(name.to_owned()); + } + } + } + CompleteAction::Command => { + let command_completions = + get_external_command_completions(shell, context.token_to_complete); + candidates.extend(command_completions); + for name in shell.builtins().keys() { + if name.starts_with(token) { + candidates.push(name.to_owned()); + } + } + for keyword in shell.get_keywords() { + if keyword.starts_with(token) { + candidates.push(keyword.to_string()); + } + } + for (name, _) in shell.funcs().iter() { + candidates.push(name.to_owned()); + } + } + CompleteAction::Directory => { + let mut file_completions = + get_file_completions(shell, context.token_to_complete, true).await; + candidates.append(&mut file_completions); + } + CompleteAction::Disabled => { + for (name, registration) in shell.builtins() { + if registration.disabled && name.starts_with(token) { + candidates.push(name.to_owned()); + } + } + } + CompleteAction::Enabled => { + for (name, registration) in shell.builtins() { + if !registration.disabled && name.starts_with(token) { + candidates.push(name.to_owned()); + } + } + } + CompleteAction::Export => { + for (key, value) in shell.env().iter() { + if value.is_exported() && key.starts_with(token) { + candidates.push(key.to_owned()); + } + } + } + CompleteAction::File => { + let mut file_completions = + get_file_completions(shell, context.token_to_complete, false).await; + candidates.append(&mut file_completions); + } + CompleteAction::Function => { + for (name, _) in shell.funcs().iter() { + candidates.push(name.to_owned()); + } + } + CompleteAction::Group => { + for group_name in users::get_all_groups()? { + if group_name.starts_with(token) { + candidates.push(group_name); + } + } + } + CompleteAction::HelpTopic => { + // For now, we only have help topics for built-in commands. + for name in shell.builtins().keys() { + if name.starts_with(token) { + candidates.push(name.to_owned()); + } + } + } + CompleteAction::HostName => { + // N.B. We only retrieve one hostname. + if let Ok(name) = sys::network::get_hostname() { + let name = name.to_string_lossy(); + if name.starts_with(token) { + candidates.push(name.to_string()); + } + } + } + CompleteAction::Job => { + for job in &shell.jobs().jobs { + let command_name = job.command_name(); + if command_name.starts_with(token) { + candidates.push(command_name.to_owned()); + } + } + } + CompleteAction::Keyword => { + for keyword in shell.get_keywords() { + if keyword.starts_with(token) { + candidates.push(keyword.to_string()); + } + } + } + CompleteAction::Running => { + for job in &shell.jobs().jobs { + if matches!(job.state, jobs::JobState::Running) { + let command_name = job.command_name(); + if command_name.starts_with(token) { + candidates.push(command_name.to_owned()); + } + } + } + } + CompleteAction::Service => { + tracing::debug!(target: trace_categories::COMPLETION, "unimplemented: complete -A service"); + } + CompleteAction::SetOpt => { + for option in namedoptions::options(namedoptions::ShellOptionKind::SetO).iter() + { + if option.name.starts_with(token) { + candidates.push(option.name.to_owned()); + } + } + } + CompleteAction::ShOpt => { + for option in namedoptions::options(namedoptions::ShellOptionKind::Shopt).iter() + { + if option.name.starts_with(token) { + candidates.push(option.name.to_owned()); + } + } + } + CompleteAction::Signal => { + for signal in traps::TrapSignal::iterator() { + if signal.as_str().starts_with(token) { + candidates.push(signal.as_str().to_string()); + } + } + } + CompleteAction::Stopped => { + for job in &shell.jobs().jobs { + if matches!(job.state, jobs::JobState::Stopped) { + let command_name = job.command_name(); + if command_name.starts_with(token) { + candidates.push(job.command_name().to_owned()); + } + } + } + } + CompleteAction::User => { + for user_name in users::get_all_users()? { + if user_name.starts_with(token) { + candidates.push(user_name); + } + } + } + CompleteAction::Variable => { + for (key, _) in shell.env().iter() { + if key.starts_with(token) { + candidates.push(key.to_owned()); + } + } + } + } + } + + Ok(candidates) + } + + async fn call_completion_command( + &self, + shell: &Shell, + command_name: &str, + context: &Context<'_>, + ) -> Result, error::Error> { + // Move to a subshell so we can start filling out variables. + let mut shell = shell.clone(); + + let vars_and_values: [(&str, ShellValueLiteral); 4] = [ + ("COMP_LINE", context.input_line.into()), + ("COMP_POINT", context.cursor_index.to_string().into()), + ("COMP_KEY", context.trigger.comp_key().to_string().into()), + ("COMP_TYPE", context.trigger.comp_type().to_string().into()), + ]; + + // Fill out variables. + for (var, value) in vars_and_values { + shell.env_mut().update_or_add( + var, + value, + |v| { + v.export(); + Ok(()) + }, + env::EnvironmentLookup::Anywhere, + env::EnvironmentScope::Global, + )?; + } + + // Compute args. + let mut args = vec![ + context.command_name.unwrap_or(""), + context.token_to_complete, + ]; + if let Some(preceding_token) = context.preceding_token { + args.push(preceding_token); + } + + // Compose the full command line. + let mut command_line = command_name.to_owned(); + for arg in args { + command_line.push(' '); + + let escaped_arg = escape::quote_if_needed(arg, escape::QuoteMode::SingleQuote); + command_line.push_str(escaped_arg.as_ref()); + } + + // Run the command. + let params = shell.default_exec_params(); + let output = + commands::invoke_command_in_subshell_and_get_output(&mut shell, ¶ms, command_line) + .await?; + + // Split results. + let candidates = output.lines().map(str::to_owned).collect(); + + Ok(candidates) + } + + async fn call_completion_function( + &self, + shell: &mut Shell, + function_name: &str, + context: &Context<'_>, + ) -> Result { + // TODO(completions): Don't pollute the persistent environment with these? + let vars_and_values: [(&str, ShellValueLiteral); 6] = [ + ("COMP_LINE", context.input_line.into()), + ("COMP_POINT", context.cursor_index.to_string().into()), + ("COMP_KEY", context.trigger.comp_key().to_string().into()), + ("COMP_TYPE", context.trigger.comp_type().to_string().into()), + ( + "COMP_WORDS", + context + .tokens + .iter() + .map(|t| t.text) + .collect::>() + .into(), + ), + ("COMP_CWORD", context.token_index.to_string().into()), + ]; + + tracing::debug!(target: trace_categories::COMPLETION, "[calling completion func '{function_name}']: {}", + vars_and_values.iter().map(|(k, v)| std::format!("{k}={v}")).collect::>().join(" ")); + + let mut vars_to_remove = Vec::with_capacity(vars_and_values.len()); + for (var, value) in vars_and_values { + shell.env_mut().update_or_add( + var, + value, + |_| Ok(()), + env::EnvironmentLookup::Anywhere, + env::EnvironmentScope::Global, + )?; + + vars_to_remove.push(var); + } + + let mut args = vec![ + context.command_name.unwrap_or(""), + context.token_to_complete, + ]; + if let Some(preceding_token) = context.preceding_token { + args.push(preceding_token); + } + + // Suppress trap delivery during completion function invocation. + // N.B. We use manual acquire/release rather than an RAII guard because an + // RAII guard would need to hold `&mut Shell`, preventing the mutable borrow + // required by `invoke_function()`. This is safe because `invoke_result` is + // captured into a variable (never early-returned with `?`), so + // `release_trap_delivery_block()` always runs. + shell.acquire_trap_delivery_block(); + + let params = shell.default_exec_params(); + let invoke_result = shell + .invoke_function(function_name, args.iter(), params) + .await; + + tracing::debug!(target: trace_categories::COMPLETION, "[completion function '{function_name}' returned: {invoke_result:?}]"); + + shell.release_trap_delivery_block(); + + // Make a best-effort attempt to unset the temporary variables. + for var_name in vars_to_remove { + let _ = shell.env_mut().unset(var_name); + } + + let result = invoke_result.unwrap_or_else(|e| { + tracing::warn!(target: trace_categories::COMPLETION, "error while running completion function '{function_name}': {e}"); + 1 // Report back a non-zero exit code. + }); + + // When the function returns the special value 124, then it's a request + // for us to restart the completion process. + if result == 124 { + Ok(Answer::RestartCompletionProcess) + } else { + if let Some(reply) = shell.env_mut().unset("COMPREPLY")? { + tracing::debug!(target: trace_categories::COMPLETION, "[completion function yielded: {reply:?}]"); + + match reply.value() { + variables::ShellValue::IndexedArray(values) => { + return Ok(Answer::Candidates( + values.values().map(|v| v.to_owned()).collect(), + ProcessingOptions::default(), + )); + } + variables::ShellValue::String(s) => { + let candidates = vec![s.to_owned()]; + return Ok(Answer::Candidates(candidates, ProcessingOptions::default())); + } + _ => (), + } + } + + Ok(Answer::Candidates(Vec::new(), ProcessingOptions::default())) + } + } +} + +/// Represents a set of generated command completions. +#[derive(Debug, Default)] +pub struct Completions { + /// The index in the input line where the completions should be inserted. Represented + /// as a byte offset into the input line; must be at a clean character boundary. + pub insertion_index: usize, + /// The number of elements in the input line that should be removed before insertion. + /// Represented as a byte count; must capture an exact character boundary. + pub delete_count: usize, + /// The ordered set of completions. + pub candidates: Vec, + /// Options for processing the candidates. + pub options: ProcessingOptions, +} + +/// Options governing how command completion candidates are processed after being generated. +#[derive(Debug)] +pub struct ProcessingOptions { + /// Treat completions as file names. + pub treat_as_filenames: bool, + /// Don't auto-quote completions that are file names. + pub no_autoquote_filenames: bool, + /// Don't append a trailing space to completions at the end of the input line. + pub no_trailing_space_at_end_of_line: bool, +} + +/// Represents a token in the input line being completed. +#[derive(Debug, Clone, Copy)] +pub struct CompletionToken<'a> { + /// The text of the token. + pub text: &'a str, + /// The start of the token, expressed as a byte offset into the input line. + pub start: usize, +} + +impl CompletionToken<'_> { + /// Returns the length of the token, expressed as a byte count. + pub const fn length(&self) -> usize { + self.text.len() + } + + /// Returns the end of the token, expressed as a byte offset into the input line. + pub const fn end(&self) -> usize { + self.start + self.length() + } +} + +impl Default for ProcessingOptions { + fn default() -> Self { + Self { + treat_as_filenames: true, + no_autoquote_filenames: false, + no_trailing_space_at_end_of_line: false, + } + } +} + +/// Encapsulates a completion answer. +pub enum Answer { + /// The completion process generated a set of candidates along with options + /// controlling how to process them. + Candidates(Vec, ProcessingOptions), + /// The completion process needs to be restarted. + RestartCompletionProcess, +} + +const EMPTY_COMMAND: &str = "_EmptycmD_"; +const DEFAULT_COMMAND: &str = "_DefaultCmD_"; +const INITIAL_WORD: &str = "_InitialWorD_"; + +impl Config { + /// Removes all registered completion specs. + pub fn clear(&mut self) { + self.commands.clear(); + self.empty_line = None; + self.default = None; + self.initial_word = None; + } + + /// Ensures the named completion spec is no longer registered; returns whether a + /// removal operation was required. + /// + /// # Arguments + /// + /// * `name` - The name of the completion spec to remove. + pub fn remove(&mut self, name: &str) -> bool { + match name { + EMPTY_COMMAND => { + let result = self.empty_line.is_some(); + self.empty_line = None; + result + } + DEFAULT_COMMAND => { + let result = self.default.is_some(); + self.default = None; + result + } + INITIAL_WORD => { + let result = self.initial_word.is_some(); + self.initial_word = None; + result + } + _ => self.commands.remove(name).is_some(), + } + } + + /// Returns an iterator over the completion specs. + pub fn iter(&self) -> impl Iterator { + self.commands.iter() + } + + /// If present, returns the completion spec for the command of the given name. + /// + /// # Arguments + /// + /// * `name` - The name of the command. + pub fn get(&self, name: &str) -> Option<&Spec> { + match name { + EMPTY_COMMAND => self.empty_line.as_ref(), + DEFAULT_COMMAND => self.default.as_ref(), + INITIAL_WORD => self.initial_word.as_ref(), + _ => self.commands.get(name), + } + } + + /// If present, sets the provided completion spec to be associated with the + /// command of the given name. + /// + /// # Arguments + /// + /// * `name` - The name of the command. + /// * `spec` - The completion spec to associate with the command. + pub fn set(&mut self, name: &str, spec: Spec) { + match name { + EMPTY_COMMAND => { + self.empty_line = Some(spec); + } + DEFAULT_COMMAND => { + self.default = Some(spec); + } + INITIAL_WORD => { + self.initial_word = Some(spec); + } + _ => { + self.commands.insert(name.to_owned(), spec); + } + } + } + + /// Returns a mutable reference to the completion spec for the command of the + /// given name; if the command already was associated with a spec, returns + /// a reference to that existing spec. Otherwise registers a new default + /// spec and returns a mutable reference to it. + /// + /// # Arguments + /// + /// * `name` - The name of the command. + #[allow( + clippy::missing_panics_doc, + clippy::unwrap_used, + reason = "these unwrap calls should not fail" + )] + pub fn get_or_add_mut(&mut self, name: &str) -> &mut Spec { + match name { + EMPTY_COMMAND => { + if self.empty_line.is_none() { + self.empty_line = Some(Spec::default()); + } + self.empty_line.as_mut().unwrap() + } + DEFAULT_COMMAND => { + if self.default.is_none() { + self.default = Some(Spec::default()); + } + self.default.as_mut().unwrap() + } + INITIAL_WORD => { + if self.initial_word.is_none() { + self.initial_word = Some(Spec::default()); + } + self.initial_word.as_mut().unwrap() + } + _ => self.commands.entry(name.to_owned()).or_default(), + } + } + + /// Generates completions for the given input line and cursor position. + /// + /// # Arguments + /// + /// * `shell` - The shell instance to use for completion generation. + /// * `input` - The input line for which completions are being generated. + /// * `position` - The 0-based index of the cursor in the input line. + #[expect(clippy::string_slice)] + pub async fn get_completions( + &self, + shell: &mut Shell, + input: &str, + position: usize, + ) -> Result { + const MAX_RESTARTS: u32 = 10; + + // Make a best-effort attempt to tokenize. + let tokens = Self::tokenize_input_for_completion(shell, input); + + let cursor = position; + let mut preceding_token = None; + let mut completion_prefix = ""; + let mut insertion_index = cursor; + let mut completion_token_index = tokens.len(); + + // Copy a set of references to the tokens; we will adjust this list as + // we find we need to insert an empty token. + let mut adjusted_tokens: Vec<&CompletionToken<'_>> = tokens.iter().collect(); + + // Try to find which token we are in. + for (i, token) in tokens.iter().enumerate() { + // If the cursor is before the start of the token, then it's between + // this token and the one that preceded it (or it's before the first + // token if this is the first token). + if cursor < token.start { + // TODO(completions): Should insert an empty token here; the position looks to have + // been between this token and the preceding one. + completion_token_index = i; + break; + } + // If the cursor is anywhere from the first char of the token up to + // (and including) the first char after the token, then this we need + // to generate completions to replace/update this token. We'll pay + // attention to the position to figure out the prefix that we should + // be completing. + else if cursor >= token.start && cursor <= token.end() { + // Update insertion index. + insertion_index = token.start; + + // Update prefix. + let offset_into_token = cursor - insertion_index; + let token_str = token.text; + completion_prefix = &token_str[..offset_into_token]; + + // Update token index. + completion_token_index = i; + + break; + } + + // Otherwise, we need to keep looking. Update what we think the + // preceding token may be. + preceding_token = Some(token); + } + + // If the position is after the last token, then we need to insert an empty + // token for the new token to be generated. + let empty_token = CompletionToken { + text: "", + start: input.len(), + }; + if completion_token_index == tokens.len() { + adjusted_tokens.push(&empty_token); + } + + // Get the completions. + let mut result = Answer::RestartCompletionProcess; + let mut restart_count = 0; + while matches!(result, Answer::RestartCompletionProcess) { + if restart_count > MAX_RESTARTS { + tracing::warn!("possible infinite loop detected in completion process"); + break; + } + + let completion_context = Context { + token_to_complete: completion_prefix, + preceding_token: preceding_token.map(|t| t.text), + command_name: adjusted_tokens.first().map(|token| token.text), + input_line: input, + token_index: completion_token_index, + tokens: adjusted_tokens.as_slice(), + cursor_index: position, + trigger: CompletionTrigger::InteractiveComplete, + }; + + result = self + .get_completions_for_token(shell, completion_context) + .await; + + restart_count += 1; + } + + match result { + Answer::Candidates(candidates, options) => Ok(Completions { + insertion_index, + delete_count: completion_prefix.len(), + candidates, + options, + }), + Answer::RestartCompletionProcess => Ok(Completions { + insertion_index, + delete_count: 0, + candidates: Vec::new(), + options: ProcessingOptions::default(), + }), + } + } + + fn tokenize_input_for_completion<'a>( + shell: &Shell, + input: &'a str, + ) -> Vec> { + const FALLBACK: &str = " \t\n\"\'@><=;|&(:"; + + let delimiter_str = shell + .env_str("COMP_WORDBREAKS") + .unwrap_or_else(|| FALLBACK.into()); + + let delimiters: Vec<_> = delimiter_str.chars().collect(); + + simple_tokenize_by_delimiters(input, delimiters.as_slice()) + } + + async fn get_completions_for_token( + &self, + shell: &mut Shell, + context: Context<'_>, + ) -> Answer { + // See if we can find a completion spec matching the current command. + let mut found_spec: Option<&Spec> = None; + + if let Some(command_name) = context.command_name { + if context.token_index == 0 { + if let Some(spec) = &self.initial_word { + found_spec = Some(spec); + } + } else { + if let Some(spec) = shell.completion_config().commands.get(command_name) { + found_spec = Some(spec); + } else if let Some(file_name) = PathBuf::from(command_name).file_name() { + if let Some(spec) = shell + .completion_config() + .commands + .get(&file_name.to_string_lossy().to_string()) + { + found_spec = Some(spec); + } + } + + if found_spec.is_none() { + if let Some(spec) = &self.default { + found_spec = Some(spec); + } + } + } + } else { + if let Some(spec) = &self.empty_line { + found_spec = Some(spec); + } + } + + // Try to generate completions. + if let Some(spec) = found_spec { + spec.to_owned() + .get_completions(shell, &context) + .await + .unwrap_or_else(|_err| Answer::Candidates(Vec::new(), ProcessingOptions::default())) + } else { + // If we didn't find a spec, then fall back to basic completion. + get_completions_using_basic_lookup(shell, &context).await + } + } +} + +async fn get_file_completions( + shell: &Shell, + token_to_complete: &str, + must_be_dir: bool, +) -> Vec { + // Basic-expand the token-to-be-completed; it won't have been expanded to this point. + let mut throwaway_shell = shell.clone(); + let params = throwaway_shell.default_exec_params(); + let options = expansion::ExpanderOptions { + execute_command_substitutions: false, + ..Default::default() + }; + let expanded_token = expansion::basic_expand_word_with_options( + &mut throwaway_shell, + ¶ms, + &unquote_str(token_to_complete), + &options, + ) + .await + .unwrap_or_else(|_err| token_to_complete.to_owned()); + + // Normalize path separators before building the glob pattern, because backslash + // is the escape character in glob syntax and must not be confused with a Windows + // path separator. + let expanded_token = sys::fs::normalize_path_separators(&expanded_token).into_owned(); + + let glob = std::format!("{expanded_token}*"); + + let path_filter = |path: &Path| !must_be_dir || shell.absolute_path(path).is_dir(); + + let pattern = patterns::Pattern::from(glob) + .set_extended_globbing(shell.options().extended_globbing) + .set_case_insensitive(shell.options().case_insensitive_pathname_expansion); + + let mut completions: Vec = pattern + .expand( + shell.working_dir(), + Some(&path_filter), + &patterns::FilenameExpansionOptions::default(), + ) + .unwrap_or_default() + .into_paths() + .into_iter() + .map(|p| match sys::fs::normalize_path_separators(&p) { + std::borrow::Cow::Borrowed(_) => p, + std::borrow::Cow::Owned(normalized) => normalized, + }) + .collect(); + + match expanded_token.as_str() { + "." => { + completions.push(".".into()); + completions.push("..".into()); + } + ".." => { + completions.push("..".into()); + } + _ => {} + } + + completions.sort(); + completions.dedup(); + completions +} + +fn get_external_command_completions( + shell: &Shell, + prefix: &str, +) -> impl Iterator { + shell + .find_executables_in_path_with_prefix( + prefix, + shell.options().case_insensitive_pathname_expansion, + ) + .filter_map(|path| { + path.file_name() + .map(|name| name.to_string_lossy().into_owned()) + }) +} + +/// Attempts to complete a variable name from the given token. +/// Returns `Some(Answer)` if the token looks like a variable reference being typed, +/// or `None` if file/command completion should be used instead. +/// +/// # Arguments +/// +/// * `shell` - The shell instance to use for variable lookup. +/// * `token` - The token being completed. May be empty. +fn try_get_variable_completions( + shell: &Shell, + token: &str, +) -> Option { + // Determine if this is a braced or unbraced variable reference + let (var_prefix, use_braces) = if let Some(prefix) = token.strip_prefix("${") { + // For braced: only complete if brace isn't closed yet + if prefix.contains('}') { + return None; + } + (prefix, true) + } else { + let prefix = token.strip_prefix('$')?; + (prefix, false) + }; + + // If there's a path separator, this is a path like $HOME/foo, not a variable to complete + if sys::fs::contains_path_separator(var_prefix) { + return None; + } + + // Find matching variables + let mut candidates: Vec = shell + .env() + .iter() + .filter(|(key, _)| key.starts_with(var_prefix)) + .map(|(key, _)| { + if use_braces { + format!("${{{key}}}") + } else { + format!("${key}") + } + }) + .collect(); + candidates.sort(); + + // Variable completions should not be treated as filenames (no escaping needed) + let options = ProcessingOptions { + treat_as_filenames: false, + ..ProcessingOptions::default() + }; + + Some(Answer::Candidates(candidates, options)) +} + +/// Adds command-position completions to candidates. +/// This includes external commands, builtins, functions, aliases, and keywords. +fn add_command_completions( + shell: &Shell, + prefix: &str, + candidates: &mut Vec, +) { + // Add external commands. + let command_completions = get_external_command_completions(shell, prefix); + candidates.extend(command_completions); + + // Add built-in commands. + for (name, registration) in shell.builtins() { + if !registration.disabled && name.starts_with(prefix) { + candidates.push(name.to_owned()); + } + } + + // Add shell functions. + for (name, _) in shell.funcs().iter() { + if name.starts_with(prefix) { + candidates.push(name.to_owned()); + } + } + + // Add aliases. + for name in shell.aliases().keys() { + if name.starts_with(prefix) { + candidates.push(name.to_owned()); + } + } + + // Add keywords. + for keyword in shell.get_keywords() { + if keyword.starts_with(prefix) { + candidates.push(keyword.to_string()); + } + } +} + +async fn get_completions_using_basic_lookup( + shell: &Shell, + context: &Context<'_>, +) -> Answer { + let token = context.token_to_complete; + + // Try variable completion first (e.g., $HO -> $HOME, ${HO -> ${HOME}) + if let Some(answer) = try_get_variable_completions(shell, token) { + return answer; + } + + // File completions + let mut candidates = get_file_completions(shell, token, false).await; + + // If this appears to be the command token (and if there's *some* prefix without + // a path separator) then also consider whether we should search the path for + // completions too. + // TODO(completions): Do a better job than just checking if index == 0. + let is_command_position = + context.token_index == 0 && !token.is_empty() && !sys::fs::contains_path_separator(token); + + if is_command_position { + add_command_completions(shell, token, &mut candidates); + candidates.sort(); + } + + Answer::Candidates(candidates, ProcessingOptions::default()) +} + +/// Tokenizes input by splitting on delimiter characters. Words (non-delimiter sequences) +/// are emitted as tokens. Consecutive non-whitespace delimiters are grouped into a single +/// token. Whitespace delimiters separate tokens but are not emitted themselves. +#[allow(clippy::string_slice, reason = "used indices come from char_indices")] +fn simple_tokenize_by_delimiters<'a>( + input: &'a str, + delimiters: &[char], +) -> Vec> { + let mut tokens = vec![]; + let mut word_start = None; + let mut word_is_delimiters = false; + let mut quote_char: Option = None; + let mut escaped = false; + + for (i, c) in input.char_indices() { + let mut is_active_delimiter = false; + if escaped { + escaped = false; + } else if let Some(q) = quote_char { + if c == '\\' && q == '"' { + // an escape in double-quoted string works as an escape. + escaped = true; + } else if c == q { + // end of quote. + quote_char = None; + } + } else { + if c == '\\' { + escaped = true; + } else if word_start.is_none() && (c == '\'' || c == '\"') { + // start a new quote. + quote_char = Some(c); + } else { + is_active_delimiter = delimiters.contains(&c); + } + } + + if is_active_delimiter { + // If we were building a regular word and this is a delimiter, then finish it. + // Similarly, if this is a whitespace delimiter, finish any delimiter sequence. + if let Some(start) = word_start { + if !word_is_delimiters || c.is_ascii_whitespace() { + tokens.push(CompletionToken { + text: &input[start..i], + start, + }); + word_start = None; + word_is_delimiters = false; + } + + if !c.is_ascii_whitespace() { + if word_start.is_none() { + word_start = Some(i); + word_is_delimiters = true; + } + } + } else if !c.is_ascii_whitespace() { + // Non-whitespace delimiter: start or continue delimiter sequence + if word_start.is_none() { + word_start = Some(i); + word_is_delimiters = true; + } + } + } else { + // Regular character (not a delimiter). Finish any delimiter sequence. + if word_is_delimiters { + if let Some(start) = word_start { + tokens.push(CompletionToken { + text: &input[start..i], + start, + }); + word_start = None; + word_is_delimiters = false; + } + } + + // Start or continue a word + if word_start.is_none() { + word_start = Some(i); + } + } + } + + // Add any remaining delimiter sequence + if let Some(start) = word_start { + tokens.push(CompletionToken { + text: &input[start..], + start, + }); + } + + tokens +} + +fn completion_filter_pattern_matches( + pattern: &str, + candidate: &str, + token_being_completed: &str, + shell: &Shell, +) -> Result { + let pattern = replace_unescaped_ampersands(pattern, token_being_completed); + + // + // TODO(completions): Replace unescaped '&' with the word being completed. + // + + let pattern = patterns::Pattern::from(pattern.as_ref()) + .set_extended_globbing(shell.options().extended_globbing) + .set_case_insensitive(shell.options().case_insensitive_pathname_expansion); + + let matches = pattern.exactly_matches(candidate)?; + + Ok(matches) +} + +fn replace_unescaped_ampersands<'a>(pattern: &'a str, replacement: &str) -> Cow<'a, str> { + let mut in_escape = false; + let mut insertion_points = vec![]; + + for (i, c) in pattern.char_indices() { + if !in_escape && c == '&' { + insertion_points.push(i); + } + in_escape = !in_escape && c == '\\'; + } + + if insertion_points.is_empty() { + return pattern.into(); + } + + let mut result = pattern.to_owned(); + for i in insertion_points.iter().rev() { + result.replace_range(*i..=*i, replacement); + } + + result.into() +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_matches; + + #[test] + #[allow(clippy::too_many_lines)] + fn completion_tokenization() { + assert_matches!( + simple_tokenize_by_delimiters("one two", &[' ']).as_slice(), + [ + CompletionToken { + text: "one", + start: 0, + }, + CompletionToken { + text: "two", + start: 4, + } + ] + ); + + assert_matches!( + simple_tokenize_by_delimiters("one \t two", &[' ', '\t']).as_slice(), + [ + CompletionToken { + text: "one", + start: 0, + }, + CompletionToken { + text: "two", + start: 6, + } + ] + ); + + assert_matches!(simple_tokenize_by_delimiters(" ", &[' ']).as_slice(), []); + + assert_matches!( + simple_tokenize_by_delimiters(":", &[':']).as_slice(), + [CompletionToken { + text: ":", + start: 0, + }] + ); + + assert_matches!( + simple_tokenize_by_delimiters("a:::b", &[':', ' ']).as_slice(), + [ + CompletionToken { + text: "a", + start: 0, + }, + CompletionToken { + text: ":::", + start: 1, + }, + CompletionToken { + text: "b", + start: 4, + } + ] + ); + + assert_matches!( + simple_tokenize_by_delimiters("a: : :b", &[':', ' ']).as_slice(), + [ + CompletionToken { + text: "a", + start: 0, + }, + CompletionToken { + text: ":", + start: 1, + }, + CompletionToken { + text: ":", + start: 3, + }, + CompletionToken { + text: ":", + start: 5, + }, + CompletionToken { + text: "b", + start: 6, + } + ] + ); + + assert_matches!( + simple_tokenize_by_delimiters("one two:three", &[':', ' ']).as_slice(), + [ + CompletionToken { + text: "one", + start: 0, + }, + CompletionToken { + text: "two", + start: 4, + }, + CompletionToken { + text: ":", + start: 7, + }, + CompletionToken { + text: "three", + start: 8, + } + ] + ); + + assert_matches!( + simple_tokenize_by_delimiters("one'two", &['\'']).as_slice(), + [ + CompletionToken { + text: "one", + start: 0, + }, + CompletionToken { + text: "'", + start: 3, + }, + CompletionToken { + text: "two", + start: 4, + }, + ] + ); + + assert_matches!( + simple_tokenize_by_delimiters("one 'two:three'", &[':', ' ']).as_slice(), + [ + CompletionToken { + text: "one", + start: 0, + }, + CompletionToken { + text: "'two:three'", + start: 4, + }, + ] + ); + + assert_matches!( + simple_tokenize_by_delimiters("one \\'two \"two four\"", &[':', ' ']).as_slice(), + [ + CompletionToken { + text: "one", + start: 0, + }, + CompletionToken { + text: "\\'two", + start: 4, + }, + CompletionToken { + text: "\"two four\"", + start: 10, + }, + ] + ); + } +} diff --git a/local/recipes/shells/brush/source/brush-core/src/env.rs b/local/recipes/shells/brush/source/brush-core/src/env.rs new file mode 100644 index 0000000000..98c8e0bc29 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/env.rs @@ -0,0 +1,676 @@ +//! Implements a shell variable environment. + +use std::borrow::Cow; +use std::collections::HashMap; +use std::collections::hash_map; + +use crate::Shell; +use crate::error; +use crate::extensions; +use crate::variables::{self, ShellValue, ShellValueUnsetType, ShellVariable}; + +/// Represents the policy for looking up variables in a shell environment. +#[derive(Clone, Copy)] +pub enum EnvironmentLookup { + /// Look anywhere. + Anywhere, + /// Look only in the global scope. + OnlyInGlobal, + /// Look only in the current local scope. + OnlyInCurrentLocal, + /// Look only in local scopes. + OnlyInLocal, +} + +/// Represents a shell environment scope. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum EnvironmentScope { + /// Scope local to a function instance + Local, + /// Globals + Global, + /// Transient overrides for a command invocation + Command, +} + +impl std::fmt::Display for EnvironmentScope { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Local => write!(f, "local"), + Self::Global => write!(f, "global"), + Self::Command => write!(f, "command"), + } + } +} + +/// A guard that pushes a scope onto a shell environment and pops it when dropped. +pub(crate) struct ScopeGuard<'a, SE: extensions::ShellExtensions> { + scope_type: EnvironmentScope, + shell: &'a mut crate::Shell, + detached: bool, +} + +impl<'a, SE: extensions::ShellExtensions> ScopeGuard<'a, SE> { + /// Creates a new scope guard, pushing the given scope type onto the environment. + /// + /// # Arguments + /// + /// * `shell` - The shell whose environment to modify. + /// * `scope_type` - The type of scope to push. + pub fn new(shell: &'a mut crate::Shell, scope_type: EnvironmentScope) -> Self { + shell.env_mut().push_scope(scope_type); + Self { + scope_type, + shell, + detached: false, + } + } + + /// Returns a mutable reference to the shell. + pub const fn shell(&mut self) -> &mut crate::Shell { + self.shell + } + + /// Detaches the guard, preventing it from popping the scope on drop. + pub const fn detach(&mut self) { + self.detached = true; + } +} + +impl Drop for ScopeGuard<'_, SE> { + fn drop(&mut self) { + if !self.detached { + let _ = self.shell.env_mut().pop_scope(self.scope_type); + } + } +} + +/// Represents the shell variable environment, composed of a stack of scopes. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct ShellEnvironment { + /// Stack of scopes, with the top of the stack being the current scope. + scopes: Vec<(EnvironmentScope, ShellVariableMap)>, + /// Whether or not to auto-export variables on creation or modification. + export_variables_on_modification: bool, + /// Count of total entries (may include duplicates with shadowed variables). + entry_count: usize, +} + +impl Default for ShellEnvironment { + fn default() -> Self { + Self::new() + } +} + +impl ShellEnvironment { + /// Returns a new shell environment. + pub fn new() -> Self { + Self { + scopes: vec![(EnvironmentScope::Global, ShellVariableMap::default())], + export_variables_on_modification: false, + entry_count: 0, + } + } + + /// Pushes a new scope of the given type onto the environment's scope stack. + /// + /// # Arguments + /// + /// * `scope_type` - The type of scope to push. + pub fn push_scope(&mut self, scope_type: EnvironmentScope) { + self.scopes.push((scope_type, ShellVariableMap::default())); + } + + /// Pops the top-most scope off the environment's scope stack. + /// + /// # Arguments + /// + /// * `expected_scope_type` - The type of scope that is expected to be atop the stack. + pub fn pop_scope(&mut self, expected_scope_type: EnvironmentScope) -> Result<(), error::Error> { + // TODO(env): Should we panic instead on failure? It's effectively a broken invariant. + match self.scopes.pop() { + Some((actual_scope_type, _)) if actual_scope_type == expected_scope_type => Ok(()), + Some((actual_scope_type, _)) => Err(error::ErrorKind::UnexpectedScopeType { + expected: expected_scope_type, + actual: actual_scope_type, + } + .into()), + None => Err(error::ErrorKind::MissingScope.into()), + } + } + + // + // Iterators/Getters + // + + /// Returns an iterator over all exported variables defined in the variable. + pub fn iter_exported(&self) -> impl Iterator { + // We won't actually need to store all entries, but we expect it should be + // within the same order. + let mut visible_vars: HashMap<&String, &ShellVariable> = + HashMap::with_capacity(self.entry_count); + + for (_, var_map) in self.scopes.iter().rev() { + for (name, var) in var_map.iter().filter(|(_, v)| v.is_exported()) { + // Only insert the variable if it hasn't been seen yet. + if let hash_map::Entry::Vacant(entry) = visible_vars.entry(name) { + entry.insert(var); + } + } + } + + visible_vars.into_iter() + } + + /// Returns an iterator over all the variables defined in the environment. + pub fn iter(&self) -> impl Iterator { + self.iter_using_policy(EnvironmentLookup::Anywhere) + } + + /// Returns an iterator over all the variables defined in the environment, + /// using the given lookup policy. + /// + /// # Arguments + /// + /// * `lookup_policy` - The policy to use when looking up variables. + pub fn iter_using_policy( + &self, + lookup_policy: EnvironmentLookup, + ) -> impl Iterator { + // We won't actually need to store all entries, but we expect it should be + // within the same order. + let mut visible_vars: HashMap<&String, &ShellVariable> = + HashMap::with_capacity(self.entry_count); + + let mut local_count = 0; + for (scope_type, var_map) in self.scopes.iter().rev() { + if matches!(scope_type, EnvironmentScope::Local) { + local_count += 1; + } + + match lookup_policy { + EnvironmentLookup::Anywhere => (), + EnvironmentLookup::OnlyInGlobal => { + if !matches!(scope_type, EnvironmentScope::Global) { + continue; + } + } + EnvironmentLookup::OnlyInCurrentLocal => { + if !(matches!(scope_type, EnvironmentScope::Local) && local_count == 1) { + continue; + } + } + EnvironmentLookup::OnlyInLocal => { + if !matches!(scope_type, EnvironmentScope::Local) { + continue; + } + } + } + + for (name, var) in var_map.iter() { + // Only insert the variable if it hasn't been seen yet. + if let hash_map::Entry::Vacant(entry) = visible_vars.entry(name) { + entry.insert(var); + } + } + + if matches!(scope_type, EnvironmentScope::Local) + && matches!(lookup_policy, EnvironmentLookup::OnlyInCurrentLocal) + { + break; + } + } + + visible_vars.into_iter() + } + + /// Tries to retrieve an immutable reference to the variable with the given name + /// in the environment. + /// + /// # Arguments + /// + /// * `name` - The name of the variable to retrieve. + pub fn get>(&self, name: S) -> Option<(EnvironmentScope, &ShellVariable)> { + // Look through scopes, from the top of the stack on down. + for (scope_type, map) in self.scopes.iter().rev() { + if let Some(var) = map.get(name.as_ref()) { + return Some((*scope_type, var)); + } + } + + None + } + + /// Tries to retrieve a mutable reference to the variable with the given name + /// in the environment. + /// + /// # Arguments + /// + /// * `name` - The name of the variable to retrieve. + pub fn get_mut>( + &mut self, + name: S, + ) -> Option<(EnvironmentScope, &mut ShellVariable)> { + // Look through scopes, from the top of the stack on down. + for (scope_type, map) in self.scopes.iter_mut().rev() { + if let Some(var) = map.get_mut(name.as_ref()) { + return Some((*scope_type, var)); + } + } + + None + } + + /// Tries to retrieve the string value of the variable with the given name in the + /// environment. + /// + /// # Arguments + /// + /// * `name` - The name of the variable to retrieve. + /// * `shell` - The shell owning the environment. + pub fn get_str, SE: extensions::ShellExtensions>( + &self, + name: S, + shell: &Shell, + ) -> Option> { + self.get(name.as_ref()) + .map(|(_, v)| v.value().to_cow_str(shell)) + } + + /// Checks if a variable of the given name is set in the environment. + /// + /// # Arguments + /// + /// * `name` - The name of the variable to check. + pub fn is_set>(&self, name: S) -> bool { + if let Some((_, var)) = self.get(name) { + !matches!(var.value(), ShellValue::Unset(_)) + } else { + false + } + } + + // + // Setters + // + + /// Tries to unset the variable with the given name in the environment, returning + /// whether or not such a variable existed. + /// + /// # Arguments + /// + /// * `name` - The name of the variable to unset. + pub fn unset(&mut self, name: &str) -> Result, error::Error> { + let mut local_count = 0; + for (scope_type, map) in self.scopes.iter_mut().rev() { + if matches!(scope_type, EnvironmentScope::Local) { + local_count += 1; + } + + let unset_result = Self::try_unset_in_map(map, name)?; + + if unset_result.is_some() { + // If we end up finding a local in the top-most local frame, then we replace + // it with a placeholder. + if matches!(scope_type, EnvironmentScope::Local) && local_count == 1 { + map.set( + name, + ShellVariable::new(ShellValue::Unset(ShellValueUnsetType::Untyped)), + ); + } else if self.entry_count > 0 { + // Entry count should never be 0 here, but we're being defensive. + self.entry_count -= 1; + } + + return Ok(unset_result); + } + } + + Ok(None) + } + + /// Tries to unset an array element from the environment, using the given name and + /// element index for lookup. Returns whether or not an element was unset. + /// + /// # Arguments + /// + /// * `name` - The name of the array variable to unset an element from. + /// * `index` - The index of the element to unset. + pub fn unset_index(&mut self, name: &str, index: &str) -> Result { + if let Some((_, var)) = self.get_mut(name) { + var.unset_index(index) + } else { + Ok(false) + } + } + + fn try_unset_in_map( + map: &mut ShellVariableMap, + name: &str, + ) -> Result, error::Error> { + match map.get(name).map(|v| v.is_readonly()) { + Some(true) => Err(error::ErrorKind::ReadonlyVariable.into()), + Some(false) => Ok(map.unset(name)), + None => Ok(None), + } + } + + /// Tries to retrieve an immutable reference to a variable from the environment, + /// using the given name and lookup policy. + /// + /// # Arguments + /// + /// * `name` - The name of the variable to retrieve. + /// * `lookup_policy` - The policy to use when looking up the variable. + pub fn get_using_policy>( + &self, + name: N, + lookup_policy: EnvironmentLookup, + ) -> Option<&ShellVariable> { + let mut local_count = 0; + for (scope_type, var_map) in self.scopes.iter().rev() { + if matches!(scope_type, EnvironmentScope::Local) { + local_count += 1; + } + + match lookup_policy { + EnvironmentLookup::Anywhere => (), + EnvironmentLookup::OnlyInGlobal => { + if !matches!(scope_type, EnvironmentScope::Global) { + continue; + } + } + EnvironmentLookup::OnlyInCurrentLocal => { + if !(matches!(scope_type, EnvironmentScope::Local) && local_count == 1) { + continue; + } + } + EnvironmentLookup::OnlyInLocal => { + if !matches!(scope_type, EnvironmentScope::Local) { + continue; + } + } + } + + if let Some(var) = var_map.get(name.as_ref()) { + return Some(var); + } + + if matches!(scope_type, EnvironmentScope::Local) + && matches!(lookup_policy, EnvironmentLookup::OnlyInCurrentLocal) + { + break; + } + } + + None + } + + /// Tries to retrieve a mutable reference to a variable from the environment, + /// using the given name and lookup policy. + /// + /// # Arguments + /// + /// * `name` - The name of the variable to retrieve. + /// * `lookup_policy` - The policy to use when looking up the variable. + pub fn get_mut_using_policy>( + &mut self, + name: N, + lookup_policy: EnvironmentLookup, + ) -> Option<&mut ShellVariable> { + let mut local_count = 0; + for (scope_type, var_map) in self.scopes.iter_mut().rev() { + if matches!(scope_type, EnvironmentScope::Local) { + local_count += 1; + } + + match lookup_policy { + EnvironmentLookup::Anywhere => (), + EnvironmentLookup::OnlyInGlobal => { + if !matches!(scope_type, EnvironmentScope::Global) { + continue; + } + } + EnvironmentLookup::OnlyInCurrentLocal => { + if !(matches!(scope_type, EnvironmentScope::Local) && local_count == 1) { + continue; + } + } + EnvironmentLookup::OnlyInLocal => { + if !matches!(scope_type, EnvironmentScope::Local) { + continue; + } + } + } + + if let Some(var) = var_map.get_mut(name.as_ref()) { + return Some(var); + } + + if matches!(scope_type, EnvironmentScope::Local) + && matches!(lookup_policy, EnvironmentLookup::OnlyInCurrentLocal) + { + break; + } + } + + None + } + + /// Update a variable in the environment, or add it if it doesn't already exist. + /// + /// # Arguments + /// + /// * `name` - The name of the variable to update or add. + /// * `value` - The value to assign to the variable. + /// * `updater` - A function to call to update the variable after assigning the value. + /// * `lookup_policy` - The policy to use when looking up the variable. + /// * `scope_if_creating` - The scope to create the variable in if it doesn't already exist. + pub fn update_or_add>( + &mut self, + name: N, + value: variables::ShellValueLiteral, + updater: impl Fn(&mut ShellVariable) -> Result<(), error::Error>, + lookup_policy: EnvironmentLookup, + scope_if_creating: EnvironmentScope, + ) -> Result<(), error::Error> { + let name = name.into(); + + let auto_export = self.export_variables_on_modification; + if let Some(var) = self.get_mut_using_policy(&name, lookup_policy) { + var.assign(value, false)?; + if auto_export { + var.export(); + } + updater(var) + } else { + let mut var = ShellVariable::new(ShellValue::Unset(ShellValueUnsetType::Untyped)); + var.assign(value, false)?; + if auto_export { + var.export(); + } + updater(&mut var)?; + + self.add(name, var, scope_if_creating) + } + } + + /// Update an array element in the environment, or add it if it doesn't already exist. + /// + /// # Arguments + /// + /// * `name` - The name of the variable to update or add. + /// * `index` - The index of the element to update or add. + /// * `value` - The value to assign to the variable. + /// * `updater` - A function to call to update the variable after assigning the value. + /// * `lookup_policy` - The policy to use when looking up the variable. + /// * `scope_if_creating` - The scope to create the variable in if it doesn't already exist. + pub fn update_or_add_array_element>( + &mut self, + name: N, + index: String, + value: String, + updater: impl Fn(&mut ShellVariable) -> Result<(), error::Error>, + lookup_policy: EnvironmentLookup, + scope_if_creating: EnvironmentScope, + ) -> Result<(), error::Error> { + let name = name.into(); + + if let Some(var) = self.get_mut_using_policy(&name, lookup_policy) { + var.assign_at_index(index, value, false)?; + updater(var) + } else { + let mut var = ShellVariable::new(ShellValue::Unset(ShellValueUnsetType::Untyped)); + var.assign( + variables::ShellValueLiteral::Array(variables::ArrayLiteral(vec![( + Some(index), + value, + )])), + false, + )?; + updater(&mut var)?; + + self.add(name, var, scope_if_creating) + } + } + + /// Adds a variable to the environment. + /// + /// # Arguments + /// + /// * `name` - The name of the variable to add. + /// * `var` - The variable to add. + /// * `target_scope` - The scope to add the variable to. + pub fn add>( + &mut self, + name: N, + mut var: ShellVariable, + target_scope: EnvironmentScope, + ) -> Result<(), error::Error> { + if self.export_variables_on_modification { + var.export(); + } + + for (scope_type, map) in self.scopes.iter_mut().rev() { + if *scope_type == target_scope { + let prev_var = map.set(name, var); + if prev_var.is_none() { + self.entry_count += 1; + } + + return Ok(()); + } + } + + Err(error::ErrorKind::MissingScopeForNewVariable.into()) + } + + /// Sets a global variable in the environment. + /// + /// # Arguments + /// + /// * `name` - The name of the variable to set. + /// * `var` - The variable to set. + pub fn set_global>( + &mut self, + name: N, + var: ShellVariable, + ) -> Result<(), error::Error> { + self.add(name, var, EnvironmentScope::Global) + } +} + +/// Represents a map from names to shell variables. +#[derive(Clone, Debug, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct ShellVariableMap { + variables: HashMap, +} + +impl ShellVariableMap { + // + // Iterators/Getters + // + + /// Returns an iterator over all the variables in the map. + pub fn iter(&self) -> impl Iterator { + self.variables.iter() + } + + /// Tries to retrieve an immutable reference to the variable with the given name. + /// + /// # Arguments + /// + /// * `name` - The name of the variable to retrieve. + pub fn get(&self, name: &str) -> Option<&ShellVariable> { + self.variables.get(name) + } + + /// Tries to retrieve a mutable reference to the variable with the given name. + /// + /// # Arguments + /// + /// * `name` - The name of the variable to retrieve. + pub fn get_mut(&mut self, name: &str) -> Option<&mut ShellVariable> { + self.variables.get_mut(name) + } + + // + // Setters + // + + /// Tries to unset the variable with the given name, returning the removed + /// variable or None if it was not already set. + /// + /// # Arguments + /// + /// * `name` - The name of the variable to unset. + pub fn unset(&mut self, name: &str) -> Option { + self.variables.remove(name) + } + + /// Sets a variable in the map. + /// + /// # Arguments + /// + /// * `name` - The name of the variable to set. + /// * `var` - The variable to set. + pub fn set>(&mut self, name: N, var: ShellVariable) -> Option { + self.variables.insert(name.into(), var) + } +} + +/// Checks if the given name is a valid variable name. +pub fn valid_variable_name(s: &str) -> bool { + let mut cs = s.chars(); + match cs.next() { + Some(c) if c.is_ascii_alphabetic() || c == '_' => { + cs.all(|c| c.is_ascii_alphanumeric() || c == '_') + } + Some(_) | None => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_valid_variable_name() { + assert!(!valid_variable_name("")); + assert!(!valid_variable_name("1")); + assert!(!valid_variable_name(" a")); + assert!(!valid_variable_name(" ")); + + assert!(valid_variable_name("_")); + assert!(valid_variable_name("_a")); + assert!(valid_variable_name("_1")); + assert!(valid_variable_name("_a1")); + assert!(valid_variable_name("a")); + assert!(valid_variable_name("A")); + assert!(valid_variable_name("a1")); + assert!(valid_variable_name("A1")); + } +} diff --git a/local/recipes/shells/brush/source/brush-core/src/error.rs b/local/recipes/shells/brush/source/brush-core/src/error.rs new file mode 100644 index 0000000000..4c0088a3cf --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/error.rs @@ -0,0 +1,482 @@ +//! Error facilities + +use std::path::PathBuf; + +use crate::{Shell, ShellFd, extensions, results, sys}; + +/// Unified error type for this crate. Contains just a kind for now, +/// but will be extended later with additional context. +#[derive(thiserror::Error, Debug)] +#[error("{kind}")] +pub struct Error { + /// The kind of error. + #[source] + kind: ErrorKind, + + /// Whether or not the error should be considered a "fatal" error that would + /// result in abnormal exit of a non-interactive shell. + fatal: bool, +} + +/// Monolithic error type for the shell +#[derive(thiserror::Error, Debug)] +pub enum ErrorKind { + /// A tilde expression was used without a valid HOME variable + #[error("cannot expand tilde expression with HOME not set")] + TildeWithoutValidHome, + + /// An attempt was made to assign a list to an array member + #[error("cannot assign list to array member")] + AssigningListToArrayMember, + + /// An attempt was made to convert an associative array to an indexed array. + #[error("cannot convert associative array to indexed array")] + ConvertingAssociativeArrayToIndexedArray, + + /// An attempt was made to convert an indexed array to an associative array. + #[error("cannot convert indexed array to associative array")] + ConvertingIndexedArrayToAssociativeArray, + + /// An error occurred while sourcing the indicated script file. + #[error("failed to source file: {0}")] + FailedSourcingFile(PathBuf, #[source] std::io::Error), + + /// The shell failed to send a signal to a process. + #[error("failed to send signal to process")] + FailedToSendSignal, + + /// An attempt was made to assign a value to a special parameter. + #[error("cannot assign in this way")] + CannotAssignToSpecialParameter, + + /// Checked expansion error. + #[error("expansion error: {0}")] + CheckedExpansionError(String), + + /// A reference was made to an unknown shell function. + #[error("function not found: {0}")] + FunctionNotFound(String), + + /// Command was not found. + #[error("command not found: {0}")] + CommandNotFound(String), + + /// Not a builtin. + #[error("not a shell builtin: {0}")] + BuiltinNotFound(String), + + /// The working directory does not exist. + #[error("working directory does not exist: {0}")] + WorkingDirMissing(PathBuf), + + /// Failed to execute command. + #[error("failed to execute command '{0}': {1}")] + FailedToExecuteCommand(String, #[source] std::io::Error), + + /// History item was not found. + #[error("history item not found")] + HistoryItemNotFound, + + /// The requested functionality has not yet been implemented in this shell. + #[error("not yet implemented: {0}")] + Unimplemented(&'static str), + + /// The requested functionality has not yet been implemented in this shell; it is tracked in a + /// GitHub issue. + #[error("not yet implemented: {0}; see https://github.com/reubeno/brush/issues/{1}")] + UnimplementedAndTracked(&'static str, u32), + + /// An expected environment scope could not be found. + #[error("missing environment scope")] + MissingScope, + + /// The environment scope required for a new variable is not available. + #[error("environment scope required for new variable is not available")] + MissingScopeForNewVariable, + + /// An unexpected environment scope type was encountered. + #[error("unexpected environment scope type: expected '{expected}', found '{actual}'")] + UnexpectedScopeType { + /// The expected scope type. + expected: crate::env::EnvironmentScope, + /// The actual scope type. + actual: crate::env::EnvironmentScope, + }, + + /// The given path is not a directory. + #[error("not a directory: {0}")] + NotADirectory(PathBuf), + + /// The given path is a directory. + #[error("path is a directory")] + IsADirectory, + + /// The given variable is not an array. + #[error("variable is not an array")] + NotArray, + + /// The current user could not be determined. + #[error("no current user")] + NoCurrentUser, + + /// The requested input or output redirection is invalid. + #[error("invalid redirection target")] + InvalidRedirection, + + /// An error occurred while redirecting input or output with the given file. + #[error("failed to redirect to {0}: {1}")] + RedirectionFailure(String, String), + + /// An error occurred evaluating an arithmetic expression. + #[error("arithmetic evaluation error: {0}")] + EvalError(#[from] crate::arithmetic::EvalError), + + /// The given string could not be parsed as an integer. + #[error("failed to parse '{s}' as a {int_type_name}, base-{radix} integer: {inner}")] + IntParseError { + /// The string that failed to parse. + s: String, + /// The integer type being parsed. + int_type_name: &'static str, + /// The radix (base) used for parsing. + radix: u32, + /// The underlying parse error. + inner: std::num::ParseIntError, + }, + + /// The given integer could not be converted to the target type. + #[error("integer conversion error")] + TryIntParseError(#[from] std::num::TryFromIntError), + + /// A byte sequence could not be decoded as a valid UTF-8 string. + #[error("failed to decode utf-8")] + FromUtf8Error(#[from] std::string::FromUtf8Error), + + /// A byte sequence could not be decoded as a valid UTF-8 string. + #[error("failed to decode utf-8")] + Utf8Error(#[from] std::str::Utf8Error), + + /// An attempt was made to modify a readonly variable. + #[error("cannot mutate readonly variable")] + ReadonlyVariable, + + /// The indicated pattern is invalid. + #[error("invalid pattern: '{0}'")] + InvalidPattern(String), + + /// A regular expression error occurred + #[error("regex error: {0}")] + RegexError(#[from] fancy_regex::Error), + + /// An invalid regular expression was provided. + #[error("invalid regex: {0}; expression: '{1}'")] + InvalidRegexError(fancy_regex::Error, String), + + /// An I/O error occurred. + #[error("i/o error: {0}")] + IoError(#[from] std::io::Error), + + /// Invalid substitution syntax. + #[error("bad substitution: {0}")] + BadSubstitution(String), + + /// An error occurred while creating a child process. + #[error("failed to create child process")] + ChildCreationFailure, + + /// An error occurred while formatting a string. + #[error(transparent)] + FormattingError(#[from] std::fmt::Error), + + /// An error occurred while parsing. + #[error("{1}: {0}")] + ParseError(crate::parser::ParseError, crate::SourceInfo), + + /// An error occurred while parsing a function body. + #[error("{0}: {1}")] + FunctionParseError(String, crate::parser::ParseError), + + /// An error occurred while parsing a word. + #[error(transparent)] + WordParseError(#[from] crate::parser::WordParseError), + + /// Unable to parse a test command. + #[error("invalid test command")] + TestCommandParseError(#[from] crate::parser::TestCommandParseError), + + /// Unable to parse a key binding specification. + #[error(transparent)] + BindingParseError(#[from] crate::parser::BindingParseError), + + /// A threading error occurred. + #[error("threading error")] + ThreadingError(#[from] tokio::task::JoinError), + + /// An invalid signal was referenced. + #[error("{0}: invalid signal specification")] + InvalidSignal(String), + + /// A platform error occurred. + #[error("platform error: {0}")] + PlatformError(#[from] sys::PlatformError), + + /// An invalid umask was provided. + #[error("invalid umask value")] + InvalidUmask, + + /// The given open file cannot be read from. + #[error("cannot read from {0}")] + OpenFileNotReadable(&'static str), + + /// The given open file cannot be written to. + #[error("cannot write to {0}")] + OpenFileNotWritable(&'static str), + + /// Bad file descriptor. + #[error("bad file descriptor: {0}")] + BadFileDescriptor(ShellFd), + + /// Printf failure + #[error("printf failure: {0}")] + PrintfFailure(i32), + + /// Printf invalid usage + #[error("printf: {0}")] + PrintfInvalidUsage(String), + + /// Interrupted + #[error("interrupted")] + Interrupted, + + /// Maximum function call depth was exceeded. + #[error("maximum function call depth exceeded")] + MaxFunctionCallDepthExceeded, + + /// System time error. + #[error("system time error: {0}")] + TimeError(#[from] std::time::SystemTimeError), + + /// Array index out of range. + #[error("array index out of range: {0}")] + ArrayIndexOutOfRange(String), + + /// Unhandled key code. + #[error("unhandled key code: {0:?}")] + UnhandledKeyCode(Vec), + + /// An error occurred in a built-in command. + #[error("{1}: {0}")] + BuiltinError(Box, String), + + /// Operation not supported on this platform. + #[error("operation not supported on this platform: {0}")] + NotSupportedOnThisPlatform(&'static str), + + /// Command history is not enabled in this shell. + #[error("command history is not enabled in this shell")] + HistoryNotEnabled, + + /// Expanding an unset variable. + #[error("expanding unset variable: {0}")] + ExpandingUnsetVariable(String), + + /// An internal error occurred. + #[error("internal shell error: {0}")] + InternalError(String), + + /// Attempted to perform an operation that requires an interactive session. + #[error("operation requires an interactive session")] + NotInInteractiveSession, + + /// Attempted to perform an operation that requires command-string mode. + #[error("operation requires command-string mode")] + NotExecutingCommandString, + + /// Too much data was provided to an operation. + #[error("too much data")] + TooMuchData, + + /// Cannot convert open file to native file descriptor. + #[error("cannot convert open file to native file descriptor")] + CannotConvertToNativeFd, + + /// History file is too large to import. + #[error("history file is too large to import")] + HistoryFileTooLargeToImport, + + /// Too many open files. + #[error("too many open files")] + TooManyOpenFiles, + + /// The function name shadows a special built-in command. + #[error("function name '{}' shadows a special built-in command", .name)] + FunctionNameShadowsSpecialBuiltin { + /// Name of the function. + name: String, + }, + + /// A glob pattern failed to match any files (failglob). + #[error("no match: {0}")] + NoMatch(String), +} + +/// Trait implementable by built-in commands to represent errors. +pub trait BuiltinError: std::error::Error + ConvertibleToExitCode + Send + Sync { + /// Try to extract a reference to the underlying `std::io::Error`, if any. + /// Implementations should return `None` if there is no inner I/O error. + /// They should not attempt to *synthesize* an I/O error if one does not + /// naturally exist. + fn as_io_error(&self) -> Option<&std::io::Error> { + None + } +} + +impl BuiltinError for Error { + fn as_io_error(&self) -> Option<&std::io::Error> { + self.as_io_error() + } +} + +/// Helper trait for converting values to exit codes. +pub trait ConvertibleToExitCode { + /// Converts to an exit code. + fn as_exit_code(&self) -> results::ExecutionExitCode; +} + +impl ConvertibleToExitCode for T +where + results::ExecutionExitCode: for<'a> From<&'a T>, +{ + fn as_exit_code(&self) -> results::ExecutionExitCode { + self.into() + } +} + +impl From<&ErrorKind> for results::ExecutionExitCode { + fn from(value: &ErrorKind) -> Self { + match value { + ErrorKind::CommandNotFound(..) => Self::NotFound, + ErrorKind::Unimplemented(..) | ErrorKind::UnimplementedAndTracked(..) => { + Self::Unimplemented + } + ErrorKind::ParseError(..) => Self::InvalidUsage, + ErrorKind::FunctionParseError(..) => Self::InvalidUsage, + ErrorKind::TestCommandParseError(..) => Self::InvalidUsage, + ErrorKind::FailedToExecuteCommand(..) => Self::CannotExecute, + ErrorKind::FunctionNameShadowsSpecialBuiltin { .. } => Self::InvalidUsage, + ErrorKind::IoError(io_err) => io_err.into(), + ErrorKind::BuiltinError(inner, ..) => inner.as_exit_code(), + _ => Self::GeneralError, + } + } +} + +impl From<&std::io::Error> for results::ExecutionExitCode { + fn from(io_err: &std::io::Error) -> Self { + if io_err.kind() == std::io::ErrorKind::BrokenPipe { + Self::BrokenPipe + } else { + Self::GeneralError + } + } +} + +impl From<&Error> for results::ExecutionExitCode { + fn from(error: &Error) -> Self { + Self::from(&error.kind) + } +} + +impl From for Error +where + ErrorKind: From, +{ + fn from(convertible_to_kind: T) -> Self { + Self { + kind: convertible_to_kind.into(), + fatal: false, + } + } +} + +impl Error { + /// Marks this error as fatal. + #[must_use] + pub const fn into_fatal(mut self) -> Self { + self.fatal = true; + self + } + + /// Returns whether or not this error is fatal. + pub const fn is_fatal(&self) -> bool { + self.fatal + } + + /// Returns a reference to the error kind. + pub const fn kind(&self) -> &ErrorKind { + &self.kind + } + + /// Try to extract a reference to the underlying `std::io::Error`, if any. + pub fn as_io_error(&self) -> Option<&std::io::Error> { + match &self.kind { + ErrorKind::IoError(io_err) => Some(io_err), + ErrorKind::BuiltinError(inner, _) => inner.as_io_error(), + _ => None, + } + } + + /// Converts this error into the appropriate control flow based on the shell's current state. + /// This centralizes the logic for determining how fatal errors should affect execution flow. + /// + /// # Arguments + /// + /// * `shell` - The shell instance, used to check interactive mode and script call stack. + pub fn to_control_flow( + &self, + shell: &Shell, + ) -> results::ExecutionControlFlow { + if self.is_fatal() && !shell.options().interactive { + results::ExecutionControlFlow::ExitShell + } else { + results::ExecutionControlFlow::Normal + } + } + + /// Converts this error into an execution result for the shell. + /// + /// # Arguments + /// + /// * `shell` - The shell instance, used to determine control flow. + pub fn into_result( + self, + shell: &Shell, + ) -> results::ExecutionResult { + let next_control_flow = self.to_control_flow(shell); + let exit_code = results::ExecutionExitCode::from(&self); + + results::ExecutionResult { + next_control_flow, + exit_code, + } + } +} + +/// Convenience function for returning an error for unimplemented functionality. +/// +/// # Arguments +/// +/// * `msg` - The message to include in the error +pub fn unimp(msg: &'static str) -> Result { + Err(ErrorKind::Unimplemented(msg).into()) +} + +/// Convenience function for returning an error for *tracked*, unimplemented functionality. +/// +/// # Arguments +/// +/// * `msg` - The message to include in the error +/// * `project_issue_id` - The GitHub issue ID where the implementation is tracked. +pub fn unimp_with_issue(msg: &'static str, project_issue_id: u32) -> Result { + Err(ErrorKind::UnimplementedAndTracked(msg, project_issue_id).into()) +} diff --git a/local/recipes/shells/brush/source/brush-core/src/escape.rs b/local/recipes/shells/brush/source/brush-core/src/escape.rs new file mode 100644 index 0000000000..7fe306fe11 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/escape.rs @@ -0,0 +1,536 @@ +//! String escaping utilities + +use std::borrow::Cow; + +use itertools::Itertools; + +use crate::{error, int_utils}; + +/// Escape expansion mode. +#[derive(Clone, Copy)] +pub enum EscapeExpansionMode { + /// echo builtin mode. + EchoBuiltin, + /// ANSI-C quotes. + AnsiCQuotes, +} + +/// Expands backslash escapes in the provided string. +/// +/// # Arguments +/// +/// * `s` - The string to expand. +/// * `mode` - The mode to use for expansion. +#[expect(clippy::too_many_lines)] +pub fn expand_backslash_escapes( + s: &str, + mode: EscapeExpansionMode, +) -> Result<(Vec, bool), error::Error> { + let mut result: Vec = Vec::with_capacity(s.len()); + let mut it = s.chars(); + while let Some(c) = it.next() { + if c != '\\' { + // Not a backslash, add and move on. + result.append(c.to_string().into_bytes().as_mut()); + continue; + } + + let Some(escape_cmd) = it.next() else { + // Trailing backslash. + result.push(b'\\'); + continue; + }; + + match escape_cmd { + 'a' => result.push(b'\x07'), + 'b' => result.push(b'\x08'), + 'c' => { + match mode { + EscapeExpansionMode::EchoBuiltin => { + // Stop all additional output! + return Ok((result, false)); + } + EscapeExpansionMode::AnsiCQuotes => { + if let Some(char_value) = it.next() { + // Special case backslash. If it's immediately followed by another + // backslash, then we consume both; if not, we still will use the + // backslash character as the one to apply the control transformation + // to. + if char_value == '\\' { + let orig_it = it.clone(); + if !matches!(it.next(), Some('\\')) { + // Didn't find another backslash; restore iterator. + it = orig_it; + } + } + + let mut bytes: Vec = if char_value.is_ascii_lowercase() { + char_value + .to_ascii_uppercase() + .to_string() + .bytes() + .collect() + } else { + char_value.to_string().bytes().collect() + }; + + if !bytes.is_empty() { + if bytes[0] == b'?' { + // We can't explain why this is the case, but it is. + bytes[0] = 0x7f; + } else { + bytes[0] &= 0x1f; + } + } + + result.append(bytes.as_mut()); + } else { + result.push(b'\\'); + result.push(b'c'); + } + } + } + } + 'e' | 'E' => result.push(b'\x1b'), + 'f' => result.push(b'\x0c'), + 'n' => result.push(b'\n'), + 'r' => result.push(b'\r'), + 't' => result.push(b'\t'), + 'v' => result.push(b'\x0b'), + '\\' => result.push(b'\\'), + '\'' if matches!(mode, EscapeExpansionMode::AnsiCQuotes) => result.push(b'\''), + '\"' if matches!(mode, EscapeExpansionMode::AnsiCQuotes) => result.push(b'\"'), + '?' if matches!(mode, EscapeExpansionMode::AnsiCQuotes) => result.push(b'?'), + '0' => { + // Consume 0-3 valid octal chars + let mut taken_so_far = 0; + let mut octal_chars: String = it + .take_while_ref(|c| { + if taken_so_far < 3 && matches!(*c, '0'..='7') { + taken_so_far += 1; + true + } else { + false + } + }) + .collect(); + + if octal_chars.is_empty() { + octal_chars.push('0'); + } + + let value = int_utils::parse::(octal_chars.as_str(), 8)?; + result.push(value); + } + 'x' => { + // Consume 1-2 valid hex chars (or unlimited with braces in ANSI-C mode) + let mut hex_chars = String::new(); + let mut invalid_prefix = false; + let mut hexits_consumed = 0; + let mut start_brace_consumed = false; + + loop { + // Save the original in case we go too far and need to restore. + let orig_it = it.clone(); + + let Some(next_c) = it.next() else { + break; + }; + + if matches!(mode, EscapeExpansionMode::AnsiCQuotes) + && !start_brace_consumed + && next_c == '{' + { + start_brace_consumed = true; + } else if start_brace_consumed && next_c == '}' { + break; + } else if ((start_brace_consumed && !invalid_prefix) + || (!start_brace_consumed && hexits_consumed < 2)) + && next_c.is_ascii_hexdigit() + { + hex_chars.push(next_c); + hexits_consumed += 1; + } else if start_brace_consumed && hexits_consumed == 0 { + invalid_prefix = true; + } else { + // Went too far; restore iterator and break. + it = orig_it; + break; + } + } + + if hex_chars.is_empty() { + if start_brace_consumed { + result.push(0); + } else { + result.push(b'\\'); + result.append(escape_cmd.to_string().into_bytes().as_mut()); + } + } else { + let value32 = int_utils::parse::(hex_chars.as_str(), 16)?; + let value8: u8 = (value32 & 0xFF) as u8; + result.push(value8); + } + } + 'u' => { + // Consume 1-4 hex digits + let mut taken_so_far = 0; + let hex_chars: String = it + .take_while_ref(|next_c| { + if taken_so_far < 4 && next_c.is_ascii_hexdigit() { + taken_so_far += 1; + true + } else { + false + } + }) + .collect(); + + if hex_chars.is_empty() { + result.push(b'\\'); + result.append(escape_cmd.to_string().into_bytes().as_mut()); + } else { + let value = int_utils::parse::(hex_chars.as_str(), 16)?; + if let Some(decoded) = char::from_u32(u32::from(value)) { + result.append(decoded.to_string().into_bytes().as_mut()); + } else { + result.push(b'\\'); + result.append(escape_cmd.to_string().into_bytes().as_mut()); + } + } + } + 'U' => { + // Consume 1-8 hex digits + let mut taken_so_far = 0; + let hex_chars: String = it + .take_while_ref(|next_c| { + if taken_so_far < 8 && next_c.is_ascii_hexdigit() { + taken_so_far += 1; + true + } else { + false + } + }) + .collect(); + + if hex_chars.is_empty() { + result.push(b'\\'); + result.append(escape_cmd.to_string().into_bytes().as_mut()); + } else { + let value = int_utils::parse::(hex_chars.as_str(), 16)?; + if let Some(decoded) = char::from_u32(value) { + result.append(decoded.to_string().into_bytes().as_mut()); + } else { + result.push(b'\\'); + result.append(escape_cmd.to_string().into_bytes().as_mut()); + } + } + } + first_octal @ '1'..='7' if matches!(mode, EscapeExpansionMode::AnsiCQuotes) => { + // We've already consumed the first octal digit. + let mut octal_chars = String::new(); + octal_chars.push(first_octal); + + // Consume up to 2 more valid octal chars + let mut taken_so_far = 1; + for next_c in it.take_while_ref(|next_c| { + if taken_so_far < 3 && matches!(next_c, '0'..='7') { + taken_so_far += 1; + true + } else { + false + } + }) { + octal_chars.push(next_c); + } + + let value = int_utils::parse::(octal_chars.as_str(), 8)?; + result.push(value); + } + unknown => { + // Not a valid escape sequence. + result.push(b'\\'); + result.append(unknown.to_string().into_bytes().as_mut()); + } + } + } + + // In ANSI-C quotes, we crop the result at the first NUL. + if matches!(mode, EscapeExpansionMode::AnsiCQuotes) { + if let Some(nul_index) = result.iter().position(|&b| b == 0) { + result.truncate(nul_index); + } + } + + Ok((result, true)) +} + +/// Quoting mode to use for escaping. +#[derive(Clone, Copy, Default)] +pub enum QuoteMode { + /// Single-quote. + #[default] + SingleQuote, + /// Double-quote. + DoubleQuote, + /// Backslash-escape. + BackslashEscape, +} + +/// Options influencing how to escape/quote an input string. +#[derive(Default)] +pub(crate) struct QuoteOptions { + /// Whether or not to *always* escape or quote the input; if false, then escaping/quoting + /// will only be applied if the input contains characters that *require* it. + pub always_quote: bool, + /// Preferred mode for quoting/escaping. Quoting may be "upgraded" to a more expressive + /// format if the input is not expressible otherwise. + pub preferred_mode: QuoteMode, + /// Whether or not to *avoid* using ANSI C quoting just for the benefit of newline characters. + /// Default is for newline characters to require upgrading the string's quoting to + /// ANSI C quoting. + pub avoid_ansi_c_quoting_newline: bool, +} + +pub(crate) fn quote<'a>(s: &'a str, options: &QuoteOptions) -> Cow<'a, str> { + let use_ansi_c_quotes = s.contains(|c| { + needs_ansi_c_quoting(c) && (!options.avoid_ansi_c_quoting_newline || c != '\n') + }); + + if use_ansi_c_quotes { + return ansi_c_quote(s).into(); + } + + let use_default_quotes = + !use_ansi_c_quotes && (options.always_quote || s.is_empty() || s.contains(needs_escaping)); + + if !use_default_quotes { + return s.into(); + } + + match options.preferred_mode { + QuoteMode::BackslashEscape => backslash_escape(s), + QuoteMode::SingleQuote => single_quote(s), + QuoteMode::DoubleQuote => double_quote(s).into(), + } +} + +/// Escape the given string, forcing quoting. +/// +/// # Arguments +/// +/// * `s` - The string to escape. +/// * `mode` - The quoting mode to use. +pub fn force_quote(s: &str, mode: QuoteMode) -> String { + let options = QuoteOptions { + always_quote: true, + preferred_mode: mode, + ..Default::default() + }; + + quote(s, &options).to_string() +} + +/// Applies the given quoting mode to the provided string, only changing it if required. +/// +/// # Arguments +/// +/// * `s` - The string to escape. +/// * `mode` - The quoting mode to use. +pub fn quote_if_needed(s: &str, mode: QuoteMode) -> Cow<'_, str> { + let options = QuoteOptions { + always_quote: false, + preferred_mode: mode, + ..Default::default() + }; + + quote(s, &options) +} + +fn backslash_escape(s: &str) -> Cow<'_, str> { + if s.is_empty() { + // An empty string must be represented as '' to be a valid shell word. + Cow::Owned("''".to_string()) + } else if !s.chars().any(needs_escaping) { + Cow::Borrowed(s) + } else { + let mut output = String::with_capacity(s.len()); + for c in s.chars() { + if needs_escaping(c) { + output.push('\\'); + } + output.push(c); + } + Cow::Owned(output) + } +} + +fn single_quote(s: &str) -> Cow<'_, str> { + // Special-case the empty string. + if s.is_empty() { + return Cow::Borrowed("''"); + } + + let mut result = String::with_capacity(s.len()); + + // Go through the string; put everything in single quotes except for + // the single quote character itself. It will get escaped outside + // all quoting. + let mut first = true; + for part in s.split('\'') { + if !first { + result.push('\\'); + result.push('\''); + } else { + first = false; + } + + if !part.is_empty() { + result.push('\''); + result.push_str(part); + result.push('\''); + } + } + + Cow::Owned(result) +} + +fn double_quote(s: &str) -> String { + let mut result = String::with_capacity(s.len()); + + result.push('"'); + + for c in s.chars() { + if matches!(c, '$' | '`' | '"' | '\\') { + result.push('\\'); + } + + result.push(c); + } + + result.push('"'); + + result +} + +fn ansi_c_quote(s: &str) -> String { + let mut result = String::with_capacity(s.len()); + result.push_str("$'"); + + for c in s.chars() { + match c { + '\x07' => result.push_str("\\a"), + '\x08' => result.push_str("\\b"), + '\x1b' => result.push_str("\\E"), + '\x0c' => result.push_str("\\f"), + '\n' => result.push_str("\\n"), + '\r' => result.push_str("\\r"), + '\t' => result.push_str("\\t"), + '\x0b' => result.push_str("\\v"), + '\\' => result.push_str("\\\\"), + '\'' => result.push_str("\\'"), + c if needs_ansi_c_quoting(c) => { + result.push_str(std::format!("\\{:03o}", c as u8).as_str()); + } + _ => result.push(c), + } + } + + result.push('\''); + + result +} + +// Returns whether or not the given character needs to be escaped (or quoted) if outside +// quotes. +const fn needs_escaping(c: char) -> bool { + matches!( + c, + '(' | ')' + | '[' + | ']' + | '{' + | '}' + | '$' + | '*' + | '?' + | '|' + | '&' + | ';' + | '<' + | '>' + | '`' + | '\\' + | '"' + | '!' + | '^' + | ',' + | ' ' + | '\'' + ) +} + +const fn needs_ansi_c_quoting(c: char) -> bool { + c.is_ascii_control() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_backslash_escape() { + assert_eq!(quote_if_needed("a", QuoteMode::BackslashEscape), "a"); + assert_eq!(quote_if_needed("a b", QuoteMode::BackslashEscape), r"a\ b"); + assert_eq!(quote_if_needed("", QuoteMode::BackslashEscape), "''"); + } + + #[test] + fn test_single_quote_escape() { + assert_eq!(quote_if_needed("a", QuoteMode::SingleQuote), "a"); + assert_eq!(quote_if_needed("a b", QuoteMode::SingleQuote), "'a b'"); + assert_eq!(quote_if_needed("", QuoteMode::SingleQuote), "''"); + assert_eq!(quote_if_needed("'", QuoteMode::SingleQuote), "\\'"); + } + + fn assert_echo_expands_to(unexpanded: &str, expected: &str) { + assert_eq!( + String::from_utf8( + expand_backslash_escapes(unexpanded, EscapeExpansionMode::EchoBuiltin) + .unwrap() + .0 + ) + .unwrap(), + expected + ); + } + + #[test] + fn test_echo_expansion() { + assert_echo_expands_to("a", "a"); + assert_echo_expands_to(r"\M", "\\M"); + assert_echo_expands_to(r"a\nb", "a\nb"); + assert_echo_expands_to(r"\a", "\x07"); + assert_echo_expands_to(r"\b", "\x08"); + assert_echo_expands_to(r"\e", "\x1b"); + assert_echo_expands_to(r"\f", "\x0c"); + assert_echo_expands_to(r"\n", "\n"); + assert_echo_expands_to(r"\r", "\r"); + assert_echo_expands_to(r"\t", "\t"); + assert_echo_expands_to(r"\v", "\x0b"); + assert_echo_expands_to(r"\\", "\\"); + assert_echo_expands_to(r"\'", "\\'"); + assert_echo_expands_to(r#"\""#, r#"\""#); + assert_echo_expands_to(r"\?", "\\?"); + assert_echo_expands_to(r"\0", "\0"); + assert_echo_expands_to(r"\00", "\0"); + assert_echo_expands_to(r"\000", "\0"); + assert_echo_expands_to(r"\081", "\081"); + assert_echo_expands_to(r"\0101", "A"); + assert_echo_expands_to(r"abc\", "abc\\"); + assert_echo_expands_to(r"\x41", "A"); + assert_echo_expands_to(r"\xf0\x9f\x90\x8d", "🐍"); + assert_echo_expands_to(r"\u2620", "☠"); + assert_echo_expands_to(r"\U0001f602", "😂"); + } +} diff --git a/local/recipes/shells/brush/source/brush-core/src/expansion.rs b/local/recipes/shells/brush/source/brush-core/src/expansion.rs new file mode 100644 index 0000000000..139e501d6a --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/expansion.rs @@ -0,0 +1,2268 @@ +//! Word expansion utilities. + +use std::borrow::Cow; +use std::cmp::min; +use std::io::Write as _; + +use brush_parser::word::{ParameterTransformOp, SubstringMatchKind}; +use itertools::Itertools; + +use crate::ExecutionParameters; +use crate::arithmetic; +use crate::arithmetic::ExpandAndEvaluate; +use crate::braceexpansion; +use crate::commands; +use crate::env; +use crate::error; +use crate::escape; +use crate::extensions; +use crate::patterns; +use crate::prompt; +use crate::shell::Shell; +use crate::sys; +use crate::trace_categories; +use crate::variables::ShellValueUnsetType; +use crate::variables::ShellVariable; +use crate::variables::{self, ShellValue}; + +/// Controls how the expander handles a backslash-escape sequence (`\X`) +/// when it appears outside any explicit quoting (single, double, ANSI-C). +/// Inside actual double-quoted text, the parser's own escape rules apply +/// and this setting is ignored. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) enum UnquotedBackslashHandling { + /// Default unquoted shell semantics: the backslash is consumed and `X` + /// is preserved literally. + #[default] + Strip, + /// The backslash and `X` are both preserved verbatim. Useful in pattern + /// contexts where the matcher will interpret the backslash itself. + Preserve, + /// Treat `\X` as if it were inside a double-quoted string: the backslash + /// is consumed only when `X` is one of the characters that double-quote + /// syntax treats as escapable; otherwise both characters are preserved. + /// A backslash followed by a literal newline is a line continuation + /// (both characters are consumed). Used by prompt-string expansion. + DoubleQuoted, +} + +/// Options to customize the behavior of the word expander. +pub(crate) struct ExpanderOptions { + /// Whether to perform tilde-expansion. + pub tilde_expand: bool, + /// Whether to perform brace-expansion. + pub brace_expand: bool, + /// Whether to perform command substitutions. If disabled, command substitutions + /// are replaced with an empty string. + pub execute_command_substitutions: bool, + /// Whether to perform pathname expansion (globbing). If disabled, glob patterns + /// are returned as literal strings. + pub pathname_expand: bool, + /// How to handle backslash-escape sequences encountered outside of any + /// explicit quoting. See [`UnquotedBackslashHandling`] for details. + pub unquoted_backslash_handling: UnquotedBackslashHandling, +} + +impl Default for ExpanderOptions { + fn default() -> Self { + Self { + tilde_expand: true, + brace_expand: true, + execute_command_substitutions: true, + pathname_expand: true, + unquoted_backslash_handling: UnquotedBackslashHandling::default(), + } + } +} + +/// The set of characters that, when preceded by a backslash inside a +/// double-quoted string, form a valid escape sequence (the backslash is +/// consumed). Any other character's backslash is preserved literally. +/// Newline is a special case: `\` is a line continuation and +/// consumes both characters, leaving nothing. +pub(crate) const DOUBLE_QUOTED_ESCAPE_CHARS: &[char] = &['\\', '$', '`', '"', '\n']; + +#[derive(Debug)] +struct Expansion { + fields: Vec, + concatenate: bool, + from_array: bool, + undefined: bool, +} + +impl Default for Expansion { + fn default() -> Self { + Self { + fields: vec![], + concatenate: true, + from_array: false, + undefined: false, + } + } +} + +impl From for Expansion { + fn from(value: String) -> Self { + Self { + fields: vec![WordField::from(value)], + ..Self::default() + } + } +} + +impl From for Expansion { + fn from(piece: ExpansionPiece) -> Self { + Self { + fields: vec![WordField::from(piece)], + ..Self::default() + } + } +} + +impl Expansion { + fn classify(&self) -> ParameterState { + let non_empty = self + .fields + .iter() + .any(|field| field.0.iter().any(|piece| !piece.as_str().is_empty())); + + if self.undefined { + ParameterState::Undefined + } else if non_empty { + ParameterState::NonZeroLength + } else if self.fields.is_empty() { + // An array referenced via [@]/[*] that yields no fields (i.e. an empty + // array) is treated as unset by bash for the +/- conditional operators. + // This is distinct from a scalar holding an empty string, which has a + // single (empty) field and remains a defined empty string. + ParameterState::Undefined + } else { + ParameterState::DefinedEmptyString + } + } + + fn undefined() -> Self { + Self { + fields: vec![WordField::from(String::new())], + concatenate: true, + undefined: true, + from_array: false, + } + } + + fn polymorphic_len(&self) -> usize { + if self.from_array { + self.fields.len() + } else { + self.fields.iter().fold(0, |acc, field| acc + field.len()) + } + } + + fn polymorphic_subslice(&self, index: usize, end: usize) -> Self { + let len = end - index; + + // If we came from an array, then interpret `index` and `end` as indices + // into the elements. + if self.from_array { + let actual_len = min(len, self.fields.len() - index); + let fields = self.fields[index..(index + actual_len)].to_vec(); + + Self { + fields, + concatenate: self.concatenate, + undefined: self.undefined, + from_array: self.from_array, + } + } else { + // Otherwise, interpret `index` and `end` as indices into the string contents. + let mut fields = vec![]; + + // Keep track of how far away the interesting data is from the current read offset. + let mut dist_to_slice = index; + // Keep track of how many characters are left to be copied. + let mut left = len; + + // Go through fields, copying the interesting parts. + for field in &self.fields { + let mut pieces = vec![]; + + for piece in &field.0 { + // Stop once we've extracted enough characters. + if left == 0 { + break; + } + + // Get the inner string of the piece, and figure out how many + // characters are in it; make sure to get the *character count* + // and not just call `.len()` to get the byte count. + let piece_str = piece.as_str(); + let piece_char_count = piece_str.chars().count(); + + // If the interesting data isn't even in this piece yet, then + // continue until we find it. + if dist_to_slice >= piece_char_count { + dist_to_slice -= piece_char_count; + continue; + } + + // Figure out how far into this piece we're interested in copying. + let desired_offset_into_this_piece = dist_to_slice; + // Figure out how many characters we're going to use from *this* piece. + let len_from_this_piece = + min(left, piece_char_count - desired_offset_into_this_piece); + + let new_piece = match piece { + ExpansionPiece::Unsplittable(s) => ExpansionPiece::Unsplittable( + s.chars() + .skip(desired_offset_into_this_piece) + .take(len_from_this_piece) + .collect(), + ), + ExpansionPiece::Splittable(s) => ExpansionPiece::Splittable( + s.chars() + .skip(desired_offset_into_this_piece) + .take(len_from_this_piece) + .collect(), + ), + }; + + pieces.push(new_piece); + + left -= len_from_this_piece; + dist_to_slice = 0; + } + + if !pieces.is_empty() { + fields.push(WordField(pieces)); + } + } + + Self { + fields, + concatenate: self.concatenate, + undefined: self.undefined, + from_array: self.from_array, + } + } + } +} + +#[derive(Clone, Debug, Default, PartialEq)] +struct WordField(Vec); + +impl WordField { + pub const fn new() -> Self { + Self(vec![]) + } + + pub fn len(&self) -> usize { + self.0.iter().fold(0, |acc, piece| acc + piece.len()) + } +} + +impl From for String { + fn from(field: WordField) -> Self { + field.0.into_iter().map(Self::from).collect() + } +} + +impl From for patterns::Pattern { + fn from(value: WordField) -> Self { + let pieces: Vec<_> = value + .0 + .into_iter() + .map(patterns::PatternPiece::from) + .collect(); + + Self::from(pieces) + } +} + +impl From for WordField { + fn from(piece: ExpansionPiece) -> Self { + Self(vec![piece]) + } +} + +impl From for WordField { + fn from(value: String) -> Self { + Self(vec![ExpansionPiece::Splittable(value)]) + } +} + +#[derive(Clone, Debug, PartialEq)] +enum ExpansionPiece { + Unsplittable(String), + Splittable(String), +} + +impl From for String { + fn from(piece: ExpansionPiece) -> Self { + match piece { + ExpansionPiece::Unsplittable(s) => s, + ExpansionPiece::Splittable(s) => s, + } + } +} + +impl From for patterns::PatternPiece { + fn from(piece: ExpansionPiece) -> Self { + match piece { + ExpansionPiece::Unsplittable(s) => Self::Literal(s), + ExpansionPiece::Splittable(s) => Self::Pattern(s), + } + } +} + +impl From for crate::regex::RegexPiece { + fn from(piece: ExpansionPiece) -> Self { + match piece { + ExpansionPiece::Unsplittable(s) => Self::Literal(s), + ExpansionPiece::Splittable(s) => Self::Pattern(s), + } + } +} + +impl ExpansionPiece { + const fn as_str(&self) -> &str { + match self { + Self::Unsplittable(s) => s.as_str(), + Self::Splittable(s) => s.as_str(), + } + } + + const fn len(&self) -> usize { + match self { + Self::Unsplittable(s) => s.len(), + Self::Splittable(s) => s.len(), + } + } + + fn make_unsplittable(self) -> Self { + match self { + Self::Unsplittable(_) => self, + Self::Splittable(s) => Self::Unsplittable(s), + } + } +} + +enum ParameterState { + Undefined, + DefinedEmptyString, + NonZeroLength, +} + +/// Applies all basic expansion to the given word, yielding a pattern. +/// +/// # Arguments +/// +/// * `shell` - The shell in which to perform expansion. +/// * `params` - The execution parameters to use during expansion. +/// * `word_str` - The word to expand, as a string. +pub(crate) async fn basic_expand_pattern( + shell: &mut Shell, + params: &ExecutionParameters, + word_str: impl AsRef, +) -> Result { + // When expanding patterns, we do not want backslash removal to occur in unquoted + // contexts, as that would interfere with pattern syntax. + let options = ExpanderOptions { + unquoted_backslash_handling: UnquotedBackslashHandling::Preserve, + ..Default::default() + }; + let mut expander = WordExpander::new_from_options(shell, params, &options); + + expander.basic_expand_pattern(word_str.as_ref()).await +} + +/// Applies all basic expansion to the given word, yielding a regex. +/// +/// # Arguments +/// +/// * `shell` - The shell in which to perform expansion. +/// * `params` - The execution parameters to use during expansion. +/// * `word_str` - The word to expand, as a string. +pub(crate) async fn basic_expand_regex( + shell: &mut Shell, + params: &ExecutionParameters, + word_str: impl AsRef, +) -> Result { + let mut expander = WordExpander::new(shell, params); + + // Brace expansion does not appear to be used in regexes. + expander.disable_brace_expansion = true; + + expander.basic_expand_regex(word_str.as_ref()).await +} + +/// Applies all basic expansion to the given word (represented as a string). +/// +/// # Arguments +/// +/// * `shell` - The shell in which to perform expansion. +/// * `params` - The execution parameters to use during expansion. +/// * `word_str` - The word to expand, as a string. +pub(crate) async fn basic_expand_word( + shell: &mut Shell, + params: &ExecutionParameters, + word_str: impl AsRef, +) -> Result { + let mut expander = WordExpander::new(shell, params); + expander.basic_expand_to_str(word_str.as_ref()).await +} + +/// Expands a heredoc body. +/// +/// Performs parameter expansion, command substitution, and arithmetic expansion +/// on the heredoc content while preserving literal quote characters. Unlike +/// [`basic_expand_word`], this treats `"` and `'` as literal characters rather +/// than quote delimiters. +/// +/// # Arguments +/// +/// * `shell` - The shell in which to perform expansion. +/// * `params` - The execution parameters to use during expansion. +/// * `word_str` - The heredoc body to expand, as a string. +pub(crate) async fn basic_expand_heredoc_word( + shell: &mut Shell, + params: &ExecutionParameters, + word_str: impl AsRef, +) -> Result { + let mut expander = WordExpander::new(shell, params); + expander.heredoc_mode = true; + expander.disable_brace_expansion = true; + expander.basic_expand_to_str(word_str.as_ref()).await +} + +/// Applies all basic expansion to the given word (represented as a string), +/// with custom expander options. +/// +/// # Arguments +/// +/// * `shell` - The shell in which to perform expansion. +/// * `params` - The execution parameters to use during expansion. +/// * `word_str` - The word to expand, as a string. +pub(crate) async fn basic_expand_word_with_options( + shell: &mut Shell, + params: &ExecutionParameters, + word_str: impl AsRef, + options: &ExpanderOptions, +) -> Result { + let mut expander = WordExpander::new_from_options(shell, params, options); + expander.basic_expand_to_str(word_str.as_ref()).await +} + +/// Apply tilde-expansion, parameter expansion, command substitution, and arithmetic expansion; +/// then perform field splitting and pathname expansion on the result. +/// +/// # Arguments +/// +/// * `shell` - The shell in which to perform expansion. +/// * `params` - The execution parameters to use during expansion. +/// * `word_str` - The word to expand, as a string. +pub(crate) async fn full_expand_and_split_word( + shell: &mut Shell, + params: &ExecutionParameters, + word_str: impl AsRef, +) -> Result, error::Error> { + let mut expander = WordExpander::new(shell, params); + expander.full_expand_with_splitting(word_str.as_ref()).await +} + +/// Apply tilde-expansion, parameter expansion, command substitution, and arithmetic expansion; +/// then perform field splitting and pathname expansion on the result. +/// +/// # Arguments +/// +/// * `shell` - The shell in which to perform expansion. +/// * `params` - The execution parameters to use during expansion. +/// * `word_str` - The word to expand, as a string. +/// * `options` - Options to customize the behavior of the expander. +pub(crate) async fn full_expand_and_split_word_with_options( + shell: &mut Shell, + params: &ExecutionParameters, + word_str: impl AsRef, + options: &ExpanderOptions, +) -> Result, error::Error> { + let mut expander = WordExpander::new_from_options(shell, params, options); + expander.full_expand_with_splitting(word_str.as_ref()).await +} + +/// Expands a word in assignment context (enables tilde-after-colon expansion). +/// +/// # Arguments +/// +/// * `shell` - The shell in which to perform expansion. +/// * `params` - The execution parameters to use during expansion. +/// * `word_str` - The word to expand, as a string. +pub(crate) async fn basic_expand_assignment_word( + shell: &mut Shell, + params: &ExecutionParameters, + word_str: impl AsRef, +) -> Result { + let mut expander = WordExpander::new(shell, params); + expander.parser_options.tilde_expansion_after_colon = true; + expander.basic_expand_to_str(word_str.as_ref()).await +} + +/// Assigns a value to a named parameter. +/// +/// # Arguments +/// +/// * `shell` - The shell in which to perform the assignment. +/// * `params` - The execution parameters to use during the assignment. +/// * `name` - The name of the parameter to assign to. May be a variable name, or a more complex, +/// assignable parameter expression (e.g., an array element). +/// * `value` - The value to assign to the parameter. +pub async fn assign_to_named_parameter( + shell: &mut Shell, + params: &ExecutionParameters, + name: &str, + value: String, +) -> Result<(), error::Error> { + let parser_options = shell.parser_options(); + let mut expander = WordExpander::new(shell, params); + let parameter = brush_parser::word::parse_parameter(name, &parser_options)?; + expander.assign_to_parameter(¶meter, value).await +} + +struct WordExpander<'a, SE: extensions::ShellExtensions> { + /// The shell in which to perform expansion. + shell: &'a mut Shell, + /// The execution parameters to use during expansion. + params: &'a ExecutionParameters, + /// The parser options to use during expansion. + parser_options: brush_parser::ParserOptions, + /// Whether to disable brace expansion. + disable_brace_expansion: bool, + /// Whether to disable command substitutions. + disable_command_substitutions: bool, + /// Whether to disable pathname expansion (globbing). + disable_pathname_expansion: bool, + /// How to handle backslash-escape sequences encountered outside of any + /// explicit quoting. See [`UnquotedBackslashHandling`] for details. + unquoted_backslash_handling: UnquotedBackslashHandling, + /// Whether we are currently expanding inside a double-quoted context. + in_double_quotes: bool, + /// Whether to use heredoc expansion semantics (literal quotes, no brace expansion). + heredoc_mode: bool, +} + +impl<'a, SE: extensions::ShellExtensions> WordExpander<'a, SE> { + pub const fn new(shell: &'a mut Shell, params: &'a ExecutionParameters) -> Self { + let parser_options = shell.parser_options(); + Self { + shell, + params, + parser_options, + disable_brace_expansion: false, + disable_command_substitutions: false, + disable_pathname_expansion: false, + unquoted_backslash_handling: UnquotedBackslashHandling::Strip, + in_double_quotes: false, + heredoc_mode: false, + } + } + + pub const fn new_from_options( + shell: &'a mut Shell, + params: &'a ExecutionParameters, + options: &ExpanderOptions, + ) -> Self { + let mut parser_options = shell.parser_options(); + + if !options.tilde_expand { + parser_options.tilde_expansion_at_word_start = false; + parser_options.tilde_expansion_after_colon = false; + } + + Self { + shell, + params, + parser_options, + disable_brace_expansion: !options.brace_expand, + disable_command_substitutions: !options.execute_command_substitutions, + disable_pathname_expansion: !options.pathname_expand, + unquoted_backslash_handling: options.unquoted_backslash_handling, + in_double_quotes: false, + heredoc_mode: false, + } + } + + /// Apply tilde-expansion, parameter expansion, command substitution, and arithmetic expansion. + pub async fn basic_expand_to_str(&mut self, word: &str) -> Result { + let expansion = self.basic_expand(word).await?; + Ok(self.fields_to_string(expansion)) + } + + /// Join an [`Expansion`]'s fields into a single string for scalar + /// (non-field-splitting) contexts, following bash rules: `$*`/`${a[*]}` + /// (`concatenate=true`) join with the first character of IFS, while + /// `$@`/`${a[@]}` join with a space. A single field (the common case) is + /// unaffected. Without this, e.g. `x=${arr[*]}` under `IFS=:` would yield + /// `a b c` instead of bash's `a:b:c`. + fn fields_to_string(&self, expansion: Expansion) -> String { + let joiner = if expansion.concatenate { + self.shell.get_ifs_first_char() + } else { + ' ' + }; + expansion + .fields + .into_iter() + .map(String::from) + .join(joiner.to_string().as_str()) + } + + async fn basic_expand_opt_pattern( + &mut self, + word: Option<&str>, + ) -> Result, error::Error> { + if let Some(word) = word { + let pattern = self + .basic_expand_pattern(word) + .await? + .set_extended_globbing(self.parser_options.enable_extended_globbing); + + Ok(Some(pattern)) + } else { + Ok(None) + } + } + + async fn basic_expand_pattern( + &mut self, + word: &str, + ) -> Result { + let expansion = self.basic_expand(word).await?; + + // TODO(IFS): Use IFS instead for separator? + #[expect(unstable_name_collisions)] + let pattern_pieces: Vec<_> = expansion + .fields + .into_iter() + .map(|field| { + field + .0 + .into_iter() + .map(patterns::PatternPiece::from) + .collect::>() + }) + .intersperse(vec![patterns::PatternPiece::Literal(String::from(" "))]) + .flatten() + .collect(); + + let pattern = patterns::Pattern::from(pattern_pieces); + + Ok(pattern) + } + + async fn basic_expand_regex( + &mut self, + word: &str, + ) -> Result { + let expansion = self.basic_expand(word).await?; + + // TODO(IFS): Use IFS instead for separator? + #[expect(unstable_name_collisions)] + let regex_pieces: Vec<_> = expansion + .fields + .into_iter() + .map(|field| { + field + .0 + .into_iter() + .map(crate::regex::RegexPiece::from) + .collect::>() + }) + .intersperse(vec![crate::regex::RegexPiece::Literal(String::from(" "))]) + .flatten() + .collect(); + + Ok(crate::regex::Regex::from(regex_pieces) + .set_case_insensitive(self.shell.options().case_insensitive_conditionals)) + } + + /// Apply tilde-expansion, parameter expansion, command substitution, and arithmetic expansion; + /// yield pieces that could be further processed. + async fn basic_expand(&mut self, word: &str) -> Result { + tracing::debug!(target: trace_categories::EXPANSION, "Basic expanding: '{word}'"); + + // Quick short circuit to avoid more expensive parsing. The characters below are + // understood to be the *only* ones indicative of *possible* expansion. There's + // still a possibility no expansion needs to be done, but that's okay; we'll still + // yield a correct result. + let expansion_chars: &[char] = if self.heredoc_mode { + // Heredoc bodies treat quotes as literal; only $, `, and \ trigger expansion. + &['$', '`', '\\'] + } else { + &['$', '`', '\\', '\'', '\"', '~', '{'] + }; + if !word.contains(expansion_chars) { + return Ok(Expansion::from(ExpansionPiece::Splittable(word.to_owned()))); + } + + // Apply brace expansion first, before anything else (not applicable to heredoc bodies). + let brace_expanded = self.brace_expand_if_needed(word)?; + if tracing::enabled!(target: trace_categories::EXPANSION, tracing::Level::DEBUG) + && brace_expanded != word + { + tracing::debug!(target: trace_categories::EXPANSION, " => brace expanded to '{brace_expanded}'"); + } + + // Expand: tildes, parameters, command substitutions, arithmetic. + let pieces = if self.heredoc_mode { + // Heredoc mode only affects top-level parsing (literal quotes); recursive + // expansion of parameter words (e.g., ${var:-"default"}) uses normal semantics. + self.heredoc_mode = false; + + brush_parser::word::parse_heredoc(brace_expanded.as_ref(), &self.parser_options)? + } else { + brush_parser::word::parse(brace_expanded.as_ref(), &self.parser_options)? + }; + + let mut expansions = Vec::with_capacity(pieces.len()); + for piece in pieces { + let piece_expansion = self.expand_word_piece(piece.piece).await?; + expansions.push(piece_expansion); + } + + let coalesced = coalesce_expansions(expansions); + + Ok(coalesced) + } + + /// Expand a word used inside a parameter expansion (like the word in ${param:+word}). + /// When we're already inside double-quotes, we preserve literal backslashes and quotes + /// (except those escaped in ways valid in double-quotes) but still expand parameters, + /// command substitutions, and arithmetic. + async fn expand_parameter_word(&mut self, word: &str) -> Result { + if self.in_double_quotes { + // When inside double-quotes, we need to parse the word with double-quote semantics. + // If the word already starts with a double-quote, we need to remove those quotes + // and expand what's inside with normal (non-double-quote) semantics. + if let Some(stripped) = word.strip_prefix('"') + && let Some(inner) = stripped.strip_suffix('"') + { + // Remove the surrounding double-quotes and expand the content normally + // This requires us to temporarily clear in_double_quotes so the inner + // content gets normal processing. + let previously_in_double_quotes = self.in_double_quotes; + self.in_double_quotes = false; + + // Now perform the expansion and make sure to restore the previous state, + // even if the expansion fails. + let result = self.basic_expand(inner).await; + self.in_double_quotes = previously_in_double_quotes; + + result + } else { + // Not double-quoted - wrap in double-quotes to get double-quote parsing semantics + let wrapped = std::format!("\"{word}\""); + self.basic_expand(&wrapped).await + } + } else { + // When not inside double-quotes, perform normal expansion with quote removal + self.basic_expand(word).await + } + } + + fn brace_expand_if_needed(&self, word: &'a str) -> Result, error::Error> { + // We perform a non-authoritative check to see if the string *may* contain braces + // to expand. There may be false positives, but must be no false negatives. + if self.disable_brace_expansion + || !self.shell.options().perform_brace_expansion + || !may_contain_braces_to_expand(word) + { + return Ok(word.into()); + } + + let parse_result = brush_parser::word::parse_brace_expansions(word, &self.parser_options); + if parse_result.is_err() { + tracing::error!("failed to parse for brace expansion: {parse_result:?}"); + return Ok(word.into()); + } + + let brace_expansion_pieces = parse_result?; + let Some(brace_expansion_pieces) = brace_expansion_pieces else { + return Ok(word.into()); + }; + + tracing::debug!(target: trace_categories::EXPANSION, "Brace expansion pieces: {brace_expansion_pieces:?}"); + + let result = braceexpansion::generate_and_combine_brace_expansions(brace_expansion_pieces) + .into_iter() + .map(|s| if s.is_empty() { "\"\"".into() } else { s }) + .join(" "); + + Ok(result.into()) + } + + /// Apply tilde-expansion, parameter expansion, command substitution, and arithmetic expansion; + /// then perform field splitting and pathname expansion. + pub async fn full_expand_with_splitting( + &mut self, + word: &str, + ) -> Result, error::Error> { + // Perform basic expansion first. + let basic_expansion = self.basic_expand(word).await?; + + // Then split. + let fields: Vec = self.split_fields(basic_expansion); + + // Now expand pathnames if necessary. This also unquotes as a side effect. + // We also know a length that the vector may be at minimally. + let mut result = Vec::with_capacity(fields.len()); + for field in fields { + if self.disable_pathname_expansion || self.shell.options().disable_filename_globbing { + result.push(String::from(field)); + } else { + result.extend(self.expand_pathnames_in_field(field)?); + } + } + + Ok(result) + } + + fn split_fields(&self, expansion: Expansion) -> Vec { + let ifs = self.shell.ifs(); + + let mut fields: Vec = vec![]; + let mut current_field = WordField::new(); + + // Go through the fields we have so far. + for existing_field in expansion.fields { + for piece in existing_field.0 { + match piece { + ExpansionPiece::Unsplittable(_) => current_field.0.push(piece), + ExpansionPiece::Splittable(s) => { + for c in s.chars() { + if ifs.contains(c) { + if !current_field.0.is_empty() { + fields.push(std::mem::take(&mut current_field)); + } + } else { + match current_field.0.last_mut() { + Some(ExpansionPiece::Splittable(last)) => last.push(c), + Some(ExpansionPiece::Unsplittable(_)) | None => { + current_field + .0 + .push(ExpansionPiece::Splittable(c.to_string())); + } + } + } + } + } + } + } + + if !current_field.0.is_empty() { + fields.push(std::mem::take(&mut current_field)); + } + } + + fields + } + + fn expand_pathnames_in_field(&self, field: WordField) -> Result, error::Error> { + let pattern = patterns::Pattern::from(field.clone()) + .set_extended_globbing(self.parser_options.enable_extended_globbing) + .set_case_insensitive(self.shell.options().case_insensitive_pathname_expansion); + + let options = patterns::FilenameExpansionOptions { + require_dot_in_pattern_to_match_dot_files: !self.shell.options().glob_matches_dotfiles, + }; + + // On error (e.g. malformed pattern), default to NoGlob so the field + // passes through as a literal rather than triggering failglob. + let expansion = pattern + .expand( + self.shell.working_dir(), + Some(&patterns::Pattern::accept_all_expand_filter), + &options, + ) + .unwrap_or_default(); + + if expansion.is_unmatched_glob() + && self.shell.options().fail_expansion_on_globs_without_match + { + let field_str = String::from(field); + return Err(error::ErrorKind::NoMatch(field_str).into()); + } + + let paths = expansion.into_paths(); + if paths.is_empty() { + if self.shell.options().expand_non_matching_patterns_to_null { + Ok(vec![]) + } else { + Ok(vec![String::from(field)]) + } + } else { + Ok(paths) + } + } + + #[async_recursion::async_recursion] + async fn expand_word_piece( + &mut self, + word_piece: brush_parser::word::WordPiece, + ) -> Result { + let expansion: Expansion = match word_piece { + brush_parser::word::WordPiece::Text(s) => { + Expansion::from(ExpansionPiece::Splittable(s)) + } + brush_parser::word::WordPiece::SingleQuotedText(s) => { + Expansion::from(ExpansionPiece::Unsplittable(s)) + } + brush_parser::word::WordPiece::AnsiCQuotedText(s) => { + let (expanded, _) = escape::expand_backslash_escapes( + s.as_str(), + escape::EscapeExpansionMode::AnsiCQuotes, + )?; + Expansion::from(ExpansionPiece::Unsplittable( + String::from_utf8_lossy(expanded.as_slice()).into_owned(), + )) + } + brush_parser::word::WordPiece::DoubleQuotedSequence(pieces) + | brush_parser::word::WordPiece::GettextDoubleQuotedSequence(pieces) => { + let pieces_is_empty = pieces.is_empty(); + + // Save the previous state and set the flag + let previously_in_double_quotes = self.in_double_quotes; + self.in_double_quotes = true; + + // Process pieces; don't inspect the result yet, so we can make + // sure we restore the previous value of the 'in_double_quotes' flag. + let result = self.process_double_quoted_pieces(pieces).await; + + // Restore the previous state + self.in_double_quotes = previously_in_double_quotes; + + // Now we can inspect the result. + let mut fields = result?; + + // If there were no pieces, then make sure we yield a single field containing an + // empty, unsplittable string. + if pieces_is_empty { + fields.push(WordField::from(ExpansionPiece::Unsplittable(String::new()))); + } + + Expansion { + fields, + concatenate: false, + undefined: false, + from_array: false, + } + } + brush_parser::word::WordPiece::TildeExpansion(tilde_expr) => { + Expansion::from(ExpansionPiece::Unsplittable( + self.expand_tilde_expression(&tilde_expr)?.to_string(), + )) + } + brush_parser::word::WordPiece::ParameterExpansion(p) => { + self.expand_parameter_expr(p).await? + } + brush_parser::word::WordPiece::BackquotedCommandSubstitution(s) + | brush_parser::word::WordPiece::CommandSubstitution(s) => { + let mut cmd_output = if !self.disable_command_substitutions { + commands::invoke_command_in_subshell_and_get_output(self.shell, self.params, s) + .await? + } else { + String::new() + }; + + // Strips null bytes from command substitution output for compatibility. + if cmd_output.contains('\0') { + writeln!( + self.params.stderr(self.shell), + "warning: command substitution: ignored null byte in input", + )?; + cmd_output.retain(|c| c != '\0'); + } + + // We trim trailing newlines, per spec. + let trimmed_len = cmd_output.trim_end_matches('\n').len(); + cmd_output.truncate(trimmed_len); + + Expansion::from(ExpansionPiece::Splittable(cmd_output)) + } + brush_parser::word::WordPiece::EscapeSequence(s) => { + let Some(escaped) = s.strip_prefix('\\') else { + // We don't ever expect this case, as it breaks our invariant--but + // we handle it to avoid panicking. + return Ok(Expansion::from(ExpansionPiece::Unsplittable(s))); + }; + + // Inside actual double-quoted text, the parser only emits an + // EscapeSequence for `\X` where X is double-quote-escapable; + // we always strip the backslash there. + if self.in_double_quotes { + return Ok(Expansion::from(ExpansionPiece::Unsplittable( + escaped.to_owned(), + ))); + } + + match self.unquoted_backslash_handling { + UnquotedBackslashHandling::Strip => { + // Default unquoted semantics: consume the backslash. + Expansion::from(ExpansionPiece::Unsplittable(escaped.to_owned())) + } + UnquotedBackslashHandling::Preserve => { + // Preserve `\X` verbatim (e.g., for pattern syntax). + Expansion::from(ExpansionPiece::Splittable(s)) + } + UnquotedBackslashHandling::DoubleQuoted => { + // `\` is a line continuation: both characters + // are consumed. + if escaped.starts_with('\n') { + Expansion::from(ExpansionPiece::Unsplittable(String::new())) + } else if escaped.starts_with(DOUBLE_QUOTED_ESCAPE_CHARS) { + // Consume the backslash; preserve the next char. + Expansion::from(ExpansionPiece::Unsplittable(escaped.to_owned())) + } else { + // Preserve both characters. + Expansion::from(ExpansionPiece::Unsplittable(s)) + } + } + } + } + brush_parser::word::WordPiece::ArithmeticExpression(e) => Expansion::from( + ExpansionPiece::Splittable(self.expand_arithmetic_expr(e).await?), + ), + }; + + Ok(expansion) + } + + fn expand_tilde_expression( + &self, + tilde_expr: &brush_parser::word::TildeExpr, + ) -> Result, error::Error> { + match tilde_expr { + brush_parser::word::TildeExpr::Home => { + if let Some(home_dir) = self.shell.home_dir() { + Ok(Cow::Owned(home_dir.to_string_lossy().to_string())) + } else { + Err(error::ErrorKind::TildeWithoutValidHome.into()) + } + } + brush_parser::word::TildeExpr::UserHome(username) => { + Ok(sys::users::get_user_home_dir(username).map_or_else( + || Cow::Owned(std::format!("~{username}")), + |p| Cow::Owned(p.to_string_lossy().to_string()), + )) + } + brush_parser::word::TildeExpr::WorkingDir => { + Ok(self.shell.working_dir().to_string_lossy()) + } + brush_parser::word::TildeExpr::OldWorkingDir => { + if let Some(old_pwd) = self.shell.env_str("OLDPWD") { + Ok(old_pwd) + } else { + Ok(Cow::Borrowed("~-")) + } + } + brush_parser::word::TildeExpr::NthDirFromBottomOfDirStack { n } => { + let dir_stack_count = self.shell.directory_stack().len(); + + if let Some(dir) = self.shell.directory_stack().get(*n) { + Ok(dir.to_string_lossy()) + } else if *n == dir_stack_count { + Ok(self.shell.working_dir().to_string_lossy()) + } else { + Ok(Cow::Owned(std::format!("~-{n}"))) + } + } + brush_parser::word::TildeExpr::NthDirFromTopOfDirStack { n, plus_used } => { + if *n == 0 { + return Ok(self.shell.working_dir().to_string_lossy()); + } + + let dir_stack_count = self.shell.directory_stack().len(); + if dir_stack_count >= *n + && let Some(dir) = self.shell.directory_stack().get(dir_stack_count - *n) + { + return Ok(dir.to_string_lossy()); + } + + let plus_or_nothing = if *plus_used { "+" } else { "" }; + Ok(Cow::Owned(std::format!("~{plus_or_nothing}{n}"))) + } + } + } + + /// Helper function to process pieces within a double-quoted sequence. + /// This ensures proper handling of concatenation and field building. + async fn process_double_quoted_pieces( + &mut self, + pieces: Vec, + ) -> Result, error::Error> { + let mut fields: Vec = vec![]; + let concatenation_joiner = self.shell.get_ifs_first_char(); + + for piece in pieces { + let Expansion { + fields: this_fields, + concatenate, + .. + } = self.expand_word_piece(piece.piece).await?; + + let fields_to_append = if concatenate { + #[expect(unstable_name_collisions)] + let mut concatenated: Vec = this_fields + .into_iter() + .map(|WordField(pieces)| { + pieces + .into_iter() + .map(|piece| piece.make_unsplittable()) + .collect() + }) + .intersperse(vec![ExpansionPiece::Unsplittable( + concatenation_joiner.to_string(), + )]) + .flatten() + .collect(); + + // If there were no pieces, make sure there's an empty string after + // concatenation. + if concatenated.is_empty() { + concatenated.push(ExpansionPiece::Splittable(String::new())); + } + + vec![WordField(concatenated)] + } else { + this_fields + }; + + for (i, WordField(next_pieces)) in fields_to_append.into_iter().enumerate() { + // Flip to unsplittable. + let mut next_pieces: Vec<_> = next_pieces + .into_iter() + .map(|piece| piece.make_unsplittable()) + .collect(); + + if i == 0 + && let Some(WordField(last_pieces)) = fields.last_mut() + { + last_pieces.append(&mut next_pieces); + continue; + } + + fields.push(WordField(next_pieces)); + } + } + + Ok(fields) + } + + #[expect(clippy::too_many_lines)] + async fn expand_parameter_expr( + &mut self, + expr: brush_parser::word::ParameterExpr, + ) -> Result { + #[expect(clippy::cast_possible_truncation)] + match expr { + brush_parser::word::ParameterExpr::Parameter { + parameter, + indirect, + } => self.expand_parameter(¶meter, indirect).await, + brush_parser::word::ParameterExpr::UseDefaultValues { + parameter, + indirect, + test_type, + default_value, + } => { + let expanded_parameter = self + .expand_parameter_allowing_unset(¶meter, indirect) + .await?; + let default_value = default_value.as_ref().map_or("", |v| v.as_str()); + + match (test_type, expanded_parameter.classify()) { + (_, ParameterState::NonZeroLength) + | ( + brush_parser::word::ParameterTestType::Unset, + ParameterState::DefinedEmptyString, + ) => Ok(expanded_parameter), + _ => Ok(self.expand_parameter_word(default_value).await?), + } + } + brush_parser::word::ParameterExpr::AssignDefaultValues { + parameter, + indirect, + test_type, + default_value, + } => { + let expanded_parameter = self + .expand_parameter_allowing_unset(¶meter, indirect) + .await?; + let default_value = default_value.as_ref().map_or("", |v| v.as_str()); + + match (test_type, expanded_parameter.classify()) { + (_, ParameterState::NonZeroLength) + | ( + brush_parser::word::ParameterTestType::Unset, + ParameterState::DefinedEmptyString, + ) => Ok(expanded_parameter), + _ => { + let expanded_default = self.expand_parameter_word(default_value).await?; + let expanded_default_value = self.fields_to_string(expanded_default); + self.assign_to_parameter(¶meter, expanded_default_value.clone()) + .await?; + Ok(Expansion::from(expanded_default_value)) + } + } + } + brush_parser::word::ParameterExpr::IndicateErrorIfNullOrUnset { + parameter, + indirect, + test_type, + error_message, + } => { + let expanded_parameter = self + .expand_parameter_allowing_unset(¶meter, indirect) + .await?; + let error_message = error_message.as_ref().map_or("", |v| v.as_str()); + + match (test_type, expanded_parameter.classify()) { + (_, ParameterState::NonZeroLength) + | ( + brush_parser::word::ParameterTestType::Unset, + ParameterState::DefinedEmptyString, + ) => Ok(expanded_parameter), + _ => { + let result = self.basic_expand_to_str(error_message).await?; + let err: error::Error = + error::ErrorKind::CheckedExpansionError(result).into(); + + // Expansion errors are fatal per POSIX spec + Err(err.into_fatal()) + } + } + } + brush_parser::word::ParameterExpr::UseAlternativeValue { + parameter, + indirect, + test_type, + alternative_value, + } => { + let expanded_parameter = self + .expand_parameter_allowing_unset(¶meter, indirect) + .await?; + let alternative_value = alternative_value.as_ref().map_or("", |v| v.as_str()); + + match (test_type, expanded_parameter.classify()) { + (_, ParameterState::NonZeroLength) + | ( + brush_parser::word::ParameterTestType::Unset, + ParameterState::DefinedEmptyString, + ) => Ok(self.expand_parameter_word(alternative_value).await?), + _ => Ok(Expansion::from(String::new())), + } + } + brush_parser::word::ParameterExpr::ParameterLength { + parameter, + indirect, + } => { + // In bash, ${#arr[i]} returns 0 for unset elements of a + // declared array even with `set -u`. But ${#unset_var} still + // errors. Allow unset only for array element/all-indices + // access on variables that exist. + let allow_unset = match ¶meter { + brush_parser::word::Parameter::NamedWithIndex { name, .. } + | brush_parser::word::Parameter::NamedWithAllIndices { name, .. } => { + self.shell.env().get(name).is_some() + } + _ => false, + }; + let expansion = if allow_unset { + self.expand_parameter_allowing_unset(¶meter, indirect) + .await? + } else { + self.expand_parameter(¶meter, indirect).await? + }; + Ok(Expansion::from(expansion.polymorphic_len().to_string())) + } + brush_parser::word::ParameterExpr::RemoveSmallestSuffixPattern { + parameter, + indirect, + pattern, + } => { + let expanded_parameter = self.expand_parameter(¶meter, indirect).await?; + let expanded_pattern = self.basic_expand_opt_pattern(pattern.as_deref()).await?; + transform_expansion(expanded_parameter, async |s| { + patterns::remove_smallest_matching_suffix(s.as_str(), expanded_pattern.as_ref()) + .map(|s| s.to_owned()) + }) + .await + } + brush_parser::word::ParameterExpr::RemoveLargestSuffixPattern { + parameter, + indirect, + pattern, + } => { + let expanded_parameter = self.expand_parameter(¶meter, indirect).await?; + let expanded_pattern = self.basic_expand_opt_pattern(pattern.as_deref()).await?; + transform_expansion(expanded_parameter, async |s| { + patterns::remove_largest_matching_suffix(s.as_str(), expanded_pattern.as_ref()) + .map(|s| s.to_owned()) + }) + .await + } + brush_parser::word::ParameterExpr::RemoveSmallestPrefixPattern { + parameter, + indirect, + pattern, + } => { + let expanded_parameter = self.expand_parameter(¶meter, indirect).await?; + let expanded_pattern = self.basic_expand_opt_pattern(pattern.as_deref()).await?; + + transform_expansion(expanded_parameter, async |s| { + patterns::remove_smallest_matching_prefix(s.as_str(), expanded_pattern.as_ref()) + .map(|s| s.to_owned()) + }) + .await + } + brush_parser::word::ParameterExpr::RemoveLargestPrefixPattern { + parameter, + indirect, + pattern, + } => { + let expanded_parameter = self.expand_parameter(¶meter, indirect).await?; + let expanded_pattern = self.basic_expand_opt_pattern(pattern.as_deref()).await?; + + transform_expansion(expanded_parameter, async |s| { + patterns::remove_largest_matching_prefix(s.as_str(), expanded_pattern.as_ref()) + .map(|s| s.to_owned()) + }) + .await + } + brush_parser::word::ParameterExpr::Substring { + parameter, + indirect, + offset, + length, + } => { + let mut expanded_parameter = self.expand_parameter(¶meter, indirect).await?; + + // If this is ${@:...} then make sure $0 is in the array being sliced. + if matches!( + parameter, + brush_parser::word::Parameter::Special( + brush_parser::word::SpecialParameter::AllPositionalParameters { + concatenate: _ + }, + ) + ) { + let shell_name = self.shell.current_shell_name().unwrap_or_else(|| "".into()); + + expanded_parameter.fields.insert( + 0, + WordField::from(ExpansionPiece::Splittable(shell_name.to_string())), + ); + } + + #[expect(clippy::cast_possible_wrap)] + let expanded_parameter_len = expanded_parameter.polymorphic_len() as i64; + let mut expanded_offset = offset.eval(self.shell, self.params, false).await?; + + // We handle negative indexes as offsets from the end of the element, with -1 + // referencing the last element. + if expanded_offset < 0 { + expanded_offset += expanded_parameter_len; + + // If the offset is still negative, then we need to yield an empty slice. + // We force the offset to the end of the array. + if expanded_offset < 0 { + expanded_offset = expanded_parameter_len; + } + } + + // Make sure the offset is within the bounds of the item. + let expanded_offset = min(expanded_offset, expanded_parameter_len); + + let end_offset = if let Some(length) = length { + let mut expanded_length = length.eval(self.shell, self.params, false).await?; + if expanded_length < 0 { + expanded_length += expanded_parameter_len; + } + + let expanded_length = + min(expanded_length, expanded_parameter_len - expanded_offset); + + expanded_offset + expanded_length + } else { + expanded_parameter_len + }; + + #[expect(clippy::cast_sign_loss)] + Ok(expanded_parameter + .polymorphic_subslice(expanded_offset as usize, end_offset as usize)) + } + brush_parser::word::ParameterExpr::Transform { + parameter, + indirect, + op: ParameterTransformOp::ToAttributeFlags, + } => { + if let (_, _, Some(var)) = self + .try_resolve_parameter_to_variable(¶meter, indirect) + .await? + { + Ok(var.attribute_flags(self.shell).into()) + } else { + Ok(String::new().into()) + } + } + brush_parser::word::ParameterExpr::Transform { + parameter, + indirect, + op: ParameterTransformOp::ToAssignmentLogic, + } => { + if let (Some(name), index, Some(var)) = self + .try_resolve_parameter_to_variable(¶meter, indirect) + .await? + { + let assignable_value_str = var + .value() + .to_assignable_str(index.as_deref(), self.shell)?; + + let mut attr_str = var.attribute_flags(self.shell); + if attr_str.is_empty() { + attr_str.push('-'); + } + + match var.value() { + ShellValue::IndexedArray(_) + | ShellValue::AssociativeArray(_) + // TODO(dynamic): confirm this + | ShellValue::Dynamic { .. } => { + let equals_or_nothing = if assignable_value_str.is_empty() { + "" + } else { + "=" + }; + + Ok(std::format!( + "declare -{attr_str} {name}{equals_or_nothing}{assignable_value_str}" + ) + .into()) + } + ShellValue::String(_) => { + Ok(std::format!("{name}={assignable_value_str}").into()) + } + ShellValue::Unset(_) => { + Ok(std::format!("declare -{attr_str} {name}").into()) + } + } + } else { + Ok(String::new().into()) + } + } + brush_parser::word::ParameterExpr::Transform { + parameter, + indirect, + op, + } => { + let expanded_parameter = self.expand_parameter(¶meter, indirect).await?; + let came_from_undefined = expanded_parameter.undefined; + + // + // For typing reasons (issues with FnMut and our mut use of self), we can't use + // transform_expansion. Instead, we inline its logic here. + // + + let mut transformed_fields = vec![]; + for field in expanded_parameter.fields { + let s = String::from(field); + let transformed = self.apply_transform_to(&op, s, came_from_undefined).await?; + transformed_fields.push(WordField::from(transformed)); + } + + Ok(Expansion { + fields: transformed_fields, + concatenate: expanded_parameter.concatenate, + from_array: expanded_parameter.from_array, + undefined: expanded_parameter.undefined, + }) + } + brush_parser::word::ParameterExpr::UppercaseFirstChar { + parameter, + indirect, + pattern, + } => { + let expanded_parameter = self.expand_parameter(¶meter, indirect).await?; + let expanded_pattern = self.basic_expand_opt_pattern(pattern.as_deref()).await?; + + transform_expansion(expanded_parameter, async |s| { + Self::pattern_to_first_char(s, expanded_pattern.as_ref(), |c| c.to_uppercase()) + }) + .await + } + brush_parser::word::ParameterExpr::UppercasePattern { + parameter, + indirect, + pattern, + } => { + let expanded_parameter = self.expand_parameter(¶meter, indirect).await?; + let expanded_pattern = self.basic_expand_opt_pattern(pattern.as_deref()).await?; + + transform_expansion(expanded_parameter, async |s| { + Self::pattern_to_string(s.as_str(), expanded_pattern.as_ref(), |str| { + str.to_uppercase() + }) + }) + .await + } + brush_parser::word::ParameterExpr::LowercaseFirstChar { + parameter, + indirect, + pattern, + } => { + let expanded_parameter = self.expand_parameter(¶meter, indirect).await?; + let expanded_pattern = self.basic_expand_opt_pattern(pattern.as_deref()).await?; + + transform_expansion(expanded_parameter, async |s| { + Self::pattern_to_first_char(s, expanded_pattern.as_ref(), |c| c.to_lowercase()) + }) + .await + } + brush_parser::word::ParameterExpr::LowercasePattern { + parameter, + indirect, + pattern, + } => { + let expanded_parameter = self.expand_parameter(¶meter, indirect).await?; + let expanded_pattern = self.basic_expand_opt_pattern(pattern.as_deref()).await?; + + transform_expansion(expanded_parameter, async |s| { + Self::pattern_to_string(s.as_str(), expanded_pattern.as_ref(), |str| { + str.to_lowercase() + }) + }) + .await + } + brush_parser::word::ParameterExpr::ReplaceSubstring { + parameter, + indirect, + pattern, + replacement, + match_kind, + } => { + let expanded_parameter = self.expand_parameter(¶meter, indirect).await?; + let expanded_pattern = self + .basic_expand_pattern(pattern.as_str()) + .await? + .set_extended_globbing(self.parser_options.enable_extended_globbing) + .set_case_insensitive(self.shell.options().case_insensitive_conditionals); + + // If no replacement was provided, then we replace with an empty string. + let replacement = replacement.unwrap_or(String::new()); + let expanded_replacement = self.basic_expand_to_str(&replacement).await?; + + let regex = expanded_pattern.to_regex( + matches!(match_kind, brush_parser::word::SubstringMatchKind::Prefix), + matches!(match_kind, brush_parser::word::SubstringMatchKind::Suffix), + )?; + + transform_expansion(expanded_parameter, async |s| { + Ok(Self::replace_substring( + s.as_str(), + ®ex, + expanded_replacement.as_str(), + &match_kind, + )) + }) + .await + } + brush_parser::word::ParameterExpr::VariableNames { + prefix, + concatenate, + } => { + if prefix.is_empty() { + Ok(Expansion::from(String::new())) + } else { + let matching_names = self + .shell + .env() + .iter() + .filter_map(|(name, _)| { + if name.starts_with(prefix.as_str()) { + Some(name.to_owned()) + } else { + None + } + }) + .sorted(); + + Ok(Expansion { + fields: matching_names + .into_iter() + .map(|name| WordField(vec![ExpansionPiece::Splittable(name)])) + .collect(), + concatenate, + from_array: true, + undefined: false, + }) + } + } + brush_parser::word::ParameterExpr::MemberKeys { + variable_name, + concatenate, + } => { + let keys = if let Some((_, var)) = self.shell.env().get(variable_name) { + var.value().element_keys(self.shell) + } else { + vec![] + }; + + Ok(Expansion { + fields: keys + .into_iter() + .map(|key| WordField(vec![ExpansionPiece::Splittable(key)])) + .collect(), + concatenate, + from_array: true, + undefined: false, + }) + } + } + } + + async fn assign_to_parameter>( + &mut self, + parameter: &brush_parser::word::Parameter, + value: T, + ) -> Result<(), error::Error> { + let (variable_name, index) = match parameter { + brush_parser::word::Parameter::Named(name) => (name, None), + brush_parser::word::Parameter::NamedWithIndex { name, index } => { + let is_set_assoc_array = if let Some((_, var)) = self.shell.env().get(name) { + matches!( + var.value(), + ShellValue::AssociativeArray(_) + | ShellValue::Unset(ShellValueUnsetType::AssociativeArray) + ) + } else { + false + }; + + let index_to_use = self + .expand_array_index(index.as_str(), is_set_assoc_array) + .await?; + (name, Some(index_to_use)) + } + brush_parser::word::Parameter::Positional(_) + | brush_parser::word::Parameter::NamedWithAllIndices { + name: _, + concatenate: _, + } + | brush_parser::word::Parameter::Special(_) => { + return Err(error::ErrorKind::CannotAssignToSpecialParameter.into()); + } + }; + + let value = value.into(); + + if let Some(index) = index { + self.shell.env_mut().update_or_add_array_element( + variable_name, + index, + value, + |_| Ok(()), + env::EnvironmentLookup::Anywhere, + env::EnvironmentScope::Global, + ) + } else { + self.shell.env_mut().update_or_add( + variable_name, + variables::ShellValueLiteral::Scalar(value), + |_| Ok(()), + env::EnvironmentLookup::Anywhere, + env::EnvironmentScope::Global, + ) + } + } + + async fn try_resolve_parameter_to_variable( + &mut self, + parameter: &brush_parser::word::Parameter, + indirect: bool, + ) -> Result<(Option, Option, Option), error::Error> { + if !indirect { + Ok(self.try_resolve_parameter_to_variable_without_indirect(parameter)) + } else { + let expansion = self.expand_parameter(parameter, false).await?; + let parameter_str: String = self.fields_to_string(expansion); + let inner_parameter = + brush_parser::word::parse_parameter(parameter_str.as_str(), &self.parser_options)?; + Ok(self.try_resolve_parameter_to_variable_without_indirect(&inner_parameter)) + } + } + + fn try_resolve_parameter_to_variable_without_indirect( + &self, + parameter: &brush_parser::word::Parameter, + ) -> (Option, Option, Option) { + let (name, index) = match parameter { + brush_parser::word::Parameter::Positional(_) + | brush_parser::word::Parameter::Special(_) => (None, None), + brush_parser::word::Parameter::Named(name) => (Some(name.to_owned()), Some("0".into())), + brush_parser::word::Parameter::NamedWithIndex { name, index } => { + (Some(name.to_owned()), Some(index.to_owned())) + } + brush_parser::word::Parameter::NamedWithAllIndices { + name, + concatenate: _concatenate, + } => (Some(name.to_owned()), None), + }; + + let var = name + .as_ref() + .and_then(|name| self.shell.env().get(name).map(|(_, var)| var.clone())); + + (name, index, var) + } + + fn undefined_expansion( + &self, + parameter: &brush_parser::word::Parameter, + allow_unset_vars: bool, + ) -> Result { + if allow_unset_vars || !self.shell.options().treat_unset_variables_as_error { + Ok(Expansion::undefined()) + } else { + let err: error::Error = + error::ErrorKind::ExpandingUnsetVariable(parameter.to_string()).into(); + Err(err.into_fatal()) + } + } + + async fn expand_parameter( + &mut self, + parameter: &brush_parser::word::Parameter, + indirect: bool, + ) -> Result { + self.expand_parameter_internal(parameter, indirect, false) + .await + } + + async fn expand_parameter_allowing_unset( + &mut self, + parameter: &brush_parser::word::Parameter, + indirect: bool, + ) -> Result { + self.expand_parameter_internal(parameter, indirect, true) + .await + } + + async fn expand_parameter_internal( + &mut self, + parameter: &brush_parser::word::Parameter, + indirect: bool, + allow_unset_vars: bool, + ) -> Result { + let expansion = self + .expand_parameter_without_indirect(parameter, allow_unset_vars) + .await?; + if !indirect { + Ok(expansion) + } else { + let parameter_str: String = self.fields_to_string(expansion); + let inner_parameter = + brush_parser::word::parse_parameter(parameter_str.as_str(), &self.parser_options)?; + + self.expand_parameter_without_indirect(&inner_parameter, allow_unset_vars) + .await + } + } + + async fn expand_parameter_without_indirect( + &mut self, + parameter: &brush_parser::word::Parameter, + allow_unset_vars: bool, + ) -> Result { + match parameter { + brush_parser::word::Parameter::Positional(p) => { + if *p == 0 { + Ok(self + .expand_special_parameter(&brush_parser::word::SpecialParameter::ShellName)) + } else if let Some(parameter) = + self.shell.current_shell_args().get((p - 1) as usize) + { + Ok(Expansion::from(parameter.to_owned())) + } else { + self.undefined_expansion(parameter, allow_unset_vars) + } + } + brush_parser::word::Parameter::Special(s) => Ok(self.expand_special_parameter(s)), + brush_parser::word::Parameter::Named(n) => { + if !env::valid_variable_name(n.as_str()) { + Err(error::ErrorKind::BadSubstitution(n.clone()).into()) + } else if let Some((_, var)) = self.shell.env().get(n) { + if matches!(var.value(), ShellValue::Unset(_)) { + self.undefined_expansion(parameter, allow_unset_vars) + } else { + let value = var.value().try_get_cow_str(self.shell); + if let Some(value) = value { + Ok(Expansion::from(value.to_string())) + } else { + self.undefined_expansion(parameter, allow_unset_vars) + } + } + } else { + self.undefined_expansion(parameter, allow_unset_vars) + } + } + brush_parser::word::Parameter::NamedWithIndex { name, index } => { + // First check to see if it's an associative array. + let is_set_assoc_array = if let Some((_, var)) = self.shell.env().get(name) { + matches!( + var.value(), + ShellValue::AssociativeArray(_) + | ShellValue::Unset(ShellValueUnsetType::AssociativeArray) + ) + } else { + false + }; + + // Figure out which index to use. + let index_to_use = self + .expand_array_index(index.as_str(), is_set_assoc_array) + .await?; + + // Index into the array. + if let Some((_, var)) = self.shell.env().get(name) + && let Ok(Some(value)) = var.value().get_at(index_to_use.as_str(), self.shell) + { + Ok(Expansion::from(value.to_string())) + } else { + self.undefined_expansion(parameter, allow_unset_vars) + } + } + brush_parser::word::Parameter::NamedWithAllIndices { name, concatenate } => { + if let Some((_, var)) = self.shell.env().get(name) { + let values = var.value().element_values(self.shell); + + Ok(Expansion { + fields: values + .into_iter() + .map(|value| WordField(vec![ExpansionPiece::Splittable(value)])) + .collect(), + concatenate: *concatenate, + from_array: true, + undefined: false, + }) + } else { + Ok(Expansion { + fields: vec![], + concatenate: *concatenate, + from_array: true, + undefined: false, + }) + } + } + } + } + + async fn expand_array_index( + &mut self, + index: &str, + for_set_associative_array: bool, + ) -> Result { + let index_to_use = if for_set_associative_array { + self.basic_expand_to_str(index).await? + } else { + arithmetic::expand_and_eval(self.shell, self.params, index, false) + .await? + .to_string() + }; + + Ok(index_to_use) + } + + fn expand_special_parameter( + &self, + parameter: &brush_parser::word::SpecialParameter, + ) -> Expansion { + match parameter { + brush_parser::word::SpecialParameter::AllPositionalParameters { concatenate } => { + let args = self.shell.current_shell_args().iter(); + + Expansion { + fields: args + .into_iter() + .map(|param| WordField(vec![ExpansionPiece::Splittable(param.to_owned())])) + .collect(), + concatenate: *concatenate, + from_array: true, + undefined: false, + } + } + brush_parser::word::SpecialParameter::PositionalParameterCount => { + Expansion::from(self.shell.current_shell_args().len().to_string()) + } + brush_parser::word::SpecialParameter::LastExitStatus => { + Expansion::from(self.shell.last_exit_status().to_string()) + } + brush_parser::word::SpecialParameter::CurrentOptionFlags => { + Expansion::from(self.shell.options().option_flags()) + } + brush_parser::word::SpecialParameter::ProcessId => { + Expansion::from(std::process::id().to_string()) + } + brush_parser::word::SpecialParameter::LastBackgroundProcessId => { + if let Some(job) = self.shell.jobs().current_job() + && let Some(pid) = job.representative_pid() + { + return Expansion::from(pid.to_string()); + } + Expansion::from(String::new()) + } + brush_parser::word::SpecialParameter::ShellName => Expansion::from( + self.shell + .current_shell_name() + .map_or_else(String::new, |name| name.to_string()), + ), + } + } + + async fn expand_arithmetic_expr( + &mut self, + expr: brush_parser::ast::UnexpandedArithmeticExpr, + ) -> Result { + let value = expr.eval(self.shell, self.params, false).await?; + Ok(value.to_string()) + } + + fn pattern_to_first_char( + s: String, + pattern: Option<&patterns::Pattern>, + transform: F, + ) -> Result + where + F: Fn(char) -> I, + I: Iterator, + { + if let Some(first_char) = s.chars().next() { + let applicable = if let Some(pattern) = pattern { + pattern.is_empty() || pattern.exactly_matches(first_char.to_string().as_str())? + } else { + true + }; + + if applicable { + if let Some(upper_char) = transform(first_char).next() { + let mut result = upper_char.to_string(); + result.extend(s.chars().skip(1)); + return Ok(result); + } + } + } + + Ok(s) + } + + fn pattern_to_string( + s: &str, + pattern: Option<&patterns::Pattern>, + transform: F, + ) -> Result + where + F: Fn(&str) -> String, + { + if let Some(pattern) = pattern { + if !pattern.is_empty() { + let regex = pattern.to_regex(false, false)?; + let result = regex.replace_all(s.as_ref(), |caps: &fancy_regex::Captures<'_>| { + transform(&caps[0]) + }); + Ok(result.into_owned()) + } else { + Ok(transform(s)) + } + } else { + Ok(transform(s)) + } + } + + fn replace_substring( + s: &str, + regex: &fancy_regex::Regex, + replacement: &str, + match_kind: &SubstringMatchKind, + ) -> String { + match match_kind { + brush_parser::word::SubstringMatchKind::Prefix + | brush_parser::word::SubstringMatchKind::Suffix + | brush_parser::word::SubstringMatchKind::FirstOccurrence => { + regex.replace(s, replacement).into_owned() + } + + brush_parser::word::SubstringMatchKind::Anywhere => { + regex.replace_all(s, replacement).into_owned() + } + } + } + + async fn apply_transform_to( + &mut self, + op: &ParameterTransformOp, + s: String, + came_from_undefined: bool, + ) -> Result { + match op { + brush_parser::word::ParameterTransformOp::PromptExpand => { + prompt::expand_prompt(self.shell, self.params, s).await + } + brush_parser::word::ParameterTransformOp::CapitalizeInitial => { + Ok(to_initial_capitals(s.as_str())) + } + brush_parser::word::ParameterTransformOp::ExpandEscapeSequences => { + let (result, _) = escape::expand_backslash_escapes( + s.as_str(), + escape::EscapeExpansionMode::AnsiCQuotes, + )?; + Ok(String::from_utf8_lossy(result.as_slice()).into_owned()) + } + brush_parser::word::ParameterTransformOp::PossiblyQuoteWithArraysExpanded { + separate_words: _separate_words, + } => { + if came_from_undefined { + Ok(String::new()) + } else { + // TODO(expansion): This isn't right for arrays. + // TODO(expansion): This doesn't honor 'separate_words' + Ok(escape::force_quote( + s.as_str(), + escape::QuoteMode::SingleQuote, + )) + } + } + brush_parser::word::ParameterTransformOp::Quoted => { + if came_from_undefined { + Ok(String::new()) + } else { + Ok(escape::force_quote( + s.as_str(), + escape::QuoteMode::SingleQuote, + )) + } + } + brush_parser::word::ParameterTransformOp::ToLowerCase => Ok(s.to_lowercase()), + brush_parser::word::ParameterTransformOp::ToUpperCase => Ok(s.to_uppercase()), + brush_parser::word::ParameterTransformOp::ToAssignmentLogic + | brush_parser::word::ParameterTransformOp::ToAttributeFlags => { + unreachable!("covered in caller") + } + } + } +} + +fn coalesce_expansions(expansions: Vec) -> Expansion { + expansions + .into_iter() + .fold(Expansion::default(), |mut acc, expansion| { + for (i, mut field) in expansion.fields.into_iter().enumerate() { + match acc.fields.last_mut() { + Some(last) if i == 0 => { + last.0.append(&mut field.0); + } + _ => acc.fields.push(field), + } + } + + // TODO(expansion): What if expansions have different concatenation values? + acc.concatenate = expansion.concatenate; + acc.from_array = expansion.from_array; + + acc + }) +} + +fn to_initial_capitals(s: &str) -> String { + let mut result = String::new(); + let mut capitalize_next = true; + + for c in s.chars() { + if c.is_whitespace() { + capitalize_next = true; + result.push(c); + } else if capitalize_next { + result.push_str(c.to_uppercase().to_string().as_str()); + capitalize_next = false; + } else { + result.push(c); + } + } + + result +} + +async fn transform_expansion( + expansion: Expansion, + mut f: F, +) -> Result +where + F: FnMut(String) -> FReturn, + FReturn: Future>, +{ + let mut transformed_fields = vec![]; + for field in expansion.fields { + let transformed_field = WordField::from(f(String::from(field)).await?); + transformed_fields.push(transformed_field); + } + + Ok(Expansion { + fields: transformed_fields, + concatenate: expansion.concatenate, + from_array: expansion.from_array, + undefined: expansion.undefined, + }) +} + +fn may_contain_braces_to_expand(s: &str) -> bool { + // This is a completely inaccurate but quick heuristic used to see if + // it's even worth properly parsing the string to find brace expressions. + // It's mostly used to avoid more expensive parsing just because we've + // encountered a brace used in a parameter expansion. + let mut last_was_unescaped_dollar_sign = false; + let mut last_was_escape = false; + let mut saw_opening_brace = false; + let mut saw_closing_brace = false; + for c in s.chars() { + if !last_was_unescaped_dollar_sign { + if c == '{' { + saw_opening_brace = true; + } else if c == '}' { + saw_closing_brace = true; + if saw_opening_brace { + return true; + } + } + } + + last_was_unescaped_dollar_sign = !last_was_escape && c == '$'; + last_was_escape = c == '\\'; + } + + saw_opening_brace && saw_closing_brace +} + +#[expect(clippy::panic_in_result_fn)] +#[cfg(test)] +mod tests { + use super::*; + use anyhow::Result; + + #[tokio::test] + async fn test_full_expansion() -> Result<()> { + let mut shell = crate::shell::Shell::builder().build().await?; + let params = shell.default_exec_params(); + + assert_eq!( + full_expand_and_split_word(&mut shell, ¶ms, "\"\"").await?, + vec![""] + ); + assert_eq!( + full_expand_and_split_word(&mut shell, ¶ms, "a b").await?, + vec!["a", "b"] + ); + assert_eq!( + full_expand_and_split_word(&mut shell, ¶ms, "ab").await?, + vec!["ab"] + ); + assert_eq!( + full_expand_and_split_word(&mut shell, ¶ms, r#""a b""#).await?, + vec!["a b"] + ); + assert_eq!( + full_expand_and_split_word(&mut shell, ¶ms, "").await?, + Vec::::new() + ); + assert_eq!( + full_expand_and_split_word(&mut shell, ¶ms, "$@").await?, + Vec::::new() + ); + assert_eq!( + full_expand_and_split_word(&mut shell, ¶ms, "$*").await?, + Vec::::new() + ); + + Ok(()) + } + + /// Regression test: a quoted empty string `""` must survive word expansion + /// even when `nullglob` is enabled. Previously, `Pattern::expand()` would + /// return `NoGlob` for an all-empty pattern, which with nullglob set caused + /// `expand_pathnames_in_field` to silently discard the empty-string field. + #[tokio::test] + async fn test_quoted_empty_string_with_nullglob() -> Result<()> { + let mut shell = crate::shell::Shell::builder().build().await?; + shell.options_mut().expand_non_matching_patterns_to_null = true; // shopt -s nullglob + let params = shell.default_exec_params(); + + // Quoted empty string must always produce exactly one empty-string field. + assert_eq!( + full_expand_and_split_word(&mut shell, ¶ms, "\"\"").await?, + vec![""] + ); + // Unquoted empty string (no characters at all) should still produce no fields. + assert_eq!( + full_expand_and_split_word(&mut shell, ¶ms, "").await?, + Vec::::new() + ); + + Ok(()) + } + + #[tokio::test] + async fn test_brace_expansion() -> Result<()> { + let mut shell = crate::shell::Shell::builder().build().await?; + let params = shell.default_exec_params(); + let expander = WordExpander::new(&mut shell, ¶ms); + + assert_eq!(expander.brace_expand_if_needed("abc")?, "abc"); + assert_eq!(expander.brace_expand_if_needed("a{,b}d")?, "ad abd"); + assert_eq!(expander.brace_expand_if_needed("a{b,c}d")?, "abd acd"); + assert_eq!(expander.brace_expand_if_needed("a{1..3}d")?, "a1d a2d a3d"); + assert_eq!(expander.brace_expand_if_needed(r#""{a,b}""#)?, r#""{a,b}""#); + assert_eq!(expander.brace_expand_if_needed("a{}b")?, "a{}b"); + assert_eq!(expander.brace_expand_if_needed("a{ }b")?, "a{ }b"); + assert_eq!(expander.brace_expand_if_needed("{a,b{1,2}}")?, "a b1 b2"); + + Ok(()) + } + + #[tokio::test] + async fn test_field_splitting() -> Result<()> { + let mut shell = crate::shell::Shell::builder().build().await?; + let params = shell.default_exec_params(); + let expander = WordExpander::new(&mut shell, ¶ms); + + let expansion = Expansion { + fields: vec![ + WordField(vec![ExpansionPiece::Unsplittable("A".into())]), + WordField(vec![ExpansionPiece::Unsplittable(String::new())]), + ], + ..Expansion::default() + }; + + let fields = expander.split_fields(expansion); + + assert_eq!( + fields, + vec![ + WordField(vec![ExpansionPiece::Unsplittable(String::from("A"))]), + WordField(vec![ExpansionPiece::Unsplittable(String::new())]) + ] + ); + + Ok(()) + } + + #[test] + fn test_to_initial_capitals() { + assert_eq!(to_initial_capitals("ab bc cd"), String::from("Ab Bc Cd")); + assert_eq!(to_initial_capitals(" a "), String::from(" A ")); + assert_eq!(to_initial_capitals(""), String::new()); + } +} diff --git a/local/recipes/shells/brush/source/brush-core/src/extendedtests.rs b/local/recipes/shells/brush/source/brush-core/src/extendedtests.rs new file mode 100644 index 0000000000..3da833e931 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/extendedtests.rs @@ -0,0 +1,612 @@ +use brush_parser::ast; +use std::path::Path; + +use crate::{ + ExecutionParameters, Shell, ShellFd, arithmetic, env, error, escape, expansion, extensions, + namedoptions, patterns, + sys::{ + fs::{MetadataExt, PathExt}, + users, + }, + variables::{self, ArrayLiteral}, +}; + +#[async_recursion::async_recursion] +pub(crate) async fn eval_extended_test_expr( + expr: &ast::ExtendedTestExpr, + shell: &mut Shell, + params: &ExecutionParameters, +) -> Result { + match expr { + ast::ExtendedTestExpr::UnaryTest(op, operand) => { + apply_unary_predicate(op, operand, shell, params).await + } + ast::ExtendedTestExpr::BinaryTest(op, left, right) => { + apply_binary_predicate(op, left, right, shell, params).await + } + ast::ExtendedTestExpr::And(left, right) => { + let result = eval_extended_test_expr(left, shell, params).await? + && eval_extended_test_expr(right, shell, params).await?; + Ok(result) + } + ast::ExtendedTestExpr::Or(left, right) => { + let result = eval_extended_test_expr(left, shell, params).await? + || eval_extended_test_expr(right, shell, params).await?; + Ok(result) + } + ast::ExtendedTestExpr::Not(expr) => { + let result = !eval_extended_test_expr(expr, shell, params).await?; + Ok(result) + } + ast::ExtendedTestExpr::Parenthesized(expr) => { + eval_extended_test_expr(expr, shell, params).await + } + } +} + +async fn apply_unary_predicate( + op: &ast::UnaryPredicate, + operand: &ast::Word, + shell: &mut Shell, + params: &ExecutionParameters, +) -> Result { + let expanded_operand = expansion::basic_expand_word(shell, params, operand).await?; + + if shell.options().print_commands_and_arguments { + shell + .trace_command( + params, + std::format!( + "[[ {op} {} ]]", + escape::quote_if_needed(&expanded_operand, escape::QuoteMode::SingleQuote) + ), + ) + .await; + } + + apply_unary_predicate_to_str(op, expanded_operand.as_str(), shell, params) +} + +#[expect(clippy::too_many_lines)] +pub(crate) fn apply_unary_predicate_to_str( + op: &ast::UnaryPredicate, + operand: &str, + shell: &Shell, + params: &ExecutionParameters, +) -> Result { + match op { + ast::UnaryPredicate::StringHasNonZeroLength => Ok(!operand.is_empty()), + ast::UnaryPredicate::StringHasZeroLength => Ok(operand.is_empty()), + ast::UnaryPredicate::FileExists => { + let path = shell.absolute_path(Path::new(operand)); + Ok(path.exists()) + } + ast::UnaryPredicate::FileExistsAndIsBlockSpecialFile => { + let path = shell.absolute_path(Path::new(operand)); + Ok(path.exists_and_is_block_device()) + } + ast::UnaryPredicate::FileExistsAndIsCharSpecialFile => { + let path = shell.absolute_path(Path::new(operand)); + Ok(path.exists_and_is_char_device()) + } + ast::UnaryPredicate::FileExistsAndIsDir => { + let path = shell.absolute_path(Path::new(operand)); + Ok(path.is_dir()) + } + ast::UnaryPredicate::FileExistsAndIsRegularFile => { + let path = shell.absolute_path(Path::new(operand)); + Ok(path.is_file()) + } + ast::UnaryPredicate::FileExistsAndIsSetgid => { + let path = shell.absolute_path(Path::new(operand)); + Ok(path.exists_and_is_setgid()) + } + ast::UnaryPredicate::FileExistsAndIsSymlink => { + let path = shell.absolute_path(Path::new(operand)); + Ok(path.is_symlink()) + } + ast::UnaryPredicate::FileExistsAndHasStickyBit => { + let path = shell.absolute_path(Path::new(operand)); + Ok(path.exists_and_is_sticky_bit()) + } + ast::UnaryPredicate::FileExistsAndIsFifo => { + let path = shell.absolute_path(Path::new(operand)); + Ok(path.exists_and_is_fifo()) + } + ast::UnaryPredicate::FileExistsAndIsReadable => { + let path = shell.absolute_path(Path::new(operand)); + Ok(path.readable()) + } + ast::UnaryPredicate::FileExistsAndIsNotZeroLength => { + let path = shell.absolute_path(Path::new(operand)); + if let Ok(metadata) = path.metadata() { + Ok(metadata.len() > 0) + } else { + Ok(false) + } + } + ast::UnaryPredicate::FdIsOpenTerminal => { + // Trim whitespace before parsing, matching bash behavior. + if let Ok(fd) = operand.trim().parse::() { + if let Some(open_file) = params.try_fd(shell, fd) { + Ok(open_file.is_terminal()) + } else { + Ok(false) + } + } else { + Ok(false) + } + } + ast::UnaryPredicate::FileExistsAndIsSetuid => { + let path = shell.absolute_path(Path::new(operand)); + Ok(path.exists_and_is_setuid()) + } + ast::UnaryPredicate::FileExistsAndIsWritable => { + let path = shell.absolute_path(Path::new(operand)); + Ok(path.writable()) + } + ast::UnaryPredicate::FileExistsAndIsExecutable => { + let path = shell.absolute_path(Path::new(operand)); + Ok(path.executable()) + } + ast::UnaryPredicate::FileExistsAndOwnedByEffectiveGroupId => { + let path = shell.absolute_path(Path::new(operand)); + if !path.exists() { + return Ok(false); + } + + let md = path.metadata()?; + Ok(md.gid() == users::get_effective_gid()?) + } + ast::UnaryPredicate::FileExistsAndModifiedSinceLastRead => { + error::unimp("unary extended test predicate: FileExistsAndModifiedSinceLastRead") + } + ast::UnaryPredicate::FileExistsAndOwnedByEffectiveUserId => { + let path = shell.absolute_path(Path::new(operand)); + if !path.exists() { + return Ok(false); + } + + let md = path.metadata()?; + Ok(md.uid() == users::get_effective_uid()?) + } + ast::UnaryPredicate::FileExistsAndIsSocket => { + let path = shell.absolute_path(Path::new(operand)); + Ok(path.exists_and_is_socket()) + } + ast::UnaryPredicate::ShellOptionEnabled => { + let shopt_name = operand; + if let Some(option) = + namedoptions::options(namedoptions::ShellOptionKind::SetO).get(shopt_name) + { + Ok(option.get(shell.options())) + } else { + Ok(false) + } + } + ast::UnaryPredicate::ShellVariableIsSetAndAssigned => Ok(shell.env().is_set(operand)), + ast::UnaryPredicate::ShellVariableIsSetAndNameRef => match shell.env().get(operand) { + Some((_, reffed)) => Ok(reffed.value().is_set() && reffed.is_treated_as_nameref()), + None => Ok(false), + }, + } +} + +#[expect(clippy::too_many_lines)] +async fn apply_binary_predicate( + op: &ast::BinaryPredicate, + left: &ast::Word, + right: &ast::Word, + shell: &mut Shell, + params: &ExecutionParameters, +) -> Result { + match op { + ast::BinaryPredicate::StringMatchesRegex => { + let s = expansion::basic_expand_word(shell, params, left).await?; + let regex = expansion::basic_expand_regex(shell, params, right) + .await? + .set_multiline(true); + + if shell.options().print_commands_and_arguments { + shell + .trace_command(params, std::format!("[[ {s} {op} {right} ]]")) + .await; + } + + let (matches, captures) = match regex.matches(s.as_str()) { + Ok(Some(captures)) => (true, captures), + Ok(None) => (false, vec![]), + // If we can't compile the regex, don't abort the whole operation but make sure to + // report it. + // TODO(test): Docs indicate we should yield 2 on an invalid regex (not 1). + Err(e) => { + tracing::warn!("error using regex: {}", e); + (false, vec![]) + } + }; + + let captures_value = variables::ShellValueLiteral::Array(ArrayLiteral( + captures + .into_iter() + .map(|c| (None, c.unwrap_or_default())) + .collect(), + )); + + shell.env_mut().update_or_add( + "BASH_REMATCH", + captures_value, + |_| Ok(()), + env::EnvironmentLookup::Anywhere, + env::EnvironmentScope::Global, + )?; + + Ok(matches) + } + ast::BinaryPredicate::StringExactlyMatchesString => { + let left = expansion::basic_expand_word(shell, params, left).await?; + let right = expansion::basic_expand_word(shell, params, right).await?; + + if shell.options().print_commands_and_arguments { + shell + .trace_command(params, std::format!("[[ {left} {op} {right} ]]")) + .await; + } + + Ok(left == right) + } + ast::BinaryPredicate::StringDoesNotExactlyMatchString => { + let left = expansion::basic_expand_word(shell, params, left).await?; + let right = expansion::basic_expand_word(shell, params, right).await?; + + if shell.options().print_commands_and_arguments { + shell + .trace_command(params, std::format!("[[ {left} {op} {right} ]]")) + .await; + } + + Ok(left != right) + } + ast::BinaryPredicate::StringContainsSubstring => { + let s = expansion::basic_expand_word(shell, params, left).await?; + let substring = expansion::basic_expand_word(shell, params, right).await?; + + if shell.options().print_commands_and_arguments { + shell + .trace_command(params, std::format!("[[ {s} {op} {substring} ]]")) + .await; + } + + Ok(s.contains(substring.as_str())) + } + ast::BinaryPredicate::FilesReferToSameDeviceAndInodeNumbers => { + let left = expansion::basic_expand_word(shell, params, left).await?; + let right = expansion::basic_expand_word(shell, params, right).await?; + + if shell.options().print_commands_and_arguments { + shell + .trace_command(params, std::format!("[[ {left} {op} {right} ]]")) + .await; + } + + files_refer_to_same_device_and_inode_numbers(shell, left, right) + } + ast::BinaryPredicate::LeftFileIsNewerOrExistsWhenRightDoesNot => { + let left = expansion::basic_expand_word(shell, params, left).await?; + let right = expansion::basic_expand_word(shell, params, right).await?; + + if shell.options().print_commands_and_arguments { + shell + .trace_command(params, std::format!("[[ {left} {op} {right} ]]")) + .await; + } + + left_file_is_newer_or_exists_when_right_does_not(shell, left, right) + } + ast::BinaryPredicate::LeftFileIsOlderOrDoesNotExistWhenRightDoes => { + let left = expansion::basic_expand_word(shell, params, left).await?; + let right = expansion::basic_expand_word(shell, params, right).await?; + + if shell.options().print_commands_and_arguments { + shell + .trace_command(params, std::format!("[[ {left} {op} {right} ]]")) + .await; + } + + left_file_is_older_or_does_not_exist_when_right_does(shell, left, right) + } + ast::BinaryPredicate::LeftSortsBeforeRight => { + let left = expansion::basic_expand_word(shell, params, left).await?; + let right = expansion::basic_expand_word(shell, params, right).await?; + + if shell.options().print_commands_and_arguments { + shell + .trace_command(params, std::format!("[[ {left} {op} {right} ]]")) + .await; + } + + // TODO(test): According to docs, should be lexicographical order of the current locale. + Ok(left < right) + } + ast::BinaryPredicate::LeftSortsAfterRight => { + let left = expansion::basic_expand_word(shell, params, left).await?; + let right = expansion::basic_expand_word(shell, params, right).await?; + + if shell.options().print_commands_and_arguments { + shell + .trace_command(params, std::format!("[[ {left} {op} {right} ]]")) + .await; + } + + // TODO(test): According to docs, should be lexicographical order of the current locale. + Ok(left > right) + } + ast::BinaryPredicate::ArithmeticEqualTo => { + let left = + arithmetic::expand_and_eval(shell, params, left.value.as_str(), false).await?; + let right = + arithmetic::expand_and_eval(shell, params, right.value.as_str(), false).await?; + + if shell.options().print_commands_and_arguments { + shell + .trace_command(params, std::format!("[[ {left} {op} {right} ]]")) + .await; + } + + Ok(left == right) + } + ast::BinaryPredicate::ArithmeticNotEqualTo => { + let left = + arithmetic::expand_and_eval(shell, params, left.value.as_str(), false).await?; + let right = + arithmetic::expand_and_eval(shell, params, right.value.as_str(), false).await?; + + if shell.options().print_commands_and_arguments { + shell + .trace_command(params, std::format!("[[ {left} {op} {right} ]]")) + .await; + } + + Ok(left != right) + } + ast::BinaryPredicate::ArithmeticLessThan => { + let left = + arithmetic::expand_and_eval(shell, params, left.value.as_str(), false).await?; + let right = + arithmetic::expand_and_eval(shell, params, right.value.as_str(), false).await?; + + if shell.options().print_commands_and_arguments { + shell + .trace_command(params, std::format!("[[ {left} {op} {right} ]]")) + .await; + } + + Ok(left < right) + } + ast::BinaryPredicate::ArithmeticLessThanOrEqualTo => { + let left = + arithmetic::expand_and_eval(shell, params, left.value.as_str(), false).await?; + let right = + arithmetic::expand_and_eval(shell, params, right.value.as_str(), false).await?; + + if shell.options().print_commands_and_arguments { + shell + .trace_command(params, std::format!("[[ {left} {op} {right} ]]")) + .await; + } + + Ok(left <= right) + } + ast::BinaryPredicate::ArithmeticGreaterThan => { + let left = + arithmetic::expand_and_eval(shell, params, left.value.as_str(), false).await?; + let right = + arithmetic::expand_and_eval(shell, params, right.value.as_str(), false).await?; + + if shell.options().print_commands_and_arguments { + shell + .trace_command(params, std::format!("[[ {left} {op} {right} ]]")) + .await; + } + + Ok(left > right) + } + ast::BinaryPredicate::ArithmeticGreaterThanOrEqualTo => { + let left = + arithmetic::expand_and_eval(shell, params, left.value.as_str(), false).await?; + let right = + arithmetic::expand_and_eval(shell, params, right.value.as_str(), false).await?; + + if shell.options().print_commands_and_arguments { + shell + .trace_command(params, std::format!("[[ {left} {op} {right} ]]")) + .await; + } + + Ok(left >= right) + } + // N.B. The "=", "==", and "!=" operators don't compare 2 strings; they check + // for whether the lefthand operand (a string) is matched by the righthand + // operand (treated as a shell pattern). + // TODO(test): implement case-insensitive matching if relevant via shopt options + // (nocasematch). + ast::BinaryPredicate::StringExactlyMatchesPattern => { + let s = expansion::basic_expand_word(shell, params, left).await?; + let pattern = expansion::basic_expand_pattern(shell, params, right) + .await? + .set_extended_globbing(shell.options().extended_globbing) + .set_case_insensitive(shell.options().case_insensitive_conditionals); + + if shell.options().print_commands_and_arguments { + let expanded_right = expansion::basic_expand_word(shell, params, right).await?; + let escaped_right = escape::quote_if_needed( + expanded_right.as_str(), + escape::QuoteMode::BackslashEscape, + ); + shell + .trace_command(params, std::format!("[[ {s} {op} {escaped_right} ]]")) + .await; + } + + pattern.exactly_matches(s.as_str()) + } + ast::BinaryPredicate::StringDoesNotExactlyMatchPattern => { + let s = expansion::basic_expand_word(shell, params, left).await?; + let pattern = expansion::basic_expand_pattern(shell, params, right) + .await? + .set_extended_globbing(shell.options().extended_globbing) + .set_case_insensitive(shell.options().case_insensitive_conditionals); + + if shell.options().print_commands_and_arguments { + let expanded_right = expansion::basic_expand_word(shell, params, right).await?; + let escaped_right = escape::quote_if_needed( + expanded_right.as_str(), + escape::QuoteMode::BackslashEscape, + ); + shell + .trace_command(params, std::format!("[[ {s} {op} {escaped_right} ]]")) + .await; + } + + let eq = pattern.exactly_matches(s.as_str())?; + Ok(!eq) + } + } +} + +pub(crate) fn apply_binary_predicate_to_strs( + op: &ast::BinaryPredicate, + left: &str, + right: &str, + shell: &Shell, +) -> Result { + match op { + ast::BinaryPredicate::FilesReferToSameDeviceAndInodeNumbers => { + files_refer_to_same_device_and_inode_numbers(shell, left, right) + } + ast::BinaryPredicate::LeftFileIsNewerOrExistsWhenRightDoesNot => { + left_file_is_newer_or_exists_when_right_does_not(shell, left, right) + } + ast::BinaryPredicate::LeftFileIsOlderOrDoesNotExistWhenRightDoes => { + left_file_is_older_or_does_not_exist_when_right_does(shell, left, right) + } + ast::BinaryPredicate::LeftSortsBeforeRight => { + // TODO(test): According to docs, should be lexicographical order of the current locale. + Ok(left < right) + } + ast::BinaryPredicate::LeftSortsAfterRight => { + // TODO(test): According to docs, should be lexicographical order of the current locale. + Ok(left > right) + } + ast::BinaryPredicate::ArithmeticEqualTo => Ok(apply_test_binary_arithmetic_predicate( + left, + right, + |left, right| left == right, + )), + ast::BinaryPredicate::ArithmeticNotEqualTo => Ok(apply_test_binary_arithmetic_predicate( + left, + right, + |left, right| left != right, + )), + ast::BinaryPredicate::ArithmeticLessThan => Ok(apply_test_binary_arithmetic_predicate( + left, + right, + |left, right| left < right, + )), + ast::BinaryPredicate::ArithmeticLessThanOrEqualTo => Ok( + apply_test_binary_arithmetic_predicate(left, right, |left, right| left <= right), + ), + ast::BinaryPredicate::ArithmeticGreaterThan => Ok(apply_test_binary_arithmetic_predicate( + left, + right, + |left, right| left > right, + )), + ast::BinaryPredicate::ArithmeticGreaterThanOrEqualTo => Ok( + apply_test_binary_arithmetic_predicate(left, right, |left, right| left >= right), + ), + ast::BinaryPredicate::StringExactlyMatchesPattern => { + let pattern = patterns::Pattern::from(right) + .set_extended_globbing(shell.options().extended_globbing) + .set_case_insensitive(shell.options().case_insensitive_conditionals); + + pattern.exactly_matches(left) + } + ast::BinaryPredicate::StringDoesNotExactlyMatchPattern => { + let pattern = patterns::Pattern::from(right) + .set_extended_globbing(shell.options().extended_globbing) + .set_case_insensitive(shell.options().case_insensitive_conditionals); + + let eq = pattern.exactly_matches(left)?; + Ok(!eq) + } + ast::BinaryPredicate::StringExactlyMatchesString => Ok(left == right), + ast::BinaryPredicate::StringDoesNotExactlyMatchString => Ok(left != right), + _ => error::unimp("unsupported test binary predicate"), + } +} + +fn apply_test_binary_arithmetic_predicate( + left: &str, + right: &str, + op: fn(i64, i64) -> bool, +) -> bool { + // We trim leading/trailing whitespace (including newlines) before parsing integers. + let left: Result = left.trim().parse(); + let right: Result = right.trim().parse(); + + if let (Ok(left), Ok(right)) = (left, right) { + op(left, right) + } else { + false + } +} + +fn left_file_is_older_or_does_not_exist_when_right_does( + shell: &Shell, + left: impl AsRef, + right: impl AsRef, +) -> Result { + let (l_path, r_path) = ( + shell.absolute_path(Path::new(left.as_ref())), + shell.absolute_path(Path::new(right.as_ref())), + ); + + match (l_path.metadata(), r_path.metadata()) { + (Ok(m1), Ok(m2)) => Ok(m1.modified()? < m2.modified()?), + (Err(_), Ok(_)) => Ok(true), + _ => Ok(false), + } +} + +fn left_file_is_newer_or_exists_when_right_does_not( + shell: &Shell, + left: impl AsRef, + right: impl AsRef, +) -> Result { + let (l_path, r_path) = ( + shell.absolute_path(Path::new(left.as_ref())), + shell.absolute_path(Path::new(right.as_ref())), + ); + + match (l_path.metadata(), r_path.metadata()) { + (Ok(m1), Ok(m2)) => Ok(m1.modified()? > m2.modified()?), + (Ok(_), Err(_)) => Ok(true), + _ => Ok(false), + } +} + +fn files_refer_to_same_device_and_inode_numbers( + shell: &Shell, + left: impl AsRef, + right: impl AsRef, +) -> Result { + let (l_path, r_path) = ( + shell.absolute_path(Path::new(left.as_ref())), + shell.absolute_path(Path::new(right.as_ref())), + ); + + if !l_path.readable() || !r_path.readable() { + return Ok(false); + } + + Ok(l_path.get_device_and_inode()? == r_path.get_device_and_inode()?) +} diff --git a/local/recipes/shells/brush/source/brush-core/src/extensions.rs b/local/recipes/shells/brush/source/brush-core/src/extensions.rs new file mode 100644 index 0000000000..487f89fc07 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/extensions.rs @@ -0,0 +1,57 @@ +//! Definition of shell behavior traits and defaults. + +use crate::{Shell, error, extensions}; + +/// Trait for static shell extensions. Collects all associated types needed to +/// instantiate a shell into a single containing struct. +pub trait ShellExtensions: Clone + Default + Send + Sync + 'static { + /// Type of the error behavior implementation. + type ErrorFormatter: ErrorFormatter; +} + +/// Shell extensions implementation constructed from component types. +#[derive(Clone, Default)] +pub struct ShellExtensionsImpl { + _marker: std::marker::PhantomData, +} + +impl ShellExtensions for ShellExtensionsImpl { + type ErrorFormatter = EF; +} + +/// Default shell extensions implementation. +/// This is a type alias for the most common shell configuration. +pub type DefaultShellExtensions = ShellExtensionsImpl; + +/// Trait for defining shell error behaviors. +pub trait ErrorFormatter: Clone + Default + Send + Sync + 'static { + /// Format the given error for display within the context of the provided shell. + /// + /// # Arguments + /// + /// * `error` - The error to format + /// * `shell` - The shell context in which the error occurred. + fn format_error( + &self, + error: &error::Error, + shell: &Shell, + ) -> String { + let _ = shell; + std::format!("error: {error:#}\n") + } +} + +/// Default shell error behavior implementation. +#[derive(Clone, Default)] +pub struct DefaultErrorFormatter; + +impl ErrorFormatter for DefaultErrorFormatter {} + +/// Trait for placeholder behavior (stub for future extension). +pub trait PlaceholderBehavior: Clone + Default + Send + Sync + 'static {} + +/// Default placeholder implementation. +#[derive(Clone, Default)] +pub struct DefaultPlaceholder; + +impl PlaceholderBehavior for DefaultPlaceholder {} diff --git a/local/recipes/shells/brush/source/brush-core/src/functions.rs b/local/recipes/shells/brush/source/brush-core/src/functions.rs new file mode 100644 index 0000000000..3815cc1a38 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/functions.rs @@ -0,0 +1,126 @@ +//! Structures for managing function registrations and calls. + +use std::{collections::HashMap, sync::Arc}; + +/// An environment for defined, named functions. +#[derive(Clone, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct FunctionEnv { + functions: HashMap, +} + +impl FunctionEnv { + /// Tries to retrieve the registration for a function by name. + /// + /// # Arguments + /// + /// * `name` - The name of the function to retrieve. + pub fn get(&self, name: &str) -> Option<&Registration> { + self.functions.get(name) + } + + /// Tries to retrieve a mutable reference to the registration for a + /// function by name. + /// + /// # Arguments + /// + /// * `name` - The name of the function to retrieve. + pub fn get_mut(&mut self, name: &str) -> Option<&mut Registration> { + self.functions.get_mut(name) + } + + /// Unregisters a function from the environment. + /// + /// # Arguments + /// + /// * `name` - The name of the function to remove. + pub fn remove(&mut self, name: &str) -> Option { + self.functions.remove(name) + } + + /// Updates a function registration in this environment. + /// + /// # Arguments + /// + /// * `name` - The name of the function to update. + /// * `registration` - The new registration for the function. + pub fn update(&mut self, name: String, registration: Registration) { + self.functions.insert(name, registration); + } + + /// Clear all functions in this environment. + pub fn clear(&mut self) { + self.functions.clear(); + } + + /// Returns an iterator over the functions registered in this environment. + pub fn iter(&self) -> impl Iterator { + self.functions.iter() + } +} + +/// Encapsulates a registration for a defined function. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Registration { + /// The parsed definition of the function. + definition: Arc, + /// The source info for the function definition. + source_info: crate::SourceInfo, + /// Whether or not this function definition should be exported to children. + exported: bool, +} + +impl From for Registration { + fn from(definition: brush_parser::ast::FunctionDefinition) -> Self { + Self { + definition: Arc::new(definition), + source_info: crate::SourceInfo::default(), + exported: false, + } + } +} + +impl Registration { + /// Creates a new function registration. + /// + /// # Arguments + /// + /// * `definition` - The function definition. + /// * `source_info` - Source information for the function definition. + pub fn new( + definition: brush_parser::ast::FunctionDefinition, + source_info: &crate::SourceInfo, + ) -> Self { + Self { + definition: Arc::new(definition), + source_info: source_info.clone(), + exported: false, + } + } + + /// Returns a reference to the function definition. + pub fn definition(&self) -> &brush_parser::ast::FunctionDefinition { + &self.definition + } + + /// Returns a reference to the source info for the function definition. + pub const fn source(&self) -> &crate::SourceInfo { + &self.source_info + } + + /// Marks the function for export. + pub const fn export(&mut self) { + self.exported = true; + } + + /// Unmarks the function for export. + pub const fn unexport(&mut self) { + self.exported = false; + } + + /// Returns whether this function is exported. + pub const fn is_exported(&self) -> bool { + self.exported + } +} diff --git a/local/recipes/shells/brush/source/brush-core/src/history.rs b/local/recipes/shells/brush/source/brush-core/src/history.rs new file mode 100644 index 0000000000..72d1380567 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/history.rs @@ -0,0 +1,498 @@ +//! Facilities for tracking and persisting the shell's command history. + +use chrono::Utc; +use std::{ + io::{BufRead, Read, Write}, + path::Path, +}; + +use crate::error; + +/// Represents a unique identifier for a history item. +type ItemId = i64; + +/// Interface for querying and manipulating the shell's recorded history of commands. +// TODO(history): support maximum item count +#[derive(Clone, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct History { + items: rpds::VectorSync, + id_map: rpds::HashTrieMapSync, + next_id: ItemId, +} + +impl History { + /// Constructs a new `History` instance, with its contents initialized from the given readable + /// stream. If errors are encountered reading lines from the stream, unreadable lines will + /// be skipped but the call will still return successfully, with a warning logged. An error + /// result will be returned only if an internal error occurs updating the history. + /// + /// # Arguments + /// + /// * `reader` - The readable stream to import history from. + pub fn import(reader: impl Read) -> Result { + let mut history = Self::default(); + + let buf_reader = std::io::BufReader::new(reader); + + let mut next_timestamp = None; + for line_result in buf_reader.lines() { + let line = match line_result { + Ok(line) => line, + // If we couldn't decode the line due to invalid data (perhaps it wasn't + // valid UTF8?), skip it and make a best-effort attempt to proceed on. + // We'll later warn the user. + Err(err) if err.kind() == std::io::ErrorKind::InvalidData => { + tracing::warn!("unreadable history line; {err}"); + continue; + } + // In the event of other kinds of errors, return an error result. We don't + // want to get stuck in a failing I/O loop. + Err(err) => { + return Err(err.into()); + } + }; + + // Look for timestamp comments; ignore other comment lines. + if let Some(comment) = line.strip_prefix("#") { + if let Ok(seconds_since_epoch) = comment.trim().parse() { + next_timestamp = ItemTimestamp::from_timestamp(seconds_since_epoch, 0); + } else { + next_timestamp = None; + } + + continue; + } + + let item = Item { + id: history.next_id, + command_line: line, + timestamp: next_timestamp.take(), + dirty: false, + }; + + history.add(item)?; + } + + Ok(history) + } + + /// Tries to retrieve a history item by its unique identifier. Returns `None` if no item is + /// found. + /// + /// # Arguments + /// + /// * `id` - The unique identifier of the history item to retrieve. + pub fn get_by_id(&self, id: ItemId) -> Result, error::Error> { + Ok(self.id_map.get(&id)) + } + + /// Replaces the history item with the given ID with a new item. Returns an error if the item + /// cannot be updated. + /// + /// # Arguments + /// + /// * `id` - The unique identifier of the history item to update. + /// * `item` - The new history item to replace the old one. + pub fn update_by_id(&mut self, id: ItemId, item: Item) -> Result<(), error::Error> { + let existing_item = self + .id_map + .get_mut(&id) + .ok_or(error::ErrorKind::HistoryItemNotFound)?; + *existing_item = item; + Ok(()) + } + + /// Removes the nth item from the history. Returns the removed item, or `None` if no such item + /// exists (i.e., because it was out of range). + pub fn remove_nth_item(&mut self, n: usize) -> bool { + if let Some(id) = self.items.get(n).copied() { + self.items = self + .items + .into_iter() + .enumerate() + .filter_map(|(i, id)| if i != n { Some(id) } else { None }) + .copied() + .collect(); + + self.id_map.remove_mut(&id); + + true + } else { + false + } + } + + /// Adds a new history item. Returns the unique identifier of the newly added item. + /// + /// # Arguments + /// + /// * `item` - The history item to add. + pub fn add(&mut self, mut item: Item) -> Result { + let id = self.next_id; + + item.id = id; + self.next_id += 1; + + self.items.push_back_mut(item.id); + self.id_map.insert_mut(item.id, item); + + Ok(id) + } + + /// Deletes a history item by its unique identifier. Returns an error if the item cannot be + /// deleted. + /// + /// # Arguments + /// + /// * `id` - The unique identifier of the history item to delete. + pub fn delete_item_by_id(&mut self, id: ItemId) -> Result<(), error::Error> { + self.id_map.remove_mut(&id); + self.items = self + .items + .into_iter() + .filter(|&item_id| *item_id != id) + .copied() + .collect(); + + Ok(()) + } + + /// Clears all history items. + pub fn clear(&mut self) -> Result<(), error::Error> { + self.id_map = rpds::HashTrieMapSync::new_sync(); + self.items = rpds::VectorSync::new_sync(); + Ok(()) + } + + /// Flushes the history to backing storage (if relevant). + /// + /// # Arguments + /// + /// * `history_file_path` - The path to the history file. + /// * `append` - Whether to append to the file or overwrite it. + /// * `unsaved_items_only` - Whether to only write unsaved items; if true, any items will be + /// marked as "saved" once saved. + /// * `write_timestamps` - Whether to write timestamps for each command line. + pub fn flush( + &mut self, + history_file_path: impl AsRef, + append: bool, + unsaved_items_only: bool, + write_timestamps: bool, + ) -> Result<(), error::Error> { + // Open the file + let mut file_options = std::fs::File::options(); + + if append { + file_options.append(true); + } else { + file_options.write(true).truncate(true); + } + + let mut file = file_options.create(true).open(history_file_path.as_ref())?; + + for item_id in &self.items { + if let Some(item) = self.id_map.get_mut(item_id) { + if unsaved_items_only && !item.dirty { + continue; + } + + if write_timestamps && let Some(timestamp) = item.timestamp { + writeln!(file, "#{}", timestamp.timestamp())?; + } + + writeln!(file, "{}", item.command_line)?; + + if unsaved_items_only { + item.dirty = false; + } + } + } + + file.flush()?; + + Ok(()) + } + + /// Searches through history using the given query. + /// + /// # Arguments + /// + /// * `query` - The query to use. + pub fn search(&self, query: Query) -> Result, error::Error> { + Ok(Search::new(self, query)) + } + + /// Returns an iterator over the history items. + pub fn iter(&self) -> impl Iterator { + Search::all(self) + } + + /// Retrieves the nth history item, if it exists. Returns `None` if no such item exists. + /// Indexing is zero-based, with an index of 0 referencing the oldest item in the history. + /// + /// # Arguments + /// + /// * `index` - The index of the history item to retrieve. + pub fn get(&self, index: usize) -> Option<&Item> { + if let Some(id) = self.items.get(index) { + self.id_map.get(id) + } else { + None + } + } + + /// Returns the number of items in the history. + pub fn count(&self) -> usize { + self.items.len() + } +} + +/// Represents a timestamp for a history item. +pub type ItemTimestamp = chrono::DateTime; + +/// Represents an item in the history. +#[derive(Clone, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Item { + /// The unique identifier of the history item. + pub id: ItemId, + /// The actual command line. + pub command_line: String, + /// The timestamp when the command was started. + pub timestamp: Option, + /// Whether or not the item is dirty, i.e., has not yet been written to backing storage. + pub dirty: bool, +} + +impl Item { + /// Constructs a new `Item` with the given command line. + /// + /// # Arguments + /// + /// * `command_line` - The command line of the item. + pub fn new(command_line: impl Into) -> Self { + Self { + id: 0, // NOTE: ID will be assigned when added to the history. + command_line: command_line.into(), + timestamp: Some(chrono::Utc::now()), + dirty: true, + } + } +} + +/// Encapsulates query parameters for searching through history. +#[derive(Default)] +pub struct Query { + /// Whether to search forward or backward + pub direction: Direction, + /// Optionally, clamp results to items with a timestamp strictly after this. + pub not_at_or_before_time: Option, + /// Optionally, clamp results to items with a timestamp strictly before this. + pub not_at_or_after_time: Option, + /// Optionally, clamp results to items with an ID equal strictly after this. + pub not_at_or_before_id: Option, + /// Optionally, clamp results to items with an ID equal strictly before this. + pub not_at_or_after_id: Option, + /// Optionally, maximum number of items to retrieve + pub max_items: Option, + /// Optionally, a string-based filter on command line. + pub command_line_filter: Option, +} + +impl Query { + /// Checks if the query includes the given item. + /// + /// # Arguments + /// + /// * `item` - The item to check. + pub fn includes(&self, item: &Item) -> bool { + // Filter based on not_at_or_before_time. + if let Some(not_at_or_before_time) = &self.not_at_or_before_time { + if item + .timestamp + .is_some_and(|ts| ts <= *not_at_or_before_time) + { + return false; + } + } + + // Filter based on not_at_or_after_time + if let Some(not_at_or_after_time) = &self.not_at_or_after_time { + if item.timestamp.is_some_and(|ts| ts >= *not_at_or_after_time) { + return false; + } + } + + // Filter based on not_at_or_before_id + if self + .not_at_or_before_id + .is_some_and(|query_id| item.id <= query_id) + { + return false; + } + + // Filter based on not_at_or_after_id + if self + .not_at_or_after_id + .is_some_and(|query_id| item.id >= query_id) + { + return false; + } + + // Filter based on command_line_filter + if let Some(command_line_filter) = &self.command_line_filter { + match command_line_filter { + CommandLineFilter::Prefix(prefix) => { + if !item.command_line.starts_with(prefix) { + return false; + } + } + CommandLineFilter::Suffix(suffix) => { + if !item.command_line.ends_with(suffix) { + return false; + } + } + CommandLineFilter::Contains(contains) => { + if !item.command_line.contains(contains) { + return false; + } + } + CommandLineFilter::Exact(exact) => { + if item.command_line != *exact { + return false; + } + } + } + } + + true + } +} + +/// Represents the direction of a search operation. +#[derive(Default)] +pub enum Direction { + /// Search forward from the oldest part of history. + #[default] + Forward, + /// Search backward from the youngest part of history. + Backward, +} + +/// Filter criteria for command lines. +pub enum CommandLineFilter { + /// The command line must start with this string. + Prefix(String), + /// The command line must end with this string. + Suffix(String), + /// The command line must contain this string. + Contains(String), + /// The command line must match this string exactly. + Exact(String), +} + +/// Represents a search operation. +pub struct Search<'a> { + /// The history to search through. + history: &'a History, + /// The query to apply. + query: Query, + /// The next index in `items`. + next_index: Option, + /// Count of items returned so far. + count: usize, +} + +impl<'a> Search<'a> { + /// Constructs a new search against the provided history, querying *all* items. + /// + /// # Arguments + /// + /// * `history` - The history to search through. + pub fn all(history: &'a History) -> Self { + Self::new(history, Query::default()) + } + + /// Constructs a new search against the provided history, using the given query. + /// + /// # Arguments + /// + /// * `history` - The history to search through. + /// * `query` - The query to use. + pub fn new(history: &'a History, query: Query) -> Self { + let next_index = match query.direction { + Direction::Forward => Some(0), + Direction::Backward => { + if history.items.is_empty() { + None + } else { + Some(history.items.len() - 1) + } + } + }; + + Self { + history, + query, + next_index, + count: 0, + } + } + + const fn increment_next_index(&mut self) { + if let Some(index) = self.next_index { + self.next_index = match self.query.direction { + Direction::Forward => Some(index + 1), + Direction::Backward => { + if index == 0 { + None + } else { + Some(index - 1) + } + } + } + } + } +} + +impl<'a> Iterator for Search<'a> { + type Item = &'a Item; + + fn next(&mut self) -> Option { + loop { + { + let index = self.next_index?; + // Make sure we haven't hit the end of the history. + if index >= self.history.items.len() { + return None; + } + + let id = self.history.items[index]; + self.increment_next_index(); + + if let Some(item) = self.history.id_map.get(&id) { + // Filter based on max_items. Once we hit the limit, + // we stop searching. + #[expect(clippy::cast_possible_truncation)] + #[expect(clippy::cast_sign_loss)] + if self + .query + .max_items + .is_some_and(|max_items| self.count >= max_items as usize) + { + return None; + } + + // Check other filters. If they don't match, then we + // skip but keep searching. + if self.query.includes(item) { + self.count += 1; + return Some(item); + } + } + } + } + } +} diff --git a/local/recipes/shells/brush/source/brush-core/src/int_utils.rs b/local/recipes/shells/brush/source/brush-core/src/int_utils.rs new file mode 100644 index 0000000000..20e8e93e30 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/int_utils.rs @@ -0,0 +1,67 @@ +//! Generic utilities. + +use crate::error; + +/// Trait for integer types that support parsing from strings with a radix. +pub trait ParseIntRadix: Sized { + /// Parse a string as this integer type using the specified radix. + fn from_str_radix(s: &str, radix: u32) -> Result; + + /// Returns the name of the integer type as a static string. + fn type_name() -> &'static str; +} + +macro_rules! impl_parse_int_radix { + ($t:ty) => { + impl ParseIntRadix for $t { + fn from_str_radix(s: &str, radix: u32) -> Result { + Self::from_str_radix(s, radix) + } + + fn type_name() -> &'static str { + stringify!($t) + } + } + }; +} + +impl_parse_int_radix!(u8); +impl_parse_int_radix!(u16); +impl_parse_int_radix!(i32); +impl_parse_int_radix!(u32); +impl_parse_int_radix!(usize); + +/// Parse the given string as an integer in the specified radix. +/// +/// # Arguments +/// +/// * `s` - The string to parse. +/// * `radix` - The base to use for parsing. +/// +/// # Type Parameters +/// +/// * `T` - The integer type to parse. Must implement `ParseIntRadix`. +/// +/// # Examples +/// +/// ``` +/// use brush_core::int_utils::parse; +/// +/// let result: u32 = parse("42", 10)?; +/// assert_eq!(result, 42); +/// +/// let result: u8 = parse("FF", 16)?; +/// assert_eq!(result, 255); +/// # Ok::<(), brush_core::error::Error>(()) +/// ``` +pub fn parse(s: &str, radix: u32) -> Result { + T::from_str_radix(s, radix).map_err(|inner| { + error::ErrorKind::IntParseError { + s: s.to_owned(), + int_type_name: T::type_name(), + radix, + inner, + } + .into() + }) +} diff --git a/local/recipes/shells/brush/source/brush-core/src/interfaces.rs b/local/recipes/shells/brush/source/brush-core/src/interfaces.rs new file mode 100644 index 0000000000..1bb87ab32d --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/interfaces.rs @@ -0,0 +1,5 @@ +//! Exports traits for shell interfaces implemented by callers. + +mod keybindings; + +pub use keybindings::{InputFunction, Key, KeyAction, KeyBindings, KeySequence, KeyStroke}; diff --git a/local/recipes/shells/brush/source/brush-core/src/interfaces/keybindings.rs b/local/recipes/shells/brush/source/brush-core/src/interfaces/keybindings.rs new file mode 100644 index 0000000000..c06f2a8c98 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/interfaces/keybindings.rs @@ -0,0 +1,420 @@ +use std::{ + collections::HashMap, + fmt::{self, Display, Formatter}, +}; + +/// Represents an action that can be taken in response to a key sequence. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub enum KeyAction { + /// Execute a shell command. + ShellCommand(String), + /// Execute an input "function". + DoInputFunction(InputFunction), + /// Execute a sequence of actions (in order). + Sequence(Vec), +} + +impl Display for KeyAction { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::ShellCommand(command) => write!(f, "shell command: {command}"), + Self::DoInputFunction(function) => function.fmt(f), + Self::Sequence(actions) => { + write!(f, "sequence[")?; + for (i, action) in actions.iter().enumerate() { + if i > 0 { + write!(f, ", ")?; + } + action.fmt(f)?; + } + write!(f, "]") + } + } + } +} + +/// Defines all input functions. Based on standard `readline` functions, +/// augmented with some `brush`-specific extensions. +#[derive( + Clone, + Debug, + Eq, + Hash, + PartialEq, + strum_macros::EnumString, + strum_macros::Display, + strum_macros::EnumIter, + strum_macros::IntoStaticStr, +)] +#[strum(serialize_all = "kebab-case")] +#[expect(missing_docs)] +pub enum InputFunction { + Abort, + AcceptLine, + AliasExpandLine, + ArrowKeyPrefix, + BackwardByte, + BackwardChar, + BackwardDeleteChar, + BackwardKillLine, + BackwardKillWord, + BackwardWord, + BashViComplete, + BeginningOfHistory, + BeginningOfLine, + BracketedPasteBegin, + BrushAcceptHint, + BrushAcceptHintWord, + CallLastKbdMacro, + CapitalizeWord, + CharacterSearch, + CharacterSearchBackward, + ClearDisplay, + ClearScreen, + Complete, + CompleteCommand, + CompleteFilename, + CompleteHostname, + CompleteIntoBraces, + CompleteUsername, + CompleteVariable, + CopyBackwardWord, + CopyForwardWord, + CopyRegionAsKill, + DabbrevExpand, + DeleteChar, + DeleteCharOrList, + DeleteHorizontalSpace, + DigitArgument, + DisplayShellVersion, + DoLowercaseVersion, + DowncaseWord, + DumpFunctions, + DumpMacros, + DumpVariables, + DynamicCompleteHistory, + EditAndExecuteCommand, + EmacsEditingMode, + EndKbdMacro, + EndOfHistory, + EndOfLine, + ExchangePointAndMark, + ExecuteNamedCommand, + ExportCompletions, + FetchHistory, + ForwardBackwardDeleteChar, + ForwardByte, + ForwardChar, + ForwardSearchHistory, + ForwardWord, + GlobCompleteWord, + GlobExpandWord, + GlobListExpansions, + HistoryAndAliasExpandLine, + HistoryExpandLine, + HistorySearchBackward, + HistorySearchForward, + HistorySubstringSearchBackward, + HistorySubstringSearchForward, + InsertComment, + InsertCompletions, + InsertLastArgument, + KillLine, + KillRegion, + KillWholeLine, + KillWord, + MagicSpace, + MenuComplete, + MenuCompleteBackward, + NextHistory, + NextScreenLine, + NonIncrementalForwardSearchHistory, + NonIncrementalForwardSearchHistoryAgain, + NonIncrementalReverseSearchHistory, + NonIncrementalReverseSearchHistoryAgain, + OldMenuComplete, + OperateAndGetNext, + OverwriteMode, + PossibleCommandCompletions, + PossibleCompletions, + PossibleFilenameCompletions, + PossibleHostnameCompletions, + PossibleUsernameCompletions, + PossibleVariableCompletions, + PreviousHistory, + PreviousScreenLine, + PrintLastKbdMacro, + QuotedInsert, + ReReadInitFile, + RedrawCurrentLine, + ReverseSearchHistory, + RevertLine, + SelfInsert, + SetMark, + ShellBackwardKillWord, + ShellBackwardWord, + ShellExpandLine, + ShellForwardWord, + ShellKillWord, + ShellTransposeWords, + SkipCsiSequence, + SpellCorrectWord, + StartKbdMacro, + TabInsert, + TildeExpand, + TransposeChars, + TransposeWords, + TtyStatus, + Undo, + UniversalArgument, + UnixFilenameRubout, + UnixLineDiscard, + UnixWordRubout, + UpcaseWord, + ViAppendEol, + ViAppendMode, + ViArgDigit, + #[strum(serialize = "vi-bWord")] + ViBWord, + ViBackToIndent, + ViBackwardBigword, + ViBackwardWord, + ViBword, + ViChangeCase, + ViChangeChar, + ViChangeTo, + ViCharSearch, + ViColumn, + ViComplete, + ViDelete, + ViDeleteTo, + #[strum(serialize = "vi-eWord")] + ViEWord, + ViEditAndExecuteCommand, + ViEditingMode, + ViEndBigword, + ViEndWord, + ViEofMaybe, + ViEword, + #[strum(serialize = "vi-fWord")] + ViFWord, + ViFetchHistory, + ViFirstPrint, + ViForwardBigword, + ViForwardWord, + ViFword, + ViGotoMark, + ViInsertBeg, + ViInsertionMode, + ViMatch, + ViMovementMode, + ViNextWord, + ViOverstrike, + ViOverstrikeDelete, + ViPrevWord, + ViPut, + ViRedo, + ViReplace, + ViRubout, + ViSearch, + ViSearchAgain, + ViSetMark, + ViSubst, + ViTildeExpand, + ViUndo, + ViUnixWordRubout, + ViYankArg, + ViYankPop, + ViYankTo, + Yank, + YankLastArg, + YankNthArg, + YankPop, +} + +/// Represents a sequence of keys. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub enum KeySequence { + /// Strokes that make up the sequence. + Strokes(Vec), + /// Raw bytes that were used to generate this sequence. + Bytes(Vec>), +} + +impl Display for KeySequence { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::Strokes(strokes) => { + for stroke in strokes { + stroke.fmt(f)?; + } + } + Self::Bytes(bytes) => { + for byte in bytes.iter().flatten() { + if !byte.is_ascii_control() { + write!(f, "{}", *byte as char)?; + } else if *byte == b'\x1b' { + write!(f, r"\e")?; + } else if *byte >= 0x01 && *byte <= 0x1A { + // Control characters: display as \C- + let letter = (b'a' + (*byte - 1)) as char; + write!(f, r"\C-{letter}")?; + } else { + write!(f, r"\x{byte:02x}")?; + } + } + } + } + + Ok(()) + } +} + +impl From for KeySequence { + /// Creates a new key sequence with a single stroke. + fn from(value: KeyStroke) -> Self { + Self::Strokes(vec![value]) + } +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +/// Represents a single key press. +pub struct KeyStroke { + /// Alt key was pressed. + pub alt: bool, + /// Control key was pressed. + pub control: bool, + /// Shift key was pressed. + pub shift: bool, + /// Primary key pressed. + pub key: Key, +} + +impl Display for KeyStroke { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + if self.alt { + write!(f, "\\e")?; + } + if self.control { + write!(f, "\\C-")?; + } + if self.shift { + // TODO(input): Figure out what to do here or if the key encodes the shift in it. + } + self.key.fmt(f) + } +} + +impl From for KeyStroke { + /// Creates a new key stroke with a single key. + fn from(value: Key) -> Self { + Self { + alt: false, + control: false, + shift: false, + key: value, + } + } +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +/// Represents a single key. +pub enum Key { + /// A simple character key. + Character(char), + /// Backspace key. + Backspace, + /// Enter key. + Enter, + /// Left arrow key. + Left, + /// Right arrow key. + Right, + /// Up arrow key. + Up, + /// Down arrow key. + Down, + /// Home key. + Home, + /// End key. + End, + /// Page up key. + PageUp, + /// Page down key. + PageDown, + /// Tab key. + Tab, + /// Shift + Tab key. + BackTab, + /// Delete key. + Delete, + /// Insert key. + Insert, + /// F key. + F(u8), + /// Escape key. + Escape, +} + +impl Display for Key { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::Character(c @ ('\\' | '\"' | '\'')) => write!(f, "\\{c}")?, + Self::Character(c) => write!(f, "{c}")?, + Self::Backspace => write!(f, "Backspace")?, + Self::Enter => write!(f, "Enter")?, + Self::Left => write!(f, "Left")?, + Self::Right => write!(f, "Right")?, + Self::Up => write!(f, "Up")?, + Self::Down => write!(f, "Down")?, + Self::Home => write!(f, "Home")?, + Self::End => write!(f, "End")?, + Self::PageUp => write!(f, "PageUp")?, + Self::PageDown => write!(f, "PageDown")?, + Self::Tab => write!(f, "Tab")?, + Self::BackTab => write!(f, "BackTab")?, + Self::Delete => write!(f, "Delete")?, + Self::Insert => write!(f, "Insert")?, + Self::F(n) => write!(f, "F{n}")?, + Self::Escape => write!(f, "Esc")?, + } + + Ok(()) + } +} + +/// Encapsulates the shell's interaction with key bindings for input. +pub trait KeyBindings: Send { + /// Retrieves current bindings. + fn get_current(&self) -> HashMap; + + /// Tries to find a binding for an untranslated byte sequence. + fn get_untranslated(&self, bytes: &[u8]) -> Option<&KeyAction>; + + /// Sets or updates a binding. + /// + /// # Arguments + /// + /// * `seq` - The key sequence to bind. + /// * `action` - The action to bind to the sequence. + fn bind(&mut self, seq: KeySequence, action: KeyAction) -> Result<(), std::io::Error>; + + /// Unbinds a key sequence. Returns true if a binding was removed. + /// + /// # Arguments + /// + /// * `seq` - The key sequence to unbind. + fn try_unbind(&mut self, seq: KeySequence) -> bool; + + /// Defines a macro that remaps a key sequence to another key sequence. + /// + /// # Arguments + /// + /// * `seq` - The key sequence to bind the macro to. + /// * `target` - The sequence that makes up the macro. + fn define_macro(&mut self, seq: KeySequence, target: KeySequence) + -> Result<(), std::io::Error>; + + /// Retrieves all defined macros. + fn get_macros(&self) -> HashMap; +} diff --git a/local/recipes/shells/brush/source/brush-core/src/interp.rs b/local/recipes/shells/brush/source/brush-core/src/interp.rs new file mode 100644 index 0000000000..ddf637f11d --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/interp.rs @@ -0,0 +1,1989 @@ +use brush_parser::ast::{self, CommandPrefixOrSuffixItem}; +use itertools::Itertools; +use std::borrow::Cow; +use std::collections::VecDeque; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use crate::arithmetic::{self, ExpandAndEvaluate}; +use crate::commands::{self, CommandArg}; +use crate::env::{EnvironmentLookup, EnvironmentScope, valid_variable_name}; +use crate::openfiles::{OpenFile, OpenFiles}; +use crate::results::{ + ExecutionExitCode, ExecutionResult, ExecutionSpawnResult, ExecutionWaitResult, +}; +use crate::shell::Shell; +use crate::variables::{ + ArrayLiteral, ShellValue, ShellValueLiteral, ShellValueUnsetType, ShellVariable, +}; +use crate::{ + ShellFd, error, expansion, extendedtests, extensions, ioutils, jobs, openfiles, sys, timing, +}; + +/// Encapsulates the context of execution in a command pipeline. +struct PipelineExecutionContext<'a, SE: extensions::ShellExtensions> { + /// The shell in which the command should be executed. + shell: commands::ShellForCommand<'a, SE>, + /// Process group ID for spawned processes. + process_group_id: Option, +} + +/// Parameters for execution. +#[derive(Clone, Default)] +pub struct ExecutionParameters { + /// The open files tracked by the current context. + open_files: openfiles::OpenFiles, + /// Policy for how to manage spawned external processes. + pub process_group_policy: ProcessGroupPolicy, + /// Whether `errexit` (exit on error) behavior should be + /// suppressed in this execution context. Defaults to `false`. + pub suppress_errexit: bool, +} + +impl ExecutionParameters { + /// Returns the standard input file; usable with `write!` et al. + /// + /// # Arguments + /// + /// * `shell` - The shell context. + pub fn stdin( + &self, + shell: &Shell, + ) -> impl std::io::Read + 'static { + self.try_stdin(shell).unwrap_or_else(|| { + ioutils::FailingReaderWriter::new("standard input not available").into() + }) + } + + /// Tries to retrieve the standard input file. Returns `None` if not set. + /// + /// # Arguments + /// + /// * `shell` - The shell context. + pub fn try_stdin(&self, shell: &Shell) -> Option { + self.try_fd(shell, openfiles::OpenFiles::STDIN_FD) + } + + /// Returns the standard output file; usable with `write!` et al. In the event that + /// no such file is available, returns a valid implementation of `std::io::Write` + /// that fails all I/O requests. + /// + /// + /// # Arguments + /// + /// * `shell` - The shell context. + pub fn stdout( + &self, + shell: &Shell, + ) -> impl std::io::Write + 'static { + self.try_stdout(shell).unwrap_or_else(|| { + ioutils::FailingReaderWriter::new("standard output not available").into() + }) + } + + /// Tries to retrieve the standard output file. Returns `None` if not set. + /// + /// # Arguments + /// + /// * `shell` - The shell context. + pub fn try_stdout(&self, shell: &Shell) -> Option { + self.try_fd(shell, openfiles::OpenFiles::STDOUT_FD) + } + + /// Returns the standard error file; usable with `write!` et al. In the event that + /// no such file is available, returns a valid implementation of `std::io::Write` + /// that fails all I/O requests. + /// + /// # Arguments + /// + /// * `shell` - The shell context. + pub fn stderr( + &self, + shell: &Shell, + ) -> impl std::io::Write + 'static { + self.try_stderr(shell).unwrap_or_else(|| { + ioutils::FailingReaderWriter::new("standard error not available").into() + }) + } + + /// Tries to retrieve the standard error file. Returns `None` if not set. + /// + /// # Arguments + /// + /// * `shell` - The shell context. + pub fn try_stderr(&self, shell: &Shell) -> Option { + self.try_fd(shell, openfiles::OpenFiles::STDERR_FD) + } + + /// Returns the file descriptor with the given number. Returns `None` + /// if the file descriptor is not open. + /// + /// # Arguments + /// + /// * `shell` - The shell context. + /// * `fd` - The file descriptor number to retrieve. + pub fn try_fd( + &self, + shell: &Shell, + fd: ShellFd, + ) -> Option { + match self.open_files.fd_entry(fd) { + openfiles::OpenFileEntry::Open(f) => Some(f.clone()), + openfiles::OpenFileEntry::NotPresent => None, + openfiles::OpenFileEntry::NotSpecified => { + // We didn't have this fd specified one way or the other; we fallback + // to what's represented in the shell's open files. + shell.persistent_open_files().try_fd(fd).cloned() + } + } + } + + /// Sets the given file descriptor to the provided open file. + /// + /// # Arguments + /// + /// * `fd` - The file descriptor number to set. + /// * `file` - The open file to set. + pub fn set_fd(&mut self, fd: ShellFd, file: openfiles::OpenFile) { + self.open_files.set_fd(fd, file); + } + + /// Iterates over all open file descriptors in this context. + /// + /// # Arguments + /// + /// * `shell` - The shell context. + pub fn iter_fds( + &self, + shell: &Shell, + ) -> impl Iterator { + let our_fds = self.open_files.iter_fds(); + let shell_fds = shell + .persistent_open_files() + .iter_fds() + .filter(|(fd, _)| !self.open_files.contains_fd(*fd)); + + #[allow(clippy::needless_collect)] + let all_fds: Vec<_> = our_fds + .chain(shell_fds) + .map(|(fd, file)| (fd, file.clone())) + .collect(); + + all_fds.into_iter() + } +} + +#[derive(Clone, Debug, Default)] +/// Policy for how to manage spawned external processes. +pub enum ProcessGroupPolicy { + /// Place the process in a new process group. + #[default] + NewProcessGroup, + /// Place the process in the same process group as its parent. + SameProcessGroup, +} + +#[async_trait::async_trait] +pub trait Execute { + async fn execute( + &self, + shell: &mut Shell, + params: &ExecutionParameters, + ) -> Result; +} + +#[async_trait::async_trait] +trait ExecuteInPipeline { + async fn execute_in_pipeline( + &self, + context: PipelineExecutionContext<'_, SE>, + params: ExecutionParameters, + ) -> Result; +} + +#[async_trait::async_trait] +impl Execute for ast::Program { + async fn execute( + &self, + shell: &mut Shell, + params: &ExecutionParameters, + ) -> Result { + let mut result = ExecutionResult::success(); + + for command in &self.complete_commands { + // Execute the command and handle any errors without immediately propagating them. + // This allows interactive shells to continue executing subsequent commands even after + // errors. + match command.execute(shell, params).await { + Ok(exec_result) => result = exec_result, + Err(err) => { + // Display the error and convert to an execution result. + let _ = shell.display_error(&mut params.stderr(shell), &err); + result = err.into_result(shell); + } + } + + // Update status + shell.set_last_exit_status(result.exit_code.into()); + + // Check if we should stop executing subsequent commands + if !result.is_normal_flow() { + break; + } + } + + Ok(result) + } +} + +#[async_trait::async_trait] +impl Execute for ast::CompoundList { + async fn execute( + &self, + shell: &mut Shell, + params: &ExecutionParameters, + ) -> Result { + let mut result = ExecutionResult::success(); + + for ast::CompoundListItem(ao_list, sep) in &self.0 { + let run_async = matches!(sep, ast::SeparatorOperator::Async); + + if run_async { + let job = spawn_async_ao_list_in_task(ao_list, shell, params); + let job_formatted = job.to_pid_style_string(); + + if shell.options().interactive && !shell.is_subshell() { + writeln!(params.stderr(shell), "{job_formatted}")?; + } + + result = ExecutionResult::success(); + } else { + result = ao_list.execute(shell, params).await?; + + // Update status + shell.set_last_exit_status(result.exit_code.into()); + } + + if !result.is_normal_flow() { + break; + } + } + + Ok(result) + } +} + +fn spawn_async_ao_list_in_task<'a, SE: extensions::ShellExtensions>( + ao_list: &ast::AndOrList, + shell: &'a mut Shell, + params: &ExecutionParameters, +) -> &'a jobs::Job { + // Clone the inputs. + let mut cloned_shell = shell.clone(); + let mut cloned_params = params.clone(); + let cloned_ao_list = ao_list.clone(); + + // Mark the child shell as not interactive; we don't want it messing with the terminal too much. + cloned_shell.options_mut().interactive = false; + + // Redirect stdin to null, per spec. + if let Ok(null) = openfiles::null() { + cloned_params.set_fd(openfiles::OpenFiles::STDIN_FD, null); + } + + let join_handle = tokio::spawn(async move { + cloned_ao_list + .execute(&mut cloned_shell, &cloned_params) + .await + }); + + shell.jobs_mut().add_as_current(jobs::Job::new( + [jobs::JobTask::Internal(join_handle)], + ao_list.to_string(), + jobs::JobState::Running, + )) +} + +#[async_trait::async_trait] +impl Execute for ast::AndOrList { + async fn execute( + &self, + shell: &mut Shell, + params: &ExecutionParameters, + ) -> Result { + let has_operators = !self.additional.is_empty(); + + // For the first command, suppress errexit if there are more commands after it + let mut first_params = params.clone(); + if has_operators { + first_params.suppress_errexit = true; + } + + let mut result = self.first.execute(shell, &first_params).await?; + + for (index, next_ao) in self.additional.iter().enumerate() { + // Check for non-normal control flow. + if !result.is_normal_flow() { + break; + } + + let (is_and, pipeline) = match next_ao { + ast::AndOr::And(p) => (true, p), + ast::AndOr::Or(p) => (false, p), + }; + + // If we short-circuit, then we don't break out of the whole loop + // but we skip evaluating the current pipeline. We'll then continue + // on and possibly evaluate a subsequent one (depending on the + // operator before it). + if is_and { + if !result.is_success() { + continue; + } + } else if result.is_success() { + continue; + } + + // For the last command in the chain, use original params (errexit not suppressed) + // For earlier commands, suppress errexit + let mut params = params.clone(); + + let is_last = index == self.additional.len() - 1; + if !is_last { + params.suppress_errexit = true; + } + + result = pipeline.execute(shell, ¶ms).await?; + } + + Ok(result) + } +} + +#[async_trait::async_trait] +impl Execute for ast::Pipeline { + async fn execute( + &self, + shell: &mut Shell, + params: &ExecutionParameters, + ) -> Result { + // Capture current timing if so requested. + let stopwatch = self + .timed + .is_some() + .then(timing::start_timing) + .transpose()?; + + let mut params = params.clone(); + + // If this pipeline is negated, suppress errexit for commands within it + if self.bang { + params.suppress_errexit = true; + } + + // Spawn all the processes required for the pipeline, connecting outputs/inputs with pipes + // as needed. + let spawn_results = spawn_pipeline_processes(self, shell, ¶ms).await?; + + // Wait for the processes. This also has a side effect of updating pipeline status. + let mut result = + wait_for_pipeline_processes_and_update_status(self, spawn_results, shell, ¶ms) + .await?; + + // Invert the exit code if requested. + if self.bang { + result.exit_code = ExecutionExitCode::from(if result.is_success() { 1 } else { 0 }); + } + + // Update exit status. + shell.set_last_exit_status(result.exit_code.into()); + + // Fire the ERR trap if the pipeline failed in a non-conditional context. + // We reuse `suppress_errexit` here because bash suppresses the ERR trap in + // exactly the same contexts it suppresses errexit (conditionals, `!`-prefixed + // pipelines, etc.). + if !result.is_success() && !params.suppress_errexit && !self.bang { + if shell.traps().handles(crate::traps::TrapSignal::Err) { + shell + .invoke_trap_handler(crate::traps::TrapSignal::Err, ¶ms) + .await?; + } + } + + // Apply errexit if not suppressed (and not negated) + if !params.suppress_errexit && !self.bang { + shell.apply_errexit_if_enabled(&mut result); + } + + // If requested, report timing. + if let (Some(timed), Some(stopwatch)) = (&self.timed, &stopwatch) + && let Some(mut stderr) = params.try_fd(shell, openfiles::OpenFiles::STDERR_FD) + { + let timing = stopwatch.stop()?; + if timed.is_posix_output() { + std::write!( + stderr, + "real {}\nuser {}\nsys {}\n", + timing::format_duration_posixly(&timing.wall), + timing::format_duration_posixly(&timing.user), + timing::format_duration_posixly(&timing.system), + )?; + } else { + std::write!( + stderr, + "\nreal\t{}\nuser\t{}\nsys\t{}\n", + timing::format_duration_non_posixly(&timing.wall), + timing::format_duration_non_posixly(&timing.user), + timing::format_duration_non_posixly(&timing.system), + )?; + } + } + + Ok(result) + } +} + +async fn spawn_pipeline_processes( + pipeline: &ast::Pipeline, + shell: &mut Shell, + params: &ExecutionParameters, +) -> Result, error::Error> { + let pipeline_len = pipeline.seq.len(); + let mut pipe_readers = vec![]; + let mut pipe_writers = vec![]; + let mut spawn_results = VecDeque::new(); + let mut process_group_id: Option = None; + + // Create pipes to use between commands, but only bother doing so if there's more than one + // command. + if pipeline_len > 1 { + pipe_readers.reserve_exact(pipeline_len - 1); + pipe_writers.reserve_exact(pipeline_len - 1); + + for _ in 0..(pipeline_len - 1) { + let (reader, writer) = std::io::pipe()?; + pipe_readers.push(Some(reader.into())); + pipe_writers.push(Some(writer.into())); + } + // Push `None` to the readers; it will be popped off by the *first* command, which will + // mean that command gets its stdin from the execution parameters' current stdin. + pipe_readers.push(None); + } + + for (current_pipeline_index, command) in pipeline.seq.iter().enumerate() { + // + // We run a command directly in the current shell if either of the following is true: + // * There's only one command in the pipeline. + // * This is the *last* command in the pipeline, the lastpipe option is enabled, and job + // monitoring is disabled. + // Otherwise, we spawn a separate subshell for each command in the pipeline. + // + + let run_in_current_shell = pipeline_len == 1 + || (current_pipeline_index == pipeline_len - 1 + && shell.options().run_last_pipeline_cmd_in_current_shell + && !shell.options().enable_job_control); + + // Set up parameters appropriate for this command. + let mut cmd_params = params.clone(); + + // Install pipes. + if let Some(Some(reader)) = pipe_readers.pop() { + cmd_params.open_files.set_fd(OpenFiles::STDIN_FD, reader); + } + if let Some(Some(writer)) = pipe_writers.pop() { + cmd_params.open_files.set_fd(OpenFiles::STDOUT_FD, writer); + } + + let pipeline_context = if !run_in_current_shell { + // Make sure that all commands in the pipeline are in the same process group. + if current_pipeline_index > 0 { + cmd_params.process_group_policy = ProcessGroupPolicy::SameProcessGroup; + } + + PipelineExecutionContext { + shell: commands::ShellForCommand::OwnedShell { + target: Box::new(shell.clone()), + parent: shell, + }, + process_group_id, + } + } else { + PipelineExecutionContext { + shell: commands::ShellForCommand::ParentShell(shell), + process_group_id, + } + }; + + let spawn_result = command + .execute_in_pipeline(pipeline_context, cmd_params) + .await?; + + // Update the process group ID if something was spawned. + if let ExecutionSpawnResult::StartedProcess(child) = &spawn_result { + if process_group_id.is_none() { + process_group_id = child.pgid(); + } + } + + spawn_results.push_back(spawn_result); + } + + Ok(spawn_results) +} + +async fn wait_for_pipeline_processes_and_update_status( + pipeline: &ast::Pipeline, + mut process_spawn_results: VecDeque, + shell: &mut Shell, + params: &ExecutionParameters, +) -> Result { + let mut result = ExecutionResult::success(); + let mut stopped_children = vec![]; + let mut last_failure_exit_code: Option = None; + + // Clear our the pipeline status so we can start filling it out. + shell.last_pipeline_statuses_mut().clear(); + + while let Some(child) = process_spawn_results.pop_front() { + let wait_result = if !stopped_children.is_empty() { + child.poll().await? + } else { + child.wait().await? + }; + + match wait_result { + ExecutionWaitResult::Completed(current_result) => { + result = current_result; + shell.set_last_exit_status(result.exit_code.into()); + shell + .last_pipeline_statuses_mut() + .push(result.exit_code.into()); + + // Track the last failure for pipefail option + if !result.is_success() { + last_failure_exit_code = Some(result.exit_code); + } + } + ExecutionWaitResult::Stopped(child) => { + result = ExecutionResult::stopped(); + shell.set_last_exit_status(result.exit_code.into()); + shell + .last_pipeline_statuses_mut() + .push(result.exit_code.into()); + + stopped_children.push(jobs::JobTask::External(child)); + } + } + } + + // Apply pipefail semantics if enabled + if shell.options().return_last_failure_from_pipeline { + if let Some(failure_exit_code) = last_failure_exit_code { + result.exit_code = failure_exit_code; + } + } + + if shell.options().interactive { + sys::terminal::move_self_to_foreground()?; + } + + // If there were stopped jobs, then encapsulate the pipeline as a managed job and hand it + // off to the job manager. + if !stopped_children.is_empty() { + let job = shell.jobs_mut().add_as_current(jobs::Job::new( + stopped_children, + pipeline.to_string(), + jobs::JobState::Stopped, + )); + + let formatted = job.to_string(); + + // N.B. We use the '\r' to overwrite any ^Z output. + writeln!(params.stderr(shell), "\r{formatted}")?; + } + + Ok(result) +} + +#[async_trait::async_trait] +impl ExecuteInPipeline for ast::Command { + async fn execute_in_pipeline( + &self, + mut pipeline_context: PipelineExecutionContext<'_, SE>, + mut params: ExecutionParameters, + ) -> Result { + if pipeline_context.shell.options().do_not_execute_commands { + return Ok(ExecutionSpawnResult::Completed(ExecutionResult::success())); + } + + // Updates the shell with information about the currently executing command. + pipeline_context.shell.set_current_cmd(self); + + match self { + Self::Simple(simple) => simple.execute_in_pipeline(pipeline_context, params).await, + Self::Compound(compound, redirects) => { + // Set up any additional redirects. + if let Some(redirects) = redirects { + for redirect in &redirects.0 { + setup_redirect(&mut pipeline_context.shell, &mut params, redirect).await?; + } + } + + Ok(compound + .execute(&mut pipeline_context.shell, ¶ms) + .await? + .into()) + } + Self::Function(func) => Ok(func + .execute(&mut pipeline_context.shell, ¶ms) + .await? + .into()), + Self::ExtendedTest(e, redirects) => { + // Set up any additional redirects. + if let Some(redirects) = redirects { + for redirect in &redirects.0 { + setup_redirect(&mut pipeline_context.shell, &mut params, redirect).await?; + } + } + + // Evaluate the extended test expression. + let result = if extendedtests::eval_extended_test_expr( + &e.expr, + &mut pipeline_context.shell, + ¶ms, + ) + .await? + { + 0 + } else { + 1 + }; + Ok(ExecutionResult::new(result).into()) + } + } + } +} + +enum WhileOrUntil { + While, + Until, +} + +#[async_trait::async_trait] +impl Execute for ast::CompoundCommand { + async fn execute( + &self, + shell: &mut Shell, + params: &ExecutionParameters, + ) -> Result { + match self { + Self::BraceGroup(ast::BraceGroupCommand { list, .. }) => { + list.execute(shell, params).await + } + Self::Subshell(ast::SubshellCommand { list, .. }) => { + // Clone off a new subshell, and run the body of the subshell there. + // TODO(source-info): Do we need to reset the line number? + let mut subshell = shell.clone(); + + // Handle errors within the subshell context to prevent fatal errors + // from propagating to the parent shell. + let subshell_result = match list.execute(&mut subshell, params).await { + Ok(result) => result, + Err(error) => { + // Display the error to stderr, but prevent fatal error propagation + let mut stderr = params.stderr(shell); + let _ = shell.display_error(&mut stderr, &error); + + // Convert error to result in subshell context + error.into_result(&subshell) + } + }; + + // Preserve the subshell's exit code, but don't honor any of its requests to exit + // the shell, break out of loops, etc. + Ok(ExecutionResult::from(subshell_result.exit_code)) + } + Self::ForClause(f) => f.execute(shell, params).await, + Self::CaseClause(c) => c.execute(shell, params).await, + Self::IfClause(i) => i.execute(shell, params).await, + Self::WhileClause(w) => (WhileOrUntil::While, w).execute(shell, params).await, + Self::UntilClause(u) => (WhileOrUntil::Until, u).execute(shell, params).await, + Self::Arithmetic(a) => a.execute(shell, params).await, + Self::ArithmeticForClause(a) => a.execute(shell, params).await, + Self::Coprocess(c) => c.execute(shell, params).await, + } + } +} + +#[async_trait::async_trait] +impl Execute for ast::CoprocessCommand { + async fn execute( + &self, + shell: &mut Shell, + params: &ExecutionParameters, + ) -> Result { + if shell.options().do_not_execute_commands { + return Ok(ExecutionResult::success()); + } + + // Resolve the name of the variable that will receive the coprocess's file descriptors. + let name = self + .name + .as_ref() + .map_or(Cow::Borrowed("COPROC"), |w| Cow::Owned(w.to_string())); + + if !valid_variable_name(&name) { + writeln!( + params.stderr(shell), + "coproc {name}: not a valid identifier" + )?; + return Ok(ExecutionExitCode::GeneralError.into()); + } + + // Set up the pipes that we'll use to communicate with the coprocess. + let (stdin_reader, stdin_writer) = std::io::pipe()?; + let (stdout_reader, stdout_writer) = std::io::pipe()?; + + // Allocate new fds in the (parent) shell for the read end of the coprocess's stdout + // and the write end of the coprocess's stdin. + let stdout_fd = shell.open_files_mut().add(stdout_reader.into())?; + let stdin_fd = shell.open_files_mut().add(stdin_writer.into())?; + + // Crete a subshell that the coprocess will own and run in. + let mut child_shell = shell.clone(); + child_shell.options_mut().interactive = false; + + // Setup redirection for the coprocess's shell's stdin/stdout. + let mut child_params = params.clone(); + child_params + .open_files + .set_fd(OpenFiles::STDIN_FD, stdin_reader.into()); + child_params + .open_files + .set_fd(OpenFiles::STDOUT_FD, stdout_writer.into()); + + let body = self.body.clone(); + let join_handle = tokio::spawn(async move { + let pipeline_context = PipelineExecutionContext { + shell: commands::ShellForCommand::ParentShell(&mut child_shell), + process_group_id: None, + }; + let spawn_result = body + .execute_in_pipeline(pipeline_context, child_params) + .await?; + match spawn_result.wait().await? { + ExecutionWaitResult::Completed(result) => Ok(result), + ExecutionWaitResult::Stopped(_) => Ok(ExecutionResult::stopped()), + } + }); + + let job = shell.jobs_mut().add_as_current(jobs::Job::new( + [jobs::JobTask::Internal(join_handle)], + format!("coproc {name}"), + jobs::JobState::Running, + )); + let job_id = job.id; + + // Fill out the fd variable. + let arr_value = ShellValue::from(vec![stdout_fd.to_string(), stdin_fd.to_string()]); + shell + .env_mut() + .set_global(name.clone(), ShellVariable::new(arr_value))?; + + // Set the job ID for the coprocess in a separate variable with the _PID suffix. + let pid_name = format!("{name}_PID"); + shell + .env_mut() + .set_global(pid_name, ShellVariable::new(job_id.to_string()))?; + + Ok(ExecutionResult::success()) + } +} + +#[async_trait::async_trait] +impl Execute for ast::ForClauseCommand { + async fn execute( + &self, + shell: &mut Shell, + params: &ExecutionParameters, + ) -> Result { + let mut result = ExecutionResult::success(); + + // If we were given explicit words to iterate over, then expand them all, with splitting + // enabled. + let mut expanded_values = vec![]; + if let Some(unexpanded_values) = &self.values { + for value in unexpanded_values { + let mut expanded = + expansion::full_expand_and_split_word(shell, params, value).await?; + expanded_values.append(&mut expanded); + } + } else { + // Otherwise, we use the current positional parameters. + expanded_values.extend_from_slice(shell.current_shell_args()); + } + + for value in expanded_values { + if shell.options().print_commands_and_arguments { + if let Some(unexpanded_values) = &self.values { + shell + .trace_command( + params, + std::format!( + "for {} in {}", + self.variable_name, + unexpanded_values.iter().join(" ") + ), + ) + .await; + } else { + shell + .trace_command(params, std::format!("for {}", self.variable_name)) + .await; + } + } + + // Update the variable. + shell.env_mut().update_or_add( + &self.variable_name, + ShellValueLiteral::Scalar(value), + |_| Ok(()), + EnvironmentLookup::Anywhere, + EnvironmentScope::Global, + )?; + + result = self.body.list.execute(shell, params).await?; + if result.is_return_or_exit() { + break; + } + + let is_break = result.is_break(); + + result.next_control_flow = result.next_control_flow.try_decrement_loop_levels(); + + if is_break || result.is_continue() { + break; + } + } + + shell.set_last_exit_status(result.exit_code.into()); + Ok(result) + } +} + +#[async_trait::async_trait] +impl Execute for ast::CaseClauseCommand { + async fn execute( + &self, + shell: &mut Shell, + params: &ExecutionParameters, + ) -> Result { + // N.B. One would think it makes sense to trace the expanded value being switched + // on, but that's not it. + if shell.options().print_commands_and_arguments { + shell + .trace_command(params, std::format!("case {} in", self.value)) + .await; + } + + let expanded_value = expansion::basic_expand_word(shell, params, &self.value).await?; + let mut result: ExecutionResult = ExecutionResult::success(); + let mut force_execute_next_case = false; + + for case in &self.cases { + if force_execute_next_case { + force_execute_next_case = false; + } else { + let mut matches = false; + for pattern in &case.patterns { + let expanded_pattern = expansion::basic_expand_pattern(shell, params, pattern) + .await? + .set_extended_globbing(shell.options().extended_globbing) + .set_case_insensitive(shell.options().case_insensitive_conditionals); + + if expanded_pattern.exactly_matches(expanded_value.as_str())? { + matches = true; + break; + } + } + + if !matches { + continue; + } + } + + result = if let Some(case_cmd) = &case.cmd { + case_cmd.execute(shell, params).await? + } else { + ExecutionResult::success() + }; + + // Check for early return (return/exit) or loop control flow (break/continue) + if !result.is_normal_flow() { + break; + } + + match case.post_action { + ast::CaseItemPostAction::ExitCase => break, + ast::CaseItemPostAction::UnconditionallyExecuteNextCaseItem => { + force_execute_next_case = true; + } + ast::CaseItemPostAction::ContinueEvaluatingCases => (), + } + } + + shell.set_last_exit_status(result.exit_code.into()); + + Ok(result) + } +} + +#[async_trait::async_trait] +impl Execute for ast::IfClauseCommand { + async fn execute( + &self, + shell: &mut Shell, + params: &ExecutionParameters, + ) -> Result { + // Execute condition with errexit suppressed + let mut condition_params = params.clone(); + condition_params.suppress_errexit = true; + let condition = self.condition.execute(shell, &condition_params).await?; + + // Check if the condition itself resulted in non-normal control flow. + if !condition.is_normal_flow() { + return Ok(condition); + } + + if condition.is_success() { + return self.then.execute(shell, params).await; + } + + if let Some(elses) = &self.elses { + for else_clause in elses { + match &else_clause.condition { + Some(else_condition) => { + let else_condition_result = + else_condition.execute(shell, &condition_params).await?; + + // Check if the elif condition caused non-normal control flow. + if !else_condition_result.is_normal_flow() { + return Ok(else_condition_result); + } + + if else_condition_result.is_success() { + return else_clause.body.execute(shell, params).await; + } + } + None => { + return else_clause.body.execute(shell, params).await; + } + } + } + } + + // If we got down here, then no branch was taken; we make sure to + // reset the last exit status to success and then return success. + let result = ExecutionResult::success(); + shell.set_last_exit_status(result.exit_code.into()); + + Ok(result) + } +} + +#[async_trait::async_trait] +impl Execute for (WhileOrUntil, &ast::WhileOrUntilClauseCommand) { + async fn execute( + &self, + shell: &mut Shell, + params: &ExecutionParameters, + ) -> Result { + let is_while = match self.0 { + WhileOrUntil::While => true, + WhileOrUntil::Until => false, + }; + let test_condition = &self.1.0; + let body = &self.1.1; + + let mut result = ExecutionResult::success(); + + // Execute loop condition with errexit suppressed + let mut condition_params = params.clone(); + condition_params.suppress_errexit = true; + + loop { + let condition_result = test_condition.execute(shell, &condition_params).await?; + + // Update status for condition + shell.set_last_exit_status(condition_result.exit_code.into()); + + if !condition_result.is_normal_flow() { + result = condition_result; + + // If the condition has break/continue, the while/until loop itself + // consumes one level. We need to decrement the level before returning. + result.next_control_flow = result.next_control_flow.try_decrement_loop_levels(); + break; + } + + if condition_result.is_success() != is_while { + break; + } + + result = body.list.execute(shell, params).await?; + if result.is_return_or_exit() { + break; + } + + let is_break = result.is_break(); + + result.next_control_flow = result.next_control_flow.try_decrement_loop_levels(); + + if is_break || result.is_continue() { + break; + } + } + + shell.set_last_exit_status(result.exit_code.into()); + Ok(result) + } +} + +#[async_trait::async_trait] +impl Execute for ast::ArithmeticCommand { + async fn execute( + &self, + shell: &mut Shell, + params: &ExecutionParameters, + ) -> Result { + let value = self.expr.eval(shell, params, true).await?; + let result = if value != 0 { + ExecutionResult::success() + } else { + ExecutionResult::general_error() + }; + + shell.set_last_exit_status(result.exit_code.into()); + + Ok(result) + } +} + +#[async_trait::async_trait] +impl Execute for ast::ArithmeticForClauseCommand { + async fn execute( + &self, + shell: &mut Shell, + params: &ExecutionParameters, + ) -> Result { + let mut result = ExecutionResult::success(); + if let Some(initializer) = &self.initializer { + initializer.eval(shell, params, true).await?; + } + + loop { + if let Some(condition) = &self.condition { + // An empty condition (e.g., `for (( ; ; ))`) means "always true". + if !condition.value.is_empty() && condition.eval(shell, params, true).await? == 0 { + break; + } + } + + result = self.body.list.execute(shell, params).await?; + if result.is_return_or_exit() { + break; + } + + let is_break = result.is_break(); + + result.next_control_flow = result.next_control_flow.try_decrement_loop_levels(); + + if is_break || result.is_continue() { + break; + } + + if let Some(updater) = &self.updater { + updater.eval(shell, params, true).await?; + } + } + + shell.set_last_exit_status(result.exit_code.into()); + Ok(result) + } +} + +#[async_trait::async_trait] +impl Execute for ast::FunctionDefinition { + async fn execute( + &self, + shell: &mut Shell, + _params: &ExecutionParameters, + ) -> Result { + let func_name = self.fname.value.clone(); + + // In POSIX mode, function names can't shadow special builtins. + if shell.options().posix_mode + && shell + .builtins() + .get(&func_name) + .is_some_and(|r| r.special_builtin) + { + return Err( + error::Error::from(error::ErrorKind::FunctionNameShadowsSpecialBuiltin { + name: func_name, + }) + .into_fatal(), + ); + } + + // The function definition's source context should be the same as the current frame + // so we directly pass that through. + let source_info = shell + .call_stack() + .current_frame() + .map_or_else(crate::SourceInfo::default, |frame| { + frame.adjusted_source_info() + }); + shell.define_func(func_name, self.clone(), &source_info); + + let result = ExecutionResult::success(); + shell.set_last_exit_status(result.exit_code.into()); + + Ok(result) + } +} + +#[async_trait::async_trait] +#[allow(clippy::too_many_lines)] +impl ExecuteInPipeline for ast::SimpleCommand { + async fn execute_in_pipeline( + &self, + mut context: PipelineExecutionContext<'_, SE>, + mut params: ExecutionParameters, + ) -> Result { + let prefix_iter = self.prefix.as_ref().map(|s| s.0.iter()).unwrap_or_default(); + let suffix_iter = self.suffix.as_ref().map(|s| s.0.iter()).unwrap_or_default(); + let cmd_name_items = self + .word_or_name + .as_ref() + .map(|won| CommandPrefixOrSuffixItem::Word(won.clone())); + + let mut assignments = vec![]; + let mut args: Vec = vec![]; + let mut command_takes_assignments = false; + + // Capture the status change count before expansion, so we can detect + // if expansion (e.g., command substitution) set an exit status. + let status_change_count_before_expansion = context.shell.last_exit_status_change_count(); + + for item in prefix_iter.chain(cmd_name_items.iter()).chain(suffix_iter) { + match item { + CommandPrefixOrSuffixItem::IoRedirect(redirect) => { + if let Err(e) = setup_redirect(&mut context.shell, &mut params, redirect).await + { + writeln!(params.stderr(&context.shell), "error: {e}")?; + return Ok(ExecutionResult::general_error().into()); + } + } + CommandPrefixOrSuffixItem::ProcessSubstitution(kind, subshell_command) => { + let (installed_fd_num, substitution_file) = setup_process_substitution( + &context.shell, + ¶ms, + kind, + subshell_command, + )?; + + params + .open_files + .set_fd(installed_fd_num, substitution_file); + + args.push(CommandArg::String(std::format!( + "/dev/fd/{installed_fd_num}" + ))); + } + CommandPrefixOrSuffixItem::AssignmentWord(assignment, word) => { + if args.is_empty() { + // If we haven't yet seen any arguments, then this must be a proper + // scoped assignment. Add it to the list we're accumulating. + assignments.push(assignment); + } else { + if command_takes_assignments { + // This looks like an assignment, and the command being invoked is a + // well-known builtin that takes arguments that need to function like + // assignments (but which are processed by the builtin). + let expanded = + expand_assignment(&mut context.shell, ¶ms, assignment).await?; + args.push(CommandArg::Assignment(expanded)); + } else { + // This *looks* like an assignment, but it's really a string we should + // fully treat as a regular looking + // argument. + let mut next_args = expansion::full_expand_and_split_word( + &mut context.shell, + ¶ms, + word, + ) + .await? + .into_iter() + .map(CommandArg::String) + .collect(); + args.append(&mut next_args); + } + } + } + CommandPrefixOrSuffixItem::Word(arg) => { + let mut next_args = + expansion::full_expand_and_split_word(&mut context.shell, ¶ms, arg) + .await?; + + if args.is_empty() { + if let Some(cmd_name) = next_args.first() { + if let Some(alias_value) = + context.shell.aliases().get(cmd_name.as_str()) + { + // + // TODO(#57): This is a total hack; aliases are supposed to be + // handled much earlier in the process. + // + let mut alias_pieces: Vec<_> = alias_value + .split_ascii_whitespace() + .map(|i| i.to_owned()) + .collect(); + + next_args.remove(0); + alias_pieces.append(&mut next_args); + + next_args = alias_pieces; + } + + let first_arg = next_args[0].as_str(); + + // Check if we're going to be invoking a special declaration builtin. + // That will change how we parse and process args. + if context + .shell + .builtins() + .get(first_arg) + .is_some_and(|r| !r.disabled && r.declaration_builtin) + { + command_takes_assignments = true; + } + } + } + + let mut next_args = next_args.into_iter().map(CommandArg::String).collect(); + args.append(&mut next_args); + } + } + } + + // If we have a command, then execute it. + if let Some(CommandArg::String(cmd_name)) = args.first() { + let mut stderr = params.stderr(&context.shell); + + let (owned_shell, parent_shell) = match context.shell { + commands::ShellForCommand::ParentShell(shell) => (None, shell), + commands::ShellForCommand::OwnedShell { target, parent } => (Some(target), parent), + }; + + let shell = if let Some(owned_shell) = owned_shell { + commands::ShellForCommand::OwnedShell { + target: owned_shell, + parent: parent_shell, + } + } else { + commands::ShellForCommand::ParentShell(parent_shell) + }; + + let context = PipelineExecutionContext { + shell, + process_group_id: context.process_group_id, + }; + + match execute_command(context, params, cmd_name, &assignments, &args).await { + Ok(result) => Ok(result), + Err(err) => { + let _ = parent_shell.display_error(&mut stderr, &err); + + let result = err.into_result(parent_shell); + Ok(result.into()) + } + } + } else { + // No command to run; assignments must be applied to this shell. + for assignment in assignments { + // Apply the assignment. Don't mark as fatal - let errors be handled + // at the program level so multiple complete_commands can execute independently. + apply_assignment( + assignment, + &mut context.shell, + ¶ms, + false, + None, + EnvironmentScope::Global, + ) + .await?; + } + + // Assignment-only statements clear $_ (set to empty string). + // This matches bash behavior where assignments don't have a "last + // argument". + context.shell.update_last_arg_variable(None); + + // We need to set the last exit status to indicate assignment success, + // but only if there was no status set during expansion. We use the + // status count captured before expansion to detect if command + // substitution (or other expansion) set an exit status. + if status_change_count_before_expansion == context.shell.last_exit_status_change_count() + { + context.shell.set_last_exit_status(0); + } + + // Return the last exit status we have; in some cases, an expansion + // might result in a non-zero exit status stored in the shell. + Ok(ExecutionResult::new(context.shell.last_exit_status()).into()) + } + } +} + +async fn execute_command>( + mut context: PipelineExecutionContext<'_, impl extensions::ShellExtensions>, + params: ExecutionParameters, + cmd_name: T, + assignments: &[&ast::Assignment], + args: &[CommandArg], +) -> Result { + // Push a new ephemeral environment scope for the duration of the command. We'll + // set command-scoped variable assignments after doing so, and revert them before + // returning. + let mut guard = crate::env::ScopeGuard::new(&mut context.shell, EnvironmentScope::Command); + + for assignment in assignments { + // Ensure it's tagged as exported and created in the command scope. + apply_assignment( + assignment, + guard.shell(), + ¶ms, + true, + Some(EnvironmentScope::Command), + EnvironmentScope::Command, + ) + .await?; + } + + if guard.shell().options().print_commands_and_arguments { + guard + .shell() + .trace_command( + ¶ms, + args.iter().map(|arg| arg.quote_for_tracing()).join(" "), + ) + .await; + } + + guard.detach(); + drop(guard); + + // Construct the command struct. + let mut cmd = + commands::SimpleCommand::new(context.shell, params, cmd_name.into(), args.iter().cloned()); + cmd.process_group_id = context.process_group_id; + + // Arrange to pop off that ephemeral environment scope. + cmd.post_execute = Some(|shell| shell.env_mut().pop_scope(EnvironmentScope::Command)); + + // Run through any pre-execution hooks as best effort. + let _ = commands::on_preexecute(&mut cmd).await; + + // Execute + // TODO(jobs): do we need to move self back to foreground on error here? + cmd.execute().await +} + +async fn expand_assignment( + shell: &mut Shell, + params: &ExecutionParameters, + assignment: &ast::Assignment, +) -> Result { + let value = expand_assignment_value(shell, params, &assignment.value).await?; + Ok(ast::Assignment { + name: basic_expand_assignment_name(shell, params, &assignment.name).await?, + value, + append: assignment.append, + loc: assignment.loc.clone(), + }) +} + +async fn basic_expand_assignment_name( + shell: &mut Shell, + params: &ExecutionParameters, + name: &ast::AssignmentName, +) -> Result { + match name { + ast::AssignmentName::VariableName(name) => { + let expanded = expansion::basic_expand_word(shell, params, name).await?; + Ok(ast::AssignmentName::VariableName(expanded)) + } + ast::AssignmentName::ArrayElementName(name, index) => { + let expanded_name = expansion::basic_expand_word(shell, params, name).await?; + let expanded_index = expansion::basic_expand_word(shell, params, index).await?; + Ok(ast::AssignmentName::ArrayElementName( + expanded_name, + expanded_index, + )) + } + } +} + +async fn expand_assignment_value( + shell: &mut Shell, + params: &ExecutionParameters, + value: &ast::AssignmentValue, +) -> Result { + let expanded = match value { + ast::AssignmentValue::Scalar(s) => { + let expanded_word = expansion::basic_expand_assignment_word(shell, params, s).await?; + ast::AssignmentValue::Scalar(ast::Word::from(expanded_word)) + } + ast::AssignmentValue::Array(arr) => { + let mut expanded_values = vec![]; + for (key, value) in arr { + if let Some(k) = key { + let expanded_key = expansion::basic_expand_assignment_word(shell, params, k) + .await? + .into(); + let expanded_value = + expansion::basic_expand_assignment_word(shell, params, value) + .await? + .into(); + expanded_values.push((Some(expanded_key), expanded_value)); + } else { + // Array elements are treated as regular words, not assignments + let split_expanded_value = + expansion::full_expand_and_split_word(shell, params, value).await?; + for expanded_value in split_expanded_value { + expanded_values.push((None, expanded_value.into())); + } + } + } + + ast::AssignmentValue::Array(expanded_values) + } + }; + + Ok(expanded) +} + +#[expect(clippy::too_many_lines)] +async fn apply_assignment( + assignment: &ast::Assignment, + shell: &mut Shell, + params: &ExecutionParameters, + mut export: bool, + required_scope: Option, + creation_scope: EnvironmentScope, +) -> Result<(), error::Error> { + // Figure out if we are trying to assign to a variable or assign to an element of an existing + // array. + let mut array_index; + let variable_name = match &assignment.name { + ast::AssignmentName::VariableName(name) => { + array_index = None; + name + } + ast::AssignmentName::ArrayElementName(name, index) => { + let expanded = expansion::basic_expand_word(shell, params, index).await?; + array_index = Some(expanded); + name + } + }; + + // Expand the values. + let new_value = match &assignment.value { + ast::AssignmentValue::Scalar(unexpanded_value) => { + let value = + expansion::basic_expand_assignment_word(shell, params, unexpanded_value).await?; + ShellValueLiteral::Scalar(value) + } + ast::AssignmentValue::Array(unexpanded_values) => { + let mut elements = vec![]; + for (unexpanded_key, unexpanded_value) in unexpanded_values { + let key = match unexpanded_key { + Some(unexpanded_key) => Some( + expansion::basic_expand_assignment_word(shell, params, unexpanded_key) + .await?, + ), + None => None, + }; + + if key.is_some() { + let value = + expansion::basic_expand_assignment_word(shell, params, unexpanded_value) + .await?; + elements.push((key, value)); + } else { + // Array elements are treated as regular words, not assignments + let values = + expansion::full_expand_and_split_word(shell, params, unexpanded_value) + .await?; + for value in values { + elements.push((None, value)); + } + } + } + ShellValueLiteral::Array(ArrayLiteral(elements)) + } + }; + + if shell.options().print_commands_and_arguments { + let op = if assignment.append { "+=" } else { "=" }; + shell + .trace_command(params, std::format!("{}{op}{new_value}", assignment.name)) + .await; + } + + // See if we need to eval an array index. + if let Some(idx) = &array_index { + // An array subscript is arithmetically evaluated unless the target is an + // associative array (in which case the subscript is used as a literal key). + // A scalar or unset/untyped variable becomes an indexed array, so its + // subscript still needs to be evaluated. + let will_be_indexed_array = + if let Some((_, existing_value)) = shell.env().get(variable_name) { + !matches!( + existing_value.value(), + ShellValue::AssociativeArray(_) + | ShellValue::Unset(ShellValueUnsetType::AssociativeArray) + ) + } else { + true + }; + + if will_be_indexed_array { + array_index = Some( + arithmetic::expand_and_eval(shell, params, idx.as_str(), false) + .await? + .to_string(), + ); + } + } + + // Read option before taking mutable borrow on env. + let export_variables_on_modification = shell.options().export_variables_on_modification; + + // See if we can find an existing value associated with the variable. + if let Some((existing_value_scope, existing_value)) = + shell.env_mut().get_mut(variable_name.as_str()) + { + if required_scope.is_none() || Some(existing_value_scope) == required_scope { + if let Some(array_index) = array_index { + match new_value { + ShellValueLiteral::Scalar(s) => { + existing_value.assign_at_index(array_index, s, assignment.append)?; + } + ShellValueLiteral::Array(_) => { + return error::unimp("replacing an array item with an array"); + } + } + } else { + if !export + && export_variables_on_modification + && !matches!(new_value, ShellValueLiteral::Array(_)) + { + export = true; + } + + existing_value.assign(new_value, assignment.append)?; + } + + if export { + existing_value.export(); + } + + // That's it! + return Ok(()); + } + } + + // If we fell down here, then we need to add it. + let new_value = if let Some(array_index) = array_index { + match new_value { + ShellValueLiteral::Scalar(s) => { + ShellValue::indexed_array_from_literals(ArrayLiteral(vec![(Some(array_index), s)])) + } + ShellValueLiteral::Array(_) => { + return error::unimp("cannot assign list to array member"); + } + } + } else { + match new_value { + ShellValueLiteral::Scalar(s) => { + export = export || shell.options().export_variables_on_modification; + ShellValue::String(s) + } + ShellValueLiteral::Array(values) => ShellValue::indexed_array_from_literals(values), + } + }; + + let mut new_var = ShellVariable::new(new_value); + + if export { + new_var.export(); + } + + shell.env_mut().add(variable_name, new_var, creation_scope) +} + +#[expect(clippy::too_many_lines)] +pub(crate) async fn setup_redirect( + shell: &mut Shell, + params: &'_ mut ExecutionParameters, + redirect: &ast::IoRedirect, +) -> Result<(), error::Error> { + match redirect { + ast::IoRedirect::OutputAndError(f, append) => { + let mut expanded_fields = + expansion::full_expand_and_split_word(shell, params, f).await?; + if expanded_fields.len() != 1 { + return Err(error::ErrorKind::InvalidRedirection.into()); + } + + let expanded_file_path = expanded_fields.remove(0); + setup_redirect_output_and_error_to(shell, params, &expanded_file_path, *append)?; + } + + ast::IoRedirect::File(specified_fd_num, kind, target) => { + match target { + ast::IoFileRedirectTarget::Filename(f) => { + let mut options = std::fs::File::options(); + + let mut expanded_fields = + expansion::full_expand_and_split_word(shell, params, f).await?; + + if expanded_fields.len() != 1 { + return Err(error::ErrorKind::InvalidRedirection.into()); + } + + let expanded_file_path: PathBuf = + shell.absolute_path(Path::new(expanded_fields.remove(0).as_str())); + + let default_fd_if_unspecified = get_default_fd_for_redirect_kind(kind); + match kind { + ast::IoFileRedirectKind::Read => { + options.read(true); + } + ast::IoFileRedirectKind::Write => { + if shell + .options() + .disallow_overwriting_regular_files_via_output_redirection + { + // First check to see if the path points to an existing regular + // file. + if !expanded_file_path.is_file() { + options.create(true); + } else { + options.create_new(true); + } + options.write(true); + } else { + options.create(true); + options.write(true); + options.truncate(true); + } + } + ast::IoFileRedirectKind::Append => { + options.create(true); + options.append(true); + } + ast::IoFileRedirectKind::ReadAndWrite => { + options.create(true); + options.read(true); + options.write(true); + } + ast::IoFileRedirectKind::Clobber => { + options.create(true); + options.write(true); + options.truncate(true); + } + ast::IoFileRedirectKind::DuplicateInput => { + options.read(true); + } + ast::IoFileRedirectKind::DuplicateOutput => { + options.create(true); + options.write(true); + } + } + + let fd_num = specified_fd_num.unwrap_or(default_fd_if_unspecified); + + let opened_file = shell + .open_file(&options, &expanded_file_path, params) + .map_err(|err| { + error::ErrorKind::RedirectionFailure( + expanded_file_path.to_string_lossy().to_string(), + err.to_string(), + ) + })?; + + params.open_files.set_fd(fd_num, opened_file); + } + + ast::IoFileRedirectTarget::Fd(fd) => { + let default_fd_if_unspecified = match kind { + ast::IoFileRedirectKind::DuplicateInput => 0, + ast::IoFileRedirectKind::DuplicateOutput => 1, + _ => { + return error::unimp("unexpected redirect kind"); + } + }; + + let fd_num = specified_fd_num.unwrap_or(default_fd_if_unspecified); + + if let Some(target_file) = params.try_fd(shell, *fd) { + params.open_files.set_fd(fd_num, target_file); + } else { + return Err(error::ErrorKind::BadFileDescriptor(*fd).into()); + } + } + + ast::IoFileRedirectTarget::Duplicate(word) => { + let default_fd_if_unspecified = match kind { + ast::IoFileRedirectKind::DuplicateInput => 0, + ast::IoFileRedirectKind::DuplicateOutput => 1, + _ => { + return error::unimp("unexpected redirect kind"); + } + }; + + let fd_num = specified_fd_num.unwrap_or(default_fd_if_unspecified); + + let mut expanded_fields = + expansion::full_expand_and_split_word(shell, params, word).await?; + + if expanded_fields.len() != 1 { + return Err(error::ErrorKind::InvalidRedirection.into()); + } + + let mut expanded = expanded_fields.remove(0); + + let dash = if expanded.ends_with('-') { + expanded.pop(); + true + } else { + false + }; + + if expanded.is_empty() { + // Nothing to do + } else if expanded.chars().all(|c: char| c.is_ascii_digit()) { + let source_fd_num = expanded + .parse::() + .map_err(|_| error::ErrorKind::InvalidRedirection)?; + + // Reference the same open file as the source fd (shared handle; no OS-level duplication). + let Some(target_file) = params.try_fd(shell, source_fd_num) else { + return Err(error::ErrorKind::BadFileDescriptor(source_fd_num).into()); + }; + + params.open_files.set_fd(fd_num, target_file); + } else if fd_num == 1 && !dash { + // Special case for compatibility: redirect stdout and stderr to the file + // given by `expanded`. + setup_redirect_output_and_error_to( + shell, params, &expanded, false, /* append? */ + )?; + } else { + return Err(error::ErrorKind::InvalidRedirection.into()); + } + + if dash { + // Close the specified fd. Ignore it if it's not valid. + params.open_files.remove_fd(fd_num); + } + } + + ast::IoFileRedirectTarget::ProcessSubstitution(substitution_kind, subshell_cmd) => { + match kind { + ast::IoFileRedirectKind::Read + | ast::IoFileRedirectKind::Write + | ast::IoFileRedirectKind::Append + | ast::IoFileRedirectKind::ReadAndWrite + | ast::IoFileRedirectKind::Clobber => { + let (substitution_fd, substitution_file) = setup_process_substitution( + shell, + params, + substitution_kind, + subshell_cmd, + )?; + + let target_file = substitution_file.clone(); + params.open_files.set_fd(substitution_fd, substitution_file); + + let fd_num = specified_fd_num + .unwrap_or_else(|| get_default_fd_for_redirect_kind(kind)); + + params.open_files.set_fd(fd_num, target_file); + } + _ => return error::unimp("invalid process substitution"), + } + } + } + } + + ast::IoRedirect::HereDocument(fd_num, io_here) => { + // If not specified, default to stdin (fd 0). + let fd_num = fd_num.unwrap_or(0); + + // Expand if required. + let io_here_doc = if io_here.requires_expansion { + expansion::basic_expand_heredoc_word(shell, params, &io_here.doc).await? + } else { + io_here.doc.flatten() + }; + + let f = setup_open_file_with_contents(io_here_doc.as_str())?; + + params.open_files.set_fd(fd_num, f); + } + + ast::IoRedirect::HereString(fd_num, word) => { + // If not specified, default to stdin (fd 0). + let fd_num = fd_num.unwrap_or(0); + + let mut expanded_word = expansion::basic_expand_word(shell, params, word).await?; + expanded_word.push('\n'); + + let f = setup_open_file_with_contents(expanded_word.as_str())?; + + params.open_files.set_fd(fd_num, f); + } + } + + Ok(()) +} + +/// Sets up redirection of both stdout and stderr to the same file, given by `file_path`. +/// +/// # Arguments +/// +/// * `shell` - The shell instance. +/// * `params` - The execution parameters to modify. +/// * `file_path` - The path to the file to redirect output and error to. +/// * `append` - Whether to append. If `false`, the file will be truncated. +fn setup_redirect_output_and_error_to( + shell: &Shell, + params: &mut ExecutionParameters, + file_path: &str, + append: bool, +) -> Result<(), error::Error> { + let abs_file_path: PathBuf = shell.absolute_path(Path::new(file_path)); + + let mut file_options = std::fs::File::options(); + file_options + .create(true) + .write(true) + .truncate(!append) + .append(append); + + let stdout_file = shell + .open_file(&file_options, &abs_file_path, params) + .map_err(|err| { + error::ErrorKind::RedirectionFailure( + abs_file_path.to_string_lossy().to_string(), + err.to_string(), + ) + })?; + + let stderr_file = stdout_file.clone(); + + params.open_files.set_fd(OpenFiles::STDOUT_FD, stdout_file); + params.open_files.set_fd(OpenFiles::STDERR_FD, stderr_file); + + Ok(()) +} + +const fn get_default_fd_for_redirect_kind(kind: &ast::IoFileRedirectKind) -> ShellFd { + match kind { + ast::IoFileRedirectKind::Read => 0, + ast::IoFileRedirectKind::Write => 1, + ast::IoFileRedirectKind::Append => 1, + ast::IoFileRedirectKind::ReadAndWrite => 0, + ast::IoFileRedirectKind::Clobber => 1, + ast::IoFileRedirectKind::DuplicateInput => 0, + ast::IoFileRedirectKind::DuplicateOutput => 1, + } +} + +fn setup_process_substitution( + shell: &Shell, + params: &ExecutionParameters, + kind: &ast::ProcessSubstitutionKind, + subshell_cmd: &ast::SubshellCommand, +) -> Result<(ShellFd, OpenFile), error::Error> { + // TODO(execute): Don't execute synchronously! + // Execute in a subshell. + let mut subshell = shell.clone(); + + // Set up execution parameters for the child execution. + let mut child_params = params.clone(); + child_params.process_group_policy = ProcessGroupPolicy::SameProcessGroup; + + // Set up pipe so we can connect to the command. + let (reader, writer) = std::io::pipe()?; + let (reader, writer) = (reader.into(), writer.into()); + + let target_file = match kind { + ast::ProcessSubstitutionKind::Read => { + child_params.open_files.set_fd(OpenFiles::STDOUT_FD, writer); + reader + } + ast::ProcessSubstitutionKind::Write => { + child_params.open_files.set_fd(OpenFiles::STDIN_FD, reader); + writer + } + }; + + // Asynchronously spawn off the subshell; we intentionally don't block on its + // completion. + let subshell_cmd = subshell_cmd.to_owned(); + tokio::spawn(async move { + // Intentionally ignore the result of the subshell command. + let _ = subshell_cmd + .list + .execute(&mut subshell, &child_params) + .await; + }); + + // Starting at 63 (a.k.a. 64-1)--and decrementing--look for an + // available fd. + let mut candidate_fd_num = 63; + while params.open_files.contains_fd(candidate_fd_num) { + candidate_fd_num -= 1; + if candidate_fd_num == 0 { + return error::unimp("no available file descriptors"); + } + } + + Ok((candidate_fd_num, target_file)) +} + +fn setup_open_file_with_contents(contents: &str) -> Result { + let (reader, mut writer) = std::io::pipe()?; + + let bytes = contents.as_bytes(); + + #[cfg(any(target_os = "linux", target_os = "android"))] + { + use std::os::fd::AsFd as _; + + let len = i32::try_from(bytes.len()) + .map_err(|_err| error::Error::from(error::ErrorKind::TooMuchData))?; + nix::fcntl::fcntl(reader.as_fd(), nix::fcntl::FcntlArg::F_SETPIPE_SZ(len))?; + } + + writer.write_all(bytes)?; + drop(writer); + + Ok(reader.into()) +} diff --git a/local/recipes/shells/brush/source/brush-core/src/ioutils.rs b/local/recipes/shells/brush/source/brush-core/src/ioutils.rs new file mode 100644 index 0000000000..8cc6e6ff49 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/ioutils.rs @@ -0,0 +1,80 @@ +//! Internal I/O utilities. + +use crate::openfiles; + +/// Error type for `FailingReaderWriter`. +#[derive(Clone, Debug, thiserror::Error)] +enum ReaderWriterError { + /// I/O read error. + #[error("I/O read error: {0}")] + Read(&'static str), + /// I/O write error. + #[error("I/O write error: {0}")] + Write(&'static str), + /// I/O flush error. + #[error("I/O flush error: {0}")] + Flush(&'static str), +} + +/// An implementation of `std::io::Read` and `std::io::Write` that always fails. +/// +/// Also implements `openfiles::Stream` for use with `openfiles::OpenFile`. Useful +/// for providing a valid stream that gracefully fails all operations in cases where +/// no real I/O is possible. +#[derive(Clone)] +pub(crate) struct FailingReaderWriter { + message: &'static str, +} + +impl FailingReaderWriter { + /// Creates a new `FailingReaderWriter` with the given error message. + /// + /// # Arguments + /// + /// * `message` - The error message to use for all operations. + pub const fn new(message: &'static str) -> Self { + Self { message } + } +} + +impl openfiles::Stream for FailingReaderWriter { + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } + + #[cfg(unix)] + fn try_clone_to_owned(&self) -> Result { + Err(crate::error::ErrorKind::CannotConvertToNativeFd.into()) + } + + #[cfg(unix)] + fn try_borrow_as_fd(&self) -> Result, crate::error::Error> { + Err(crate::error::ErrorKind::CannotConvertToNativeFd.into()) + } +} + +impl std::io::Read for FailingReaderWriter { + fn read(&mut self, _buf: &mut [u8]) -> std::io::Result { + Err(std::io::Error::other(ReaderWriterError::Read(self.message))) + } +} + +impl std::io::Write for FailingReaderWriter { + fn write(&mut self, _buf: &[u8]) -> std::io::Result { + Err(std::io::Error::other(ReaderWriterError::Write( + self.message, + ))) + } + + fn flush(&mut self) -> std::io::Result<()> { + Err(std::io::Error::other(ReaderWriterError::Flush( + self.message, + ))) + } +} + +impl From for openfiles::OpenFile { + fn from(frw: FailingReaderWriter) -> Self { + Self::Stream(Box::new(frw)) + } +} diff --git a/local/recipes/shells/brush/source/brush-core/src/jobs.rs b/local/recipes/shells/brush/source/brush-core/src/jobs.rs new file mode 100644 index 0000000000..a549c92cc6 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/jobs.rs @@ -0,0 +1,458 @@ +//! Job management + +use std::borrow::Cow; +use std::collections::VecDeque; +use std::fmt::Display; + +use futures::FutureExt; + +use crate::ExecutionResult; +use crate::error; +use crate::processes; +use crate::sys; +use crate::trace_categories; +use crate::traps; + +pub(crate) type JobJoinHandle = tokio::task::JoinHandle>; +pub(crate) type JobResult = (Job, Result); + +/// Manages the jobs that are currently managed by the shell. +#[derive(Default)] +pub struct JobManager { + /// The jobs that are currently managed by the shell. + pub jobs: Vec, +} + +/// Represents a task that is part of a job. +pub enum JobTask { + /// An external process. + External(processes::ChildProcess), + /// An internal asynchronous task. + Internal(JobJoinHandle), +} + +/// Represents the result of waiting on a job task. +pub enum JobTaskWaitResult { + /// The task has completed. + Completed(ExecutionResult), + /// The task was stopped. + Stopped, +} + +impl JobTask { + /// Returns whether the task is an external process. + pub const fn is_external(&self) -> bool { + matches!(self, Self::External(_)) + } + + /// Waits for the task to complete. Returns the result of the wait. + pub async fn wait(&mut self) -> Result { + match self { + Self::External(process) => { + let wait_result = process.wait().await?; + match wait_result { + processes::ProcessWaitResult::Completed(output) => { + Ok(JobTaskWaitResult::Completed(output.into())) + } + processes::ProcessWaitResult::Stopped => Ok(JobTaskWaitResult::Stopped), + } + } + Self::Internal(handle) => Ok(JobTaskWaitResult::Completed(handle.await??)), + } + } + + /// Polls the task for completion. Returns `Some(result)` if the task has completed, + /// or `None` if it is still running. The result is the execution result of the task. + /// Behaves in a best-effort manner; if an internal error occurs during polling, + /// it will return `None`. + fn poll(&mut self) -> Option> { + match self { + Self::External(process) => { + let check_result = process.poll(); + check_result.map(|polled_result| polled_result.map(|output| output.into())) + } + Self::Internal(handle) => { + let checkable_handle = handle; + checkable_handle.now_or_never().and_then(|r| r.ok()) + } + } + } +} + +impl JobManager { + /// Returns a new job manager. + pub fn new() -> Self { + Self::default() + } + + /// Adds a job to the job manager and marks it as the current job; + /// returns an immutable reference to the job. + /// + /// # Arguments + /// + /// * `job` - The job to add. + #[allow( + clippy::missing_panics_doc, + reason = "push() guarantees the vector length is >= 1" + )] + pub fn add_as_current(&mut self, mut job: Job) -> &Job { + for j in &mut self.jobs { + if matches!(j.annotation, JobAnnotation::Current) { + j.annotation = JobAnnotation::Previous; + break; + } + } + + let id = self.jobs.len() + 1; + job.id = id; + job.annotation = JobAnnotation::Current; + self.jobs.push(job); + + #[allow(clippy::unwrap_used, reason = "we just pushed an element")] + self.jobs.last().unwrap() + } + + /// Returns the current job, if there is one. + pub fn current_job(&self) -> Option<&Job> { + self.jobs + .iter() + .find(|j| matches!(j.annotation, JobAnnotation::Current)) + } + + /// Returns a mutable reference to the current job, if there is one. + pub fn current_job_mut(&mut self) -> Option<&mut Job> { + self.jobs + .iter_mut() + .find(|j| matches!(j.annotation, JobAnnotation::Current)) + } + + /// Returns the previous job, if there is one. + pub fn prev_job(&self) -> Option<&Job> { + self.jobs + .iter() + .find(|j| matches!(j.annotation, JobAnnotation::Previous)) + } + + /// Returns a mutable reference to the previous job, if there is one. + pub fn prev_job_mut(&mut self) -> Option<&mut Job> { + self.jobs + .iter_mut() + .find(|j| matches!(j.annotation, JobAnnotation::Previous)) + } + + /// Tries to resolve the given job specification to a job. + /// + /// # Arguments + /// + /// * `job_spec` - The job specification to resolve. + pub fn resolve_job_spec(&mut self, job_spec: &str) -> Option<&mut Job> { + let remainder = job_spec.strip_prefix('%')?; + + match remainder { + "%" | "+" => self.current_job_mut(), + "-" => self.prev_job_mut(), + s if s.chars().all(char::is_numeric) => { + let id = s.parse::().ok()?; + self.jobs.iter_mut().find(|j| j.id == id) + } + _ => { + tracing::warn!(target: trace_categories::UNIMPLEMENTED, "unimplemented: job spec naming command: '{job_spec}'"); + None + } + } + } + + /// Waits for all managed jobs to complete. + pub async fn wait_all(&mut self) -> Result, error::Error> { + for job in &mut self.jobs { + job.wait().await?; + } + + Ok(self.sweep_completed_jobs()) + } + + /// Polls all managed jobs for completion. + pub fn poll(&mut self) -> Result, error::Error> { + let mut results = Vec::with_capacity(self.jobs.len()); + + let mut i = 0; + while i != self.jobs.len() { + if let Some(result) = self.jobs[i].poll_done()? { + let job = self.jobs.remove(i); + results.push((job, result)); + } else if matches!(self.jobs[i].state, JobState::Done) { + // TODO(jobs): This is a workaround to remove jobs that are done but for which we + // don't know what happened. + results.push((self.jobs.remove(i), Ok(ExecutionResult::success()))); + } else { + i += 1; + } + } + + Ok(results) + } + + fn sweep_completed_jobs(&mut self) -> Vec { + let mut completed_jobs = vec![]; + + let mut i = 0; + while i != self.jobs.len() { + if self.jobs[i].tasks.is_empty() { + completed_jobs.push(self.jobs.remove(i)); + } else { + i += 1; + } + } + + completed_jobs + } +} + +/// Represents the current execution state of a job. +#[derive(Clone)] +pub enum JobState { + /// Unknown state. + Unknown, + /// The job is running. + Running, + /// The job is stopped. + Stopped, + /// The job has completed. + Done, +} + +impl Display for JobState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Unknown => write!(f, "Unknown"), + Self::Running => write!(f, "Running"), + Self::Stopped => write!(f, "Stopped"), + Self::Done => write!(f, "Done"), + } + } +} + +/// Represents an annotation for a job. +#[derive(Clone)] +pub enum JobAnnotation { + /// No annotation. + None, + /// The job is the current job. + Current, + /// The job is the previous job. + Previous, +} + +impl Display for JobAnnotation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::None => write!(f, ""), + Self::Current => write!(f, "+"), + Self::Previous => write!(f, "-"), + } + } +} + +/// Encapsulates a set of processes managed by the shell as a single unit. +pub struct Job { + /// The tasks that make up the job. + tasks: VecDeque, + + /// If available, the process group ID of the job's processes. + pgid: Option, + + /// The annotation of the job (e.g., current, previous). + annotation: JobAnnotation, + + /// The shell-internal ID of the job. + pub id: usize, + + /// The command line of the job. + pub command_line: String, + + /// The current operational state of the job. + pub state: JobState, +} + +impl Display for Job { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "[{}]{:3}{}\t{}", + self.id, self.annotation, self.state, self.command_line + ) + } +} + +impl Job { + /// Returns a new job object. + /// + /// # Arguments + /// + /// * `children` - The job's known child processes. + /// * `command_line` - The command line of the job. + /// * `state` - The current operational state of the job. + pub(crate) fn new(tasks: I, command_line: String, state: JobState) -> Self + where + I: IntoIterator, + { + Self { + id: 0, + tasks: tasks.into_iter().collect(), + pgid: None, + annotation: JobAnnotation::None, + command_line, + state, + } + } + + /// Returns a pid-style string for the job. + pub fn to_pid_style_string(&self) -> String { + let display_pid = self + .representative_pid() + .map_or(Cow::Borrowed(""), |pid| { + Cow::Owned(pid.to_string()) + }); + std::format!("[{}]{}\t{}", self.id, self.annotation, display_pid) + } + + /// Returns the annotation of the job. + pub fn annotation(&self) -> JobAnnotation { + self.annotation.clone() + } + + /// Returns the command name of the job. + pub fn command_name(&self) -> &str { + self.command_line + .split_ascii_whitespace() + .next() + .unwrap_or_default() + } + + /// Returns whether the job is the current job. + pub const fn is_current(&self) -> bool { + matches!(self.annotation, JobAnnotation::Current) + } + + /// Returns whether the job is the previous job. + pub const fn is_prev(&self) -> bool { + matches!(self.annotation, JobAnnotation::Previous) + } + + /// Polls whether the job has completed. + pub fn poll_done( + &mut self, + ) -> Result>, error::Error> { + let mut result: Option> = None; + + tracing::debug!(target: trace_categories::JOBS, "Polling job {} for completion...", self.id); + + while !self.tasks.is_empty() { + let task = &mut self.tasks[0]; + match task.poll() { + Some(r) => { + self.tasks.remove(0); + result = Some(r); + } + None => { + return Ok(None); + } + } + } + + tracing::debug!(target: trace_categories::JOBS, "Job {} has completed.", self.id); + + self.state = JobState::Done; + + Ok(result) + } + + /// Waits for the job to complete. + pub async fn wait(&mut self) -> Result { + let mut result = ExecutionResult::success(); + + while let Some(task) = self.tasks.back_mut() { + match task.wait().await? { + JobTaskWaitResult::Completed(execution_result) => { + result = execution_result; + self.tasks.pop_back(); + } + JobTaskWaitResult::Stopped => { + self.state = JobState::Stopped; + return Ok(ExecutionResult::stopped()); + } + } + } + + self.state = JobState::Done; + + Ok(result) + } + + /// Moves the job to execute in the background. + pub fn move_to_background(&mut self) -> Result<(), error::Error> { + if matches!(self.state, JobState::Stopped) { + if let Some(pgid) = self.process_group_id() { + sys::signal::continue_process(pgid)?; + self.state = JobState::Running; + Ok(()) + } else { + Err(error::ErrorKind::FailedToSendSignal.into()) + } + } else { + error::unimp("move job to background") + } + } + + /// Moves the job to execute in the foreground. + pub fn move_to_foreground(&mut self) -> Result<(), error::Error> { + if matches!(self.state, JobState::Stopped) { + if let Some(pgid) = self.process_group_id() { + sys::signal::continue_process(pgid)?; + self.state = JobState::Running; + } else { + return Err(error::ErrorKind::FailedToSendSignal.into()); + } + } + + if let Some(pgid) = self.process_group_id() { + sys::terminal::move_to_foreground(pgid)?; + } + + Ok(()) + } + + /// Kills the job. + /// + /// # Arguments + /// + /// * `signal` - The signal to send to the job. + pub fn kill(&self, signal: traps::TrapSignal) -> Result<(), error::Error> { + if let Some(pid) = self.process_group_id() { + sys::signal::kill_process(pid, signal) + } else { + Err(error::ErrorKind::FailedToSendSignal.into()) + } + } + + /// Tries to retrieve a "representative" pid for the job. + pub fn representative_pid(&self) -> Option { + for task in &self.tasks { + match task { + JobTask::External(p) => { + if let Some(pid) = p.pid() { + return Some(pid); + } + } + JobTask::Internal(_) => (), + } + } + None + } + + /// Tries to retrieve the process group ID (PGID) of the job. + pub fn process_group_id(&self) -> Option { + // TODO(jobs): Don't assume that the first PID is the PGID. + self.pgid.or_else(|| self.representative_pid()) + } +} diff --git a/local/recipes/shells/brush/source/brush-core/src/keywords.rs b/local/recipes/shells/brush/source/brush-core/src/keywords.rs new file mode 100644 index 0000000000..620b33718d --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/keywords.rs @@ -0,0 +1,37 @@ +use std::collections::HashSet; +use std::sync::LazyLock; + +fn get_keywords(sh_mode_only: bool) -> HashSet<&'static str> { + let mut keywords = HashSet::new(); + keywords.insert("!"); + keywords.insert("{"); + keywords.insert("}"); + keywords.insert("case"); + keywords.insert("do"); + keywords.insert("done"); + keywords.insert("elif"); + keywords.insert("else"); + keywords.insert("esac"); + keywords.insert("fi"); + keywords.insert("for"); + keywords.insert("if"); + keywords.insert("in"); + keywords.insert("then"); + keywords.insert("until"); + keywords.insert("while"); + + if !sh_mode_only { + keywords.insert("[["); + keywords.insert("]]"); + keywords.insert("coproc"); + keywords.insert("function"); + keywords.insert("select"); + keywords.insert("time"); + } + + keywords +} + +pub(crate) static SH_MODE_KEYWORDS: LazyLock> = + LazyLock::new(|| get_keywords(true)); +pub(crate) static KEYWORDS: LazyLock> = LazyLock::new(|| get_keywords(false)); diff --git a/local/recipes/shells/brush/source/brush-core/src/lib.rs b/local/recipes/shells/brush/source/brush-core/src/lib.rs new file mode 100644 index 0000000000..ee0cdfaa53 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/lib.rs @@ -0,0 +1,64 @@ +//! Core implementation of the brush shell. Implements the shell's abstraction, its interpreter, and +//! various facilities used internally by the shell. + +pub mod arithmetic; +mod braceexpansion; +pub mod builtins; +pub mod callstack; +pub mod commands; +pub mod completion; +pub mod env; +pub mod error; +pub mod escape; +pub mod expansion; +mod extendedtests; +pub mod extensions; +pub mod functions; +pub mod history; +pub mod int_utils; +pub mod interfaces; +mod interp; +mod ioutils; +pub mod jobs; +mod keywords; +pub mod namedoptions; +pub mod openfiles; +pub mod options; +pub mod pathcache; +pub mod pathsearch; +pub mod patterns; +pub mod processes; +mod prompt; +mod regex; +pub mod results; +mod shell; +pub mod sourceinfo; +pub mod sys; +pub mod terminal; +pub mod tests; +pub mod timing; +pub mod trace_categories; +pub mod traps; +pub mod variables; +mod wellknownvars; + +/// Re-export parser types used in core definitions. +pub mod parser { + pub use brush_parser::{ + BindingParseError, ParseError, ParserImpl, SourcePosition, SourcePositionOffset, + SourceSpan, TestCommandParseError, WordParseError, ast, + }; +} + +pub use commands::{CommandArg, ExecutionContext}; +pub use error::{BuiltinError, Error, ErrorKind}; +pub use extensions::ShellExtensions; +pub use interp::{ExecutionParameters, ProcessGroupPolicy}; +pub use parser::{SourcePosition, SourcePositionOffset, SourceSpan}; +pub use results::{ExecutionControlFlow, ExecutionExitCode, ExecutionResult, ExecutionSpawnResult}; +pub use shell::{ + CreateOptions, ProfileLoadBehavior, RcLoadBehavior, Shell, ShellBuilder, ShellBuilderState, + ShellFd, ShellState, +}; +pub use sourceinfo::SourceInfo; +pub use variables::{ShellValue, ShellVariable}; diff --git a/local/recipes/shells/brush/source/brush-core/src/namedoptions.rs b/local/recipes/shells/brush/source/brush-core/src/namedoptions.rs new file mode 100644 index 0000000000..16280d3955 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/namedoptions.rs @@ -0,0 +1,878 @@ +//! Defines shell options. + +use std::collections::HashMap; +use std::sync::LazyLock; + +use crate::options::RuntimeOptions; + +type OptionGetter = fn(shell: &RuntimeOptions) -> bool; +type OptionSetter = fn(shell: &mut RuntimeOptions, value: bool) -> (); + +/// Defines an option. +pub struct ShellOptionDef { + /// Getter function that retrieves the current value of the option. + getter: OptionGetter, + /// Setter function that may be used to set the current value of the option. + setter: OptionSetter, +} + +impl ShellOptionDef { + /// Constructs a new option definition. + /// + /// # Arguments + /// + /// * `getter` - A function that retrieves the current value of the option. + /// * `setter` - A function that sets the current value of the option. + fn new(getter: OptionGetter, setter: OptionSetter) -> Self { + Self { getter, setter } + } + + /// Retrieves the current value of this option from the given runtime options. + /// + /// # Arguments + /// + /// * `options` - The runtime options to retrieve the value from. + pub fn get(&self, options: &RuntimeOptions) -> bool { + (self.getter)(options) + } + + /// Sets the value of this option in the given runtime options. + /// + /// # Arguments + /// + /// * `options` - The runtime options to modify. + /// * `value` - The new value to set for the option. + pub fn set(&self, options: &mut RuntimeOptions, value: bool) { + (self.setter)(options, value); + } +} + +/// Describes a shell option. +pub struct ShellOption { + /// The name of the option. + pub name: &'static str, + /// The definition of the option. + pub definition: &'static ShellOptionDef, +} + +/// Describes a set of shell options. +pub struct ShellOptionSet { + inner: &'static HashMap<&'static str, ShellOptionDef>, +} + +/// Kind of shell option. +#[derive(Clone, Copy)] +pub enum ShellOptionKind { + /// `set` option. + Set, + /// `set -o` option. + SetO, + /// `shopt` option. + Shopt, +} + +/// Returns the options for the given shell option kind. +/// +/// # Arguments +/// +/// * `kind` - The kind of shell options to retrieve. +pub fn options(kind: ShellOptionKind) -> ShellOptionSet { + match kind { + ShellOptionKind::Set => ShellOptionSet { + inner: &SET_OPTIONS, + }, + ShellOptionKind::SetO => ShellOptionSet { + inner: &SET_O_OPTIONS, + }, + ShellOptionKind::Shopt => ShellOptionSet { + inner: &SHOPT_OPTIONS, + }, + } +} + +impl ShellOptionSet { + /// Returns an iterator over the options defined in this set. + pub fn iter(&self) -> impl Iterator { + self.inner + .iter() + .map(|(&name, definition)| ShellOption { name, definition }) + } + + /// Returns the option with the given name, if it exists. + /// + /// # Arguments + /// + /// * `name` - The name of the option to retrieve. + pub fn get(&self, name: &str) -> Option<&'static ShellOptionDef> { + self.inner.get(name) + } +} + +static SET_OPTIONS: LazyLock> = LazyLock::new(|| { + HashMap::from([ + ( + "a", + ShellOptionDef::new( + |options| options.export_variables_on_modification, + |options, value| options.export_variables_on_modification = value, + ), + ), + ( + "b", + ShellOptionDef::new( + |options| options.notify_job_termination_immediately, + |options, value| options.notify_job_termination_immediately = value, + ), + ), + ( + "c", + ShellOptionDef::new( + |options| options.command_string_mode, + |options, value| options.command_string_mode = value, + ), + ), + ( + "e", + ShellOptionDef::new( + |options| options.exit_on_nonzero_command_exit, + |options, value| options.exit_on_nonzero_command_exit = value, + ), + ), + ( + "f", + ShellOptionDef::new( + |options| options.disable_filename_globbing, + |options, value| options.disable_filename_globbing = value, + ), + ), + ( + "h", + ShellOptionDef::new( + |options| options.remember_command_locations, + |options, value| options.remember_command_locations = value, + ), + ), + ( + "i", + ShellOptionDef::new( + |options| options.interactive, + |options, value| options.interactive = value, + ), + ), + ( + "k", + ShellOptionDef::new( + |options| options.place_all_assignment_args_in_command_env, + |options, value| options.place_all_assignment_args_in_command_env = value, + ), + ), + ( + "m", + ShellOptionDef::new( + |options| options.enable_job_control, + |options, value| options.enable_job_control = value, + ), + ), + ( + "n", + ShellOptionDef::new( + |options| options.do_not_execute_commands, + |options, value| options.do_not_execute_commands = value, + ), + ), + ( + "p", + ShellOptionDef::new( + |options| options.real_effective_uid_mismatch, + |options, value| options.real_effective_uid_mismatch = value, + ), + ), + ( + "t", + ShellOptionDef::new( + |options| options.exit_after_one_command, + |options, value| options.exit_after_one_command = value, + ), + ), + ( + "u", + ShellOptionDef::new( + |options| options.treat_unset_variables_as_error, + |options, value| options.treat_unset_variables_as_error = value, + ), + ), + ( + "v", + ShellOptionDef::new( + |options| options.print_shell_input_lines, + |options, value| options.print_shell_input_lines = value, + ), + ), + ( + "x", + ShellOptionDef::new( + |options| options.print_commands_and_arguments, + |options, value| options.print_commands_and_arguments = value, + ), + ), + ( + "B", + ShellOptionDef::new( + |options| options.perform_brace_expansion, + |options, value| options.perform_brace_expansion = value, + ), + ), + ( + "C", + ShellOptionDef::new( + |options| options.disallow_overwriting_regular_files_via_output_redirection, + |options, value| { + options.disallow_overwriting_regular_files_via_output_redirection = value; + }, + ), + ), + ( + "E", + ShellOptionDef::new( + |options| options.shell_functions_inherit_err_trap, + |options, value| options.shell_functions_inherit_err_trap = value, + ), + ), + ( + "H", + ShellOptionDef::new( + |options| options.enable_bang_style_history_substitution, + |options, value| options.enable_bang_style_history_substitution = value, + ), + ), + ( + "P", + ShellOptionDef::new( + |options| options.do_not_resolve_symlinks_when_changing_dir, + |options, value| options.do_not_resolve_symlinks_when_changing_dir = value, + ), + ), + ( + "T", + ShellOptionDef::new( + |options| options.shell_functions_inherit_debug_and_return_traps, + |options, value| options.shell_functions_inherit_debug_and_return_traps = value, + ), + ), + ( + "s", + ShellOptionDef::new( + |options| options.read_commands_from_stdin, + |options, value| options.read_commands_from_stdin = value, + ), + ), + ]) +}); + +static SET_O_OPTIONS: LazyLock> = LazyLock::new(|| { + HashMap::from([ + ( + "allexport", + ShellOptionDef::new( + |options| options.export_variables_on_modification, + |options, value| options.export_variables_on_modification = value, + ), + ), + ( + "braceexpand", + ShellOptionDef::new( + |options| options.perform_brace_expansion, + |options, value| options.perform_brace_expansion = value, + ), + ), + ( + "emacs", + ShellOptionDef::new( + |options| options.emacs_mode, + |options, value| options.emacs_mode = value, + ), + ), + ( + "errexit", + ShellOptionDef::new( + |options| options.exit_on_nonzero_command_exit, + |options, value| options.exit_on_nonzero_command_exit = value, + ), + ), + ( + "errtrace", + ShellOptionDef::new( + |options| options.shell_functions_inherit_err_trap, + |options, value| options.shell_functions_inherit_err_trap = value, + ), + ), + ( + "functrace", + ShellOptionDef::new( + |options| options.shell_functions_inherit_debug_and_return_traps, + |options, value| options.shell_functions_inherit_debug_and_return_traps = value, + ), + ), + ( + "hashall", + ShellOptionDef::new( + |options| options.remember_command_locations, + |options, value| options.remember_command_locations = value, + ), + ), + ( + "histexpand", + ShellOptionDef::new( + |options| options.enable_bang_style_history_substitution, + |options, value| options.enable_bang_style_history_substitution = value, + ), + ), + ( + "history", + ShellOptionDef::new( + |options| options.enable_command_history, + |options, value| options.enable_command_history = value, + ), + ), + ( + "ignoreeof", + ShellOptionDef::new( + |options| options.ignore_eof, + |options, value| options.ignore_eof = value, + ), + ), + ( + "interactive-comments", + ShellOptionDef::new( + |options| options.interactive_comments, + |options, value| options.interactive_comments = value, + ), + ), + ( + "keyword", + ShellOptionDef::new( + |options| options.place_all_assignment_args_in_command_env, + |options, value| options.place_all_assignment_args_in_command_env = value, + ), + ), + ( + "monitor", + ShellOptionDef::new( + |options| options.enable_job_control, + |options, value| options.enable_job_control = value, + ), + ), + ( + "noclobber", + ShellOptionDef::new( + |options| options.disallow_overwriting_regular_files_via_output_redirection, + |options, value| { + options.disallow_overwriting_regular_files_via_output_redirection = value; + }, + ), + ), + ( + "noexec", + ShellOptionDef::new( + |options| options.do_not_execute_commands, + |options, value| options.do_not_execute_commands = value, + ), + ), + ( + "noglob", + ShellOptionDef::new( + |options| options.disable_filename_globbing, + |options, value| options.disable_filename_globbing = value, + ), + ), + ("nolog", ShellOptionDef::new(|_| false, |_, _| ())), + ( + "notify", + ShellOptionDef::new( + |options| options.notify_job_termination_immediately, + |options, value| options.notify_job_termination_immediately = value, + ), + ), + ( + "nounset", + ShellOptionDef::new( + |options| options.treat_unset_variables_as_error, + |options, value| options.treat_unset_variables_as_error = value, + ), + ), + ( + "onecmd", + ShellOptionDef::new( + |options| options.exit_after_one_command, + |options, value| options.exit_after_one_command = value, + ), + ), + ( + "physical", + ShellOptionDef::new( + |options| options.do_not_resolve_symlinks_when_changing_dir, + |options, value| options.do_not_resolve_symlinks_when_changing_dir = value, + ), + ), + ( + "pipefail", + ShellOptionDef::new( + |options| options.return_last_failure_from_pipeline, + |options, value| options.return_last_failure_from_pipeline = value, + ), + ), + ( + "posix", + ShellOptionDef::new( + |options| options.posix_mode, + |options, value| options.posix_mode = value, + ), + ), + ( + "privileged", + ShellOptionDef::new( + |options| options.real_effective_uid_mismatch, + |options, value| options.real_effective_uid_mismatch = value, + ), + ), + ( + "verbose", + ShellOptionDef::new( + |options| options.print_shell_input_lines, + |options, value| options.print_shell_input_lines = value, + ), + ), + ( + "vi", + ShellOptionDef::new( + |options| options.vi_mode, + |options, value| options.vi_mode = value, + ), + ), + ( + "xtrace", + ShellOptionDef::new( + |options| options.print_commands_and_arguments, + |options, value| options.print_commands_and_arguments = value, + ), + ), + ]) +}); + +static SHOPT_OPTIONS: LazyLock> = LazyLock::new(|| { + HashMap::from([ + ( + "autocd", + ShellOptionDef::new( + |options| options.auto_cd, + |options, value| options.auto_cd = value, + ), + ), + ( + "array_expand_once", + ShellOptionDef::new( + |options| options.array_expand_once, + |options, value| options.array_expand_once = value, + ), + ), + ( + "assoc_expand_once", + ShellOptionDef::new( + |options| options.assoc_expand_once, + |options, value| options.assoc_expand_once = value, + ), + ), + ( + "bash_source_fullpath", + ShellOptionDef::new( + |options| options.bash_source_full_path, + |options, value| options.bash_source_full_path = value, + ), + ), + ( + "cdable_vars", + ShellOptionDef::new( + |options| options.cdable_vars, + |options, value| options.cdable_vars = value, + ), + ), + ( + "cdspell", + ShellOptionDef::new( + |options| options.cd_autocorrect_spelling, + |options, value| options.cd_autocorrect_spelling = value, + ), + ), + ( + "checkhash", + ShellOptionDef::new( + |options| options.check_hashtable_before_command_exec, + |options, value| options.check_hashtable_before_command_exec = value, + ), + ), + ( + "checkjobs", + ShellOptionDef::new( + |options| options.check_jobs_before_exit, + |options, value| options.check_jobs_before_exit = value, + ), + ), + ( + "checkwinsize", + ShellOptionDef::new( + |options| options.check_window_size_after_external_commands, + |options, value| options.check_window_size_after_external_commands = value, + ), + ), + ( + "cmdhist", + ShellOptionDef::new( + |options| options.save_multiline_cmds_in_history, + |options, value| options.save_multiline_cmds_in_history = value, + ), + ), + ( + "compat31", + ShellOptionDef::new( + |options| options.compat31, + |options, value| options.compat31 = value, + ), + ), + ( + "compat32", + ShellOptionDef::new( + |options| options.compat32, + |options, value| options.compat32 = value, + ), + ), + ( + "compat40", + ShellOptionDef::new( + |options| options.compat40, + |options, value| options.compat40 = value, + ), + ), + ( + "compat41", + ShellOptionDef::new( + |options| options.compat41, + |options, value| options.compat41 = value, + ), + ), + ( + "compat42", + ShellOptionDef::new( + |options| options.compat42, + |options, value| options.compat42 = value, + ), + ), + ( + "compat43", + ShellOptionDef::new( + |options| options.compat43, + |options, value| options.compat43 = value, + ), + ), + ( + "compat44", + ShellOptionDef::new( + |options| options.compat44, + |options, value| options.compat44 = value, + ), + ), + ( + "complete_fullquote", + ShellOptionDef::new( + |options| options.quote_all_metachars_in_completion, + |options, value| options.quote_all_metachars_in_completion = value, + ), + ), + ( + "direxpand", + ShellOptionDef::new( + |options| options.expand_dir_names_on_completion, + |options, value| options.expand_dir_names_on_completion = value, + ), + ), + ( + "dirspell", + ShellOptionDef::new( + |options| options.autocorrect_dir_spelling_on_completion, + |options, value| options.autocorrect_dir_spelling_on_completion = value, + ), + ), + ( + "dotglob", + ShellOptionDef::new( + |options| options.glob_matches_dotfiles, + |options, value| options.glob_matches_dotfiles = value, + ), + ), + ( + "execfail", + ShellOptionDef::new( + |options| options.exit_on_exec_fail, + |options, value| options.exit_on_exec_fail = value, + ), + ), + ( + "expand_aliases", + ShellOptionDef::new( + |options| options.expand_aliases, + |options, value| options.expand_aliases = value, + ), + ), + ( + "extdebug", + ShellOptionDef::new( + |options| options.enable_debugger, + |options, value| options.enable_debugger = value, + ), + ), + ( + "extglob", + ShellOptionDef::new( + |options| options.extended_globbing, + |options, value| options.extended_globbing = value, + ), + ), + ( + "extquote", + ShellOptionDef::new( + |options| options.extquote, + |options, value| options.extquote = value, + ), + ), + ( + "failglob", + ShellOptionDef::new( + |options| options.fail_expansion_on_globs_without_match, + |options, value| options.fail_expansion_on_globs_without_match = value, + ), + ), + ( + "force_fignore", + ShellOptionDef::new( + |options| options.force_fignore, + |options, value| options.force_fignore = value, + ), + ), + ( + "globasciiranges", + ShellOptionDef::new( + |options| options.glob_ranges_use_c_locale, + |options, value| options.glob_ranges_use_c_locale = value, + ), + ), + ( + "globskipdots", + ShellOptionDef::new( + |options| options.glob_skip_dots, + |options, value| options.glob_skip_dots = value, + ), + ), + ( + "globstar", + ShellOptionDef::new( + |options| options.enable_star_star_glob, + |options, value| options.enable_star_star_glob = value, + ), + ), + ( + "gnu_errfmt", + ShellOptionDef::new( + |options| options.errors_in_gnu_format, + |options, value| options.errors_in_gnu_format = value, + ), + ), + ( + "histappend", + ShellOptionDef::new( + |options| options.append_to_history_file, + |options, value| options.append_to_history_file = value, + ), + ), + ( + "histreedit", + ShellOptionDef::new( + |options| options.allow_reedit_failed_history_subst, + |options, value| options.allow_reedit_failed_history_subst = value, + ), + ), + ( + "histverify", + ShellOptionDef::new( + |options| options.allow_modifying_history_substitution, + |options, value| options.allow_modifying_history_substitution = value, + ), + ), + ( + "hostcomplete", + ShellOptionDef::new( + |options| options.enable_hostname_completion, + |options, value| options.enable_hostname_completion = value, + ), + ), + ( + "huponexit", + ShellOptionDef::new( + |options| options.send_sighup_to_all_jobs_on_exit, + |options, value| options.send_sighup_to_all_jobs_on_exit = value, + ), + ), + ( + "inherit_errexit", + ShellOptionDef::new( + |options| options.command_subst_inherits_errexit, + |options, value| options.command_subst_inherits_errexit = value, + ), + ), + ( + "interactive_comments", + ShellOptionDef::new( + |options| options.interactive_comments, + |options, value| options.interactive_comments = value, + ), + ), + ( + "lastpipe", + ShellOptionDef::new( + |options| options.run_last_pipeline_cmd_in_current_shell, + |options, value| options.run_last_pipeline_cmd_in_current_shell = value, + ), + ), + ( + "lithist", + ShellOptionDef::new( + |options| options.embed_newlines_in_multiline_cmds_in_history, + |options, value| options.embed_newlines_in_multiline_cmds_in_history = value, + ), + ), + ( + "localvar_inherit", + ShellOptionDef::new( + |options| options.local_vars_inherit_value_and_attrs, + |options, value| options.local_vars_inherit_value_and_attrs = value, + ), + ), + ( + "localvar_unset", + ShellOptionDef::new( + |options| options.localvar_unset, + |options, value| options.localvar_unset = value, + ), + ), + ( + "login_shell", + ShellOptionDef::new( + |options| options.login_shell, + |options, value| options.login_shell = value, + ), + ), + ( + "mailwarn", + ShellOptionDef::new( + |options| options.mail_warn, + |options, value| options.mail_warn = value, + ), + ), + ( + "no_empty_cmd_completion", + ShellOptionDef::new( + |options| options.no_empty_cmd_completion, + |options, value| options.no_empty_cmd_completion = value, + ), + ), + ( + "nocaseglob", + ShellOptionDef::new( + |options| options.case_insensitive_pathname_expansion, + |options, value| options.case_insensitive_pathname_expansion = value, + ), + ), + ( + "nocasematch", + ShellOptionDef::new( + |options| options.case_insensitive_conditionals, + |options, value| options.case_insensitive_conditionals = value, + ), + ), + ( + "noexpand_translation", + ShellOptionDef::new( + |options| options.no_expand_translation, + |options, value| options.no_expand_translation = value, + ), + ), + ( + "nullglob", + ShellOptionDef::new( + |options| options.expand_non_matching_patterns_to_null, + |options, value| options.expand_non_matching_patterns_to_null = value, + ), + ), + ( + "patsub_replacement", + ShellOptionDef::new( + |options| options.patsub_replacement, + |options, value| options.patsub_replacement = value, + ), + ), + ( + "progcomp", + ShellOptionDef::new( + |options| options.programmable_completion, + |options, value| options.programmable_completion = value, + ), + ), + ( + "progcomp_alias", + ShellOptionDef::new( + |options| options.programmable_completion_alias, + |options, value| options.programmable_completion_alias = value, + ), + ), + ( + "promptvars", + ShellOptionDef::new( + |options| options.expand_prompt_strings, + |options, value| options.expand_prompt_strings = value, + ), + ), + ( + "restricted_shell", + ShellOptionDef::new( + |options| options.restricted_shell, + |options, value| options.restricted_shell = value, + ), + ), + ( + "shift_verbose", + ShellOptionDef::new( + |options| options.shift_verbose, + |options, value| options.shift_verbose = value, + ), + ), + ( + "sourcepath", + ShellOptionDef::new( + |options| options.source_builtin_searches_path, + |options, value| options.source_builtin_searches_path = value, + ), + ), + ( + "varredir_close", + ShellOptionDef::new( + |options| options.var_redir_close, + |options, value| options.var_redir_close = value, + ), + ), + ( + "xpg_echo", + ShellOptionDef::new( + |options| options.echo_builtin_expands_escape_sequences, + |options, value| options.echo_builtin_expands_escape_sequences = value, + ), + ), + ]) +}); diff --git a/local/recipes/shells/brush/source/brush-core/src/openfiles.rs b/local/recipes/shells/brush/source/brush-core/src/openfiles.rs new file mode 100644 index 0000000000..f51ece0dea --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/openfiles.rs @@ -0,0 +1,465 @@ +//! Managing files open within a shell instance. + +use std::collections::HashMap; +use std::io::IsTerminal; +use std::process::Stdio; +use std::sync::Arc; + +use crate::ShellFd; +use crate::error; +use crate::sys; + +/// A trait representing a stream that can be read from and written to. +/// This is used for custom stream implementations in `OpenFile`. +/// +/// Types that implement this trait are expected to be cloneable via the +/// `clone_box` function. +pub trait Stream: std::io::Read + std::io::Write + Send + Sync { + /// Clones the stream into a boxed trait object. + fn clone_box(&self) -> Box; + + /// Converts the stream into an `OwnedFd`. Returns an error if the operation + /// is not supported or if it fails. + #[cfg(unix)] + fn try_clone_to_owned(&self) -> Result; + + /// Borrows the stream as a `BorrowedFd`. Returns an error if the operation + /// is not supported or if it fails. + #[cfg(unix)] + fn try_borrow_as_fd(&self) -> Result, error::Error>; +} + +/// Represents a file open in a shell context. +/// +/// File- and pipe-backed variants hold their handle behind an [`Arc`], so cloning an `OpenFile` +/// shares the underlying descriptor by reference count. The shell opens a fresh context for each +/// subshell, command substitution, background job, and function call and runs them as in-process +/// tasks against one process-wide descriptor table (rather than via `fork(2)` like a traditional +/// shell); sharing the descriptor keeps deeply nested or highly concurrent execution from +/// exhausting that table. A descriptor is duplicated for real only when an independently owned +/// copy is needed to hand to an external child process — see [`OpenFile::try_clone_to_owned`] and +/// the `Stdio` conversion below. +pub enum OpenFile { + /// The original standard input this process was started with. + Stdin(std::io::Stdin), + /// The original standard output this process was started with. + Stdout(std::io::Stdout), + /// The original standard error this process was started with. + Stderr(std::io::Stderr), + /// A file open for reading or writing. + File(Arc), + /// A read end of a pipe. + PipeReader(Arc), + /// A write end of a pipe. + PipeWriter(Arc), + /// A custom stream. + Stream(Box), +} + +#[cfg(feature = "serde")] +impl serde::Serialize for OpenFile { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + Self::Stdin(_) => serializer.serialize_str("stdin"), + Self::Stdout(_) => serializer.serialize_str("stdout"), + Self::Stderr(_) => serializer.serialize_str("stderr"), + Self::File(_) => serializer.serialize_str("file"), + Self::PipeReader(_) => serializer.serialize_str("pipe_reader"), + Self::PipeWriter(_) => serializer.serialize_str("pipe_writer"), + Self::Stream(_) => serializer.serialize_str("stream"), + } + } +} + +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for OpenFile { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + match String::deserialize(deserializer)?.as_str() { + "stdin" => return Ok(std::io::stdin().into()), + "stdout" => return Ok(std::io::stdout().into()), + "stderr" => return Ok(std::io::stderr().into()), + "file" => (), + "pipe_reader" => (), + "pipe_writer" => (), + "stream" => (), + _ => return Err(serde::de::Error::custom("invalid open file")), + } + + // TODO(serde): Figure out something better to do with open pipes and files. + null().map_err(serde::de::Error::custom) + } +} + +/// Returns an open file that will discard all I/O. +pub fn null() -> Result { + let file = sys::fs::open_null_file()?; + Ok(file.into()) +} + +impl Clone for OpenFile { + fn clone(&self) -> Self { + match self { + Self::Stdin(_) => std::io::stdin().into(), + Self::Stdout(_) => std::io::stdout().into(), + Self::Stderr(_) => std::io::stderr().into(), + // File and pipe handles are shared by reference count; cloning never issues a + // syscall and so cannot fail. + Self::File(f) => Self::File(Arc::clone(f)), + Self::PipeReader(r) => Self::PipeReader(Arc::clone(r)), + Self::PipeWriter(w) => Self::PipeWriter(Arc::clone(w)), + Self::Stream(s) => Self::Stream(s.clone_box()), + } + } +} + +impl std::fmt::Display for OpenFile { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Stdin(_) => write!(f, "stdin"), + Self::Stdout(_) => write!(f, "stdout"), + Self::Stderr(_) => write!(f, "stderr"), + Self::File(_) => write!(f, "file"), + Self::PipeReader(_) => write!(f, "pipe reader"), + Self::PipeWriter(_) => write!(f, "pipe writer"), + Self::Stream(_) => write!(f, "stream"), + } + } +} + +impl OpenFile { + /// Converts the open file into an `OwnedFd`. For shared file/pipe handles this materializes + /// a real duplicate via `dup(2)` so the caller receives an independently owned descriptor. + #[cfg(unix)] + pub(crate) fn try_clone_to_owned(self) -> Result { + use std::os::fd::AsFd as _; + + match self { + Self::Stdin(f) => Ok(f.as_fd().try_clone_to_owned()?), + Self::Stdout(f) => Ok(f.as_fd().try_clone_to_owned()?), + Self::Stderr(f) => Ok(f.as_fd().try_clone_to_owned()?), + Self::File(f) => Ok(f.as_fd().try_clone_to_owned()?), + Self::PipeReader(r) => Ok(r.as_fd().try_clone_to_owned()?), + Self::PipeWriter(w) => Ok(w.as_fd().try_clone_to_owned()?), + Self::Stream(s) => s.try_clone_to_owned(), + } + } + + /// Borrows the open file as a `BorrowedFd`. + /// + /// # Errors + /// + /// Returns an error if the operation is not supported for the underlying file type. + #[cfg(unix)] + pub fn try_borrow_as_fd(&self) -> Result, error::Error> { + use std::os::fd::AsFd as _; + + match self { + Self::Stdin(f) => Ok(f.as_fd()), + Self::Stdout(f) => Ok(f.as_fd()), + Self::Stderr(f) => Ok(f.as_fd()), + Self::File(f) => Ok(f.as_fd()), + Self::PipeReader(r) => Ok(r.as_fd()), + Self::PipeWriter(w) => Ok(w.as_fd()), + Self::Stream(s) => s.try_borrow_as_fd(), + } + } + + pub(crate) fn is_dir(&self) -> bool { + match self { + Self::Stdin(_) | Self::Stdout(_) | Self::Stderr(_) => false, + Self::File(file) => file.metadata().is_ok_and(|m| m.is_dir()), + Self::PipeReader(_) | Self::PipeWriter(_) | Self::Stream(_) => false, + } + } + + /// Checks if the open file is associated with a terminal. + pub fn is_terminal(&self) -> bool { + match self { + Self::Stdin(f) => f.is_terminal(), + Self::Stdout(f) => f.is_terminal(), + Self::Stderr(f) => f.is_terminal(), + Self::File(f) => f.is_terminal(), + Self::PipeReader(_) | Self::PipeWriter(_) | Self::Stream(_) => false, + } + } +} + +impl From for OpenFile { + /// Creates an `OpenFile` from standard input. + fn from(stdin: std::io::Stdin) -> Self { + Self::Stdin(stdin) + } +} + +impl From for OpenFile { + /// Creates an `OpenFile` from standard output. + fn from(stdout: std::io::Stdout) -> Self { + Self::Stdout(stdout) + } +} + +impl From for OpenFile { + /// Creates an `OpenFile` from standard error. + fn from(stderr: std::io::Stderr) -> Self { + Self::Stderr(stderr) + } +} + +impl From for OpenFile { + fn from(file: std::fs::File) -> Self { + Self::File(Arc::new(file)) + } +} + +impl From for OpenFile { + fn from(reader: std::io::PipeReader) -> Self { + Self::PipeReader(Arc::new(reader)) + } +} + +impl From for OpenFile { + fn from(writer: std::io::PipeWriter) -> Self { + Self::PipeWriter(Arc::new(writer)) + } +} + +impl TryFrom for Stdio { + type Error = error::Error; + + fn try_from(open_file: OpenFile) -> Result { + // File and pipe handles are shared behind an `Arc`, so the descriptor cannot be moved + // out; duplicate it to give the child an independently owned descriptor. Duplication can + // fail (e.g. under descriptor exhaustion), so the conversion is fallible and the error is + // surfaced to the caller rather than silently degrading the child's streams. + match open_file { + OpenFile::Stdin(_) | OpenFile::Stdout(_) | OpenFile::Stderr(_) => Ok(Self::inherit()), + OpenFile::File(f) => Ok(f.try_clone()?.into()), + OpenFile::PipeReader(r) => Ok(r.try_clone()?.into()), + OpenFile::PipeWriter(w) => Ok(w.try_clone()?.into()), + // Custom streams have no descriptor to hand to a child process. + OpenFile::Stream(_) => Ok(Self::null()), + } + } +} + +impl std::io::Read for OpenFile { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + match self { + Self::Stdin(f) => f.read(buf), + Self::Stdout(_) => Err(std::io::Error::other( + error::ErrorKind::OpenFileNotReadable("stdout"), + )), + Self::Stderr(_) => Err(std::io::Error::other( + error::ErrorKind::OpenFileNotReadable("stderr"), + )), + // The handle is shared behind an `Arc`; read through a shared reference (`&File` + // and `&PipeReader` both implement `Read`). + Self::File(f) => f.as_ref().read(buf), + Self::PipeReader(reader) => reader.as_ref().read(buf), + Self::PipeWriter(_) => Err(std::io::Error::other( + error::ErrorKind::OpenFileNotReadable("pipe writer"), + )), + Self::Stream(s) => s.read(buf), + } + } +} + +impl std::io::Write for OpenFile { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + match self { + Self::Stdin(_) => Err(std::io::Error::other( + error::ErrorKind::OpenFileNotWritable("stdin"), + )), + Self::Stdout(f) => f.write(buf), + Self::Stderr(f) => f.write(buf), + // The handle is shared behind an `Arc`; write through a shared reference (`&File` + // and `&PipeWriter` both implement `Write`). + Self::File(f) => f.as_ref().write(buf), + Self::PipeReader(_) => Err(std::io::Error::other( + error::ErrorKind::OpenFileNotWritable("pipe reader"), + )), + Self::PipeWriter(writer) => writer.as_ref().write(buf), + Self::Stream(s) => s.write(buf), + } + } + + fn flush(&mut self) -> std::io::Result<()> { + match self { + Self::Stdin(_) => Ok(()), + Self::Stdout(f) => f.flush(), + Self::Stderr(f) => f.flush(), + Self::File(f) => f.as_ref().flush(), + Self::PipeReader(_) => Ok(()), + Self::PipeWriter(writer) => writer.as_ref().flush(), + Self::Stream(s) => s.flush(), + } + } +} + +/// Tristate representing the an `OpenFile` entry in an `OpenFiles` structure. +pub enum OpenFileEntry<'a> { + /// File descriptor is present and has a valid associated `OpenFile`. + Open(&'a OpenFile), + /// File descriptor is explicitly marked as not being mapped to any `OpenFile`. + NotPresent, + /// File descriptor is not specified in any way; it may be provided by a + /// parent context of some kind. + NotSpecified, +} + +/// Represents the open files in a shell context. +#[derive(Clone, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct OpenFiles { + /// Maps shell file descriptors to open files. + files: HashMap>, +} + +impl OpenFiles { + /// File descriptor used for standard input. + pub const STDIN_FD: ShellFd = 0; + /// File descriptor used for standard output. + pub const STDOUT_FD: ShellFd = 1; + /// File descriptor used for standard error. + pub const STDERR_FD: ShellFd = 2; + + /// First file descriptor available for non-stdio files. + const FIRST_NON_STDIO_FD: ShellFd = 3; + /// Maximum file descriptor number allowed. + const MAX_FD: ShellFd = 1024; + + /// Creates a new `OpenFiles` instance populated with stdin, stdout, and stderr + /// from the host environment. + pub(crate) fn new() -> Self { + Self { + files: HashMap::from([ + (Self::STDIN_FD, Some(std::io::stdin().into())), + (Self::STDOUT_FD, Some(std::io::stdout().into())), + (Self::STDERR_FD, Some(std::io::stderr().into())), + ]), + } + } + + /// Updates the open files from the provided iterator of (fd number, `OpenFile`) pairs. + /// Any existing entries for the provided file descriptors will be overwritten. + /// + /// # Arguments + /// + /// * `files`: An iterator of (fd number, `OpenFile`) pairs to update the open files with. + pub fn update_from(&mut self, files: impl Iterator) { + for (fd, file) in files { + let _ = self.files.insert(fd, Some(file)); + } + } + + /// Retrieves the file backing standard input in this context. + pub fn try_stdin(&self) -> Option<&OpenFile> { + self.files.get(&Self::STDIN_FD).and_then(|f| f.as_ref()) + } + + /// Retrieves the file backing standard output in this context. + pub fn try_stdout(&self) -> Option<&OpenFile> { + self.files.get(&Self::STDOUT_FD).and_then(|f| f.as_ref()) + } + + /// Retrieves the file backing standard error in this context. + pub fn try_stderr(&self) -> Option<&OpenFile> { + self.files.get(&Self::STDERR_FD).and_then(|f| f.as_ref()) + } + + /// Tries to remove an open file by its file descriptor. If the file descriptor + /// is not used, `None` will be returned; otherwise, the removed file will + /// be returned. + /// + /// Arguments: + /// + /// * `fd`: The file descriptor to remove. + pub fn remove_fd(&mut self, fd: ShellFd) -> Option { + self.files.insert(fd, None).and_then(|f| f) + } + + /// Tries to lookup the `OpenFile` associated with a file descriptor. + /// Returns `None` if the file descriptor is not present. + /// + /// Arguments: + /// + /// * `fd`: The file descriptor to lookup. + pub fn try_fd(&self, fd: ShellFd) -> Option<&OpenFile> { + self.files.get(&fd).and_then(|f| f.as_ref()) + } + + /// Tries to lookup the `OpenFile` associated with a file descriptor. Returns + /// an `OpenFileEntry` representing the state of the file descriptor. + /// + /// Arguments: + /// + /// * `fd`: The file descriptor to lookup. + pub fn fd_entry(&self, fd: ShellFd) -> OpenFileEntry<'_> { + self.files + .get(&fd) + .map_or(OpenFileEntry::NotSpecified, |opt_file| match opt_file { + Some(f) => OpenFileEntry::Open(f), + None => OpenFileEntry::NotPresent, + }) + } + + /// Checks if the given file descriptor is in use. + pub fn contains_fd(&self, fd: ShellFd) -> bool { + self.files.contains_key(&fd) + } + + /// Associates the given file descriptor with the provided file. If the file descriptor + /// is already in use, the previous file will be returned; otherwise, `None` + /// will be returned. + /// + /// Arguments: + /// + /// * `fd`: The file descriptor to associate with the file. + /// * `file`: The file to associate with the file descriptor. + pub fn set_fd(&mut self, fd: ShellFd, file: OpenFile) -> Option { + self.files.insert(fd, Some(file)).and_then(|f| f) + } + + /// Iterates over all file descriptors. + pub fn iter_fds(&self) -> impl Iterator { + self.files + .iter() + .filter_map(|(fd, file)| file.as_ref().map(|f| (*fd, f))) + } + + /// Adds a new open file, returning the assigned file descriptor. + /// + /// # Arguments + /// + /// * `file`: The open file to add. + pub fn add(&mut self, file: OpenFile) -> Result { + // Start searching for free file descriptors after the standard ones. + let mut fd = Self::FIRST_NON_STDIO_FD; + while self.files.contains_key(&fd) { + if fd >= Self::MAX_FD { + return Err(error::ErrorKind::TooManyOpenFiles.into()); + } + + fd += 1; + } + + self.files.insert(fd, Some(file)); + Ok(fd) + } +} + +impl From for OpenFiles +where + I: Iterator, +{ + fn from(iter: I) -> Self { + let files = iter.map(|(fd, file)| (fd, Some(file))).collect(); + Self { files } + } +} diff --git a/local/recipes/shells/brush/source/brush-core/src/options.rs b/local/recipes/shells/brush/source/brush-core/src/options.rs new file mode 100644 index 0000000000..4008f91797 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/options.rs @@ -0,0 +1,395 @@ +//! Defines runtime options for the shell. + +use itertools::Itertools; + +use crate::{CreateOptions, extensions, namedoptions}; + +/// Runtime changeable options for a shell instance. +#[derive(Clone, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[expect(clippy::module_name_repetitions)] +pub struct RuntimeOptions { + // + // Single-character options. + /// -a + pub export_variables_on_modification: bool, + /// -b + pub notify_job_termination_immediately: bool, + /// -e + pub exit_on_nonzero_command_exit: bool, + /// -f + pub disable_filename_globbing: bool, + /// -h + pub remember_command_locations: bool, + /// -k + pub place_all_assignment_args_in_command_env: bool, + /// -m + pub enable_job_control: bool, + /// -n + pub do_not_execute_commands: bool, + /// -p + pub real_effective_uid_mismatch: bool, + /// -t + pub exit_after_one_command: bool, + /// -u + pub treat_unset_variables_as_error: bool, + /// -v + pub print_shell_input_lines: bool, + /// -x + pub print_commands_and_arguments: bool, + /// -B + pub perform_brace_expansion: bool, + /// -C + pub disallow_overwriting_regular_files_via_output_redirection: bool, + /// -E + pub shell_functions_inherit_err_trap: bool, + /// -H + pub enable_bang_style_history_substitution: bool, + /// -P + pub do_not_resolve_symlinks_when_changing_dir: bool, + /// -T + pub shell_functions_inherit_debug_and_return_traps: bool, + + // + // Options set through -o. + /// 'emacs' + pub emacs_mode: bool, + /// 'history' + pub enable_command_history: bool, + /// 'ignoreeof' + pub ignore_eof: bool, + /// 'pipefail' + pub return_last_failure_from_pipeline: bool, + /// 'posix' + pub posix_mode: bool, + /// 'vi' + pub vi_mode: bool, + + // + // Options set through shopt. + /// `array_expand_once` + pub array_expand_once: bool, + /// `assoc_expand_once` + pub assoc_expand_once: bool, + /// 'autocd' + pub auto_cd: bool, + /// `bash_source_full_path` + pub bash_source_full_path: bool, + /// `cdable_vars` + pub cdable_vars: bool, + /// 'cdspell' + pub cd_autocorrect_spelling: bool, + /// 'checkhash' + pub check_hashtable_before_command_exec: bool, + /// 'checkjobs' + pub check_jobs_before_exit: bool, + /// 'checkwinsize' + pub check_window_size_after_external_commands: bool, + /// 'cmdhist' + pub save_multiline_cmds_in_history: bool, + /// 'compat31' + pub compat31: bool, + /// 'compat32' + pub compat32: bool, + /// 'compat40' + pub compat40: bool, + /// 'compat41' + pub compat41: bool, + /// 'compat42' + pub compat42: bool, + /// 'compat43' + pub compat43: bool, + /// 'compat44' + pub compat44: bool, + /// `complete_fullquote` + pub quote_all_metachars_in_completion: bool, + /// 'direxpand' + pub expand_dir_names_on_completion: bool, + /// 'dirspell' + pub autocorrect_dir_spelling_on_completion: bool, + /// 'dotglob' + pub glob_matches_dotfiles: bool, + /// 'execfail' + pub exit_on_exec_fail: bool, + /// `expand_aliases` + pub expand_aliases: bool, + /// 'extdebug' + pub enable_debugger: bool, + /// 'extglob' + pub extended_globbing: bool, + /// 'extquote' + pub extquote: bool, + /// 'failglob' + pub fail_expansion_on_globs_without_match: bool, + /// `force_fignore` + pub force_fignore: bool, + /// 'globasciiranges' + pub glob_ranges_use_c_locale: bool, + /// 'globskipdots' + pub glob_skip_dots: bool, + /// 'globstar' + pub enable_star_star_glob: bool, + /// `gnu_errfmt` + pub errors_in_gnu_format: bool, + /// 'histappend' + pub append_to_history_file: bool, + /// 'histreedit' + pub allow_reedit_failed_history_subst: bool, + /// 'histverify' + pub allow_modifying_history_substitution: bool, + /// 'hostcomplete' + pub enable_hostname_completion: bool, + /// 'huponexit' + pub send_sighup_to_all_jobs_on_exit: bool, + /// `inherit_errexit` + pub command_subst_inherits_errexit: bool, + /// `interactive_comments` + pub interactive_comments: bool, + /// 'lastpipe' + pub run_last_pipeline_cmd_in_current_shell: bool, + /// 'lithist' + pub embed_newlines_in_multiline_cmds_in_history: bool, + /// `localvar_inherit` + pub local_vars_inherit_value_and_attrs: bool, + /// `localvar_unset` + pub localvar_unset: bool, + /// `login_shell` + pub login_shell: bool, + /// 'mailwarn' + pub mail_warn: bool, + /// `no_empty_cmd_completion` + pub no_empty_cmd_completion: bool, + /// 'nocaseglob' + pub case_insensitive_pathname_expansion: bool, + /// 'nocasematch' + pub case_insensitive_conditionals: bool, + /// `noexpand_translation` + pub no_expand_translation: bool, + /// 'nullglob' + pub expand_non_matching_patterns_to_null: bool, + /// `patsub_replacement` + pub patsub_replacement: bool, + /// 'progcomp' + pub programmable_completion: bool, + /// `progcomp_alias` + pub programmable_completion_alias: bool, + /// 'promptvars' + pub expand_prompt_strings: bool, + /// `restricted_shell` + pub restricted_shell: bool, + /// `shift_verbose` + pub shift_verbose: bool, + /// `sourcepath` + pub source_builtin_searches_path: bool, + /// `varredir_close` + pub var_redir_close: bool, + /// `xpg_echo` + pub echo_builtin_expands_escape_sequences: bool, + + // + // Options set by the shell. + /// Whether or not the shell is interactive. + pub interactive: bool, + /// Whether commands are being read from stdin. + pub read_commands_from_stdin: bool, + /// Whether the shell is in command string mode (-c). + pub command_string_mode: bool, + /// Whether or not the shell is in maximal `sh` compatibility mode. + pub sh_mode: bool, + /// Whether to treat external commands as session leaders. + pub external_cmd_leads_session: bool, + /// Whether externally spawned commands are killed when their spawning shell is dropped. + pub kill_external_commands_on_drop: bool, + /// Maximum function call depth. + pub max_function_call_depth: Option, +} + +impl RuntimeOptions { + /// Creates a default set of runtime options based on the given creation options. + /// + /// # Arguments + /// + /// * `create_options` - The options used to create the shell. + pub fn defaults_from( + create_options: &CreateOptions, + ) -> Self { + // There's a set of options enabled by default for all shells. + let mut options = Self { + interactive: create_options.interactive, + disallow_overwriting_regular_files_via_output_redirection: create_options + .disallow_overwriting_regular_files_via_output_redirection, + do_not_execute_commands: create_options.do_not_execute_commands, + enable_command_history: create_options.interactive, + enable_job_control: create_options.interactive, + exit_after_one_command: create_options.exit_after_one_command, + read_commands_from_stdin: create_options.read_commands_from_stdin, + command_string_mode: create_options.command_string_mode, + sh_mode: create_options.sh_mode, + posix_mode: create_options.posix, + print_commands_and_arguments: create_options.print_commands_and_arguments, + print_shell_input_lines: create_options.verbose, + treat_unset_variables_as_error: create_options.treat_unset_variables_as_error, + exit_on_nonzero_command_exit: create_options.exit_on_nonzero_command_exit, + external_cmd_leads_session: create_options.external_cmd_leads_session, + kill_external_commands_on_drop: create_options.kill_external_commands_on_drop, + login_shell: create_options.login, + disable_filename_globbing: create_options.disable_pathname_expansion, + remember_command_locations: true, + check_window_size_after_external_commands: true, + save_multiline_cmds_in_history: true, + extquote: true, + force_fignore: true, + case_insensitive_pathname_expansion: + crate::sys::fs::default_case_insensitive_path_expansion(), + enable_hostname_completion: true, + interactive_comments: true, + expand_prompt_strings: true, + source_builtin_searches_path: true, + perform_brace_expansion: true, + quote_all_metachars_in_completion: true, + programmable_completion: true, + glob_ranges_use_c_locale: true, + glob_skip_dots: true, + patsub_replacement: true, + max_function_call_depth: create_options.max_function_call_depth, + ..Self::default() + }; + + // Additional options are enabled by default for interactive shells. + if create_options.interactive { + options.enable_bang_style_history_substitution = true; + options.emacs_mode = !create_options.no_editing; + options.expand_aliases = true; + } + + // Update any options. + for enabled_option in &create_options.enabled_options { + if let Some(option) = namedoptions::options(namedoptions::ShellOptionKind::SetO) + .get(enabled_option.as_str()) + { + option.set(&mut options, true); + } + } + for disabled_option in &create_options.disabled_options { + if let Some(option) = namedoptions::options(namedoptions::ShellOptionKind::SetO) + .get(disabled_option.as_str()) + { + option.set(&mut options, false); + } + } + + // Update any shopt options. + for enabled_option in &create_options.enabled_shopt_options { + if let Some(shopt_option) = namedoptions::options(namedoptions::ShellOptionKind::Shopt) + .get(enabled_option.as_str()) + { + shopt_option.set(&mut options, true); + } + } + for disabled_option in &create_options.disabled_shopt_options { + if let Some(shopt_option) = namedoptions::options(namedoptions::ShellOptionKind::Shopt) + .get(disabled_option.as_str()) + { + shopt_option.set(&mut options, false); + } + } + + options + } + + /// Returns a string representing the current `set`-style option flags set in the shell. + pub fn option_flags(&self) -> String { + let mut cs = vec![]; + + for o in namedoptions::options(namedoptions::ShellOptionKind::Set).iter() { + if o.definition.get(self) + && let Some(c) = o.name.chars().next() + { + cs.push(c); + } + } + + // Sort the flags in a way that matches what bash does. + cs.sort_by_key(|flag| option_flag_sort_key(*flag)); + + cs.into_iter().collect() + } + + /// Returns a colon-separated list of sorted 'set -o' options enabled. + pub fn seto_optstr(&self) -> String { + let mut cs = vec![]; + + for option in namedoptions::options(namedoptions::ShellOptionKind::SetO).iter() { + if option.definition.get(self) { + cs.push(option.name); + } + } + + cs.sort_unstable(); + cs.into_iter().join(":") + } + + /// Returns a colon-separated list of sorted 'shopt' options enabled. + pub fn shopt_optstr(&self) -> String { + let mut cs = vec![]; + + for option in namedoptions::options(namedoptions::ShellOptionKind::Shopt).iter() { + if option.definition.get(self) { + cs.push(option.name); + } + } + + cs.sort_unstable(); + cs.into_iter().join(":") + } +} + +/// Sort option flag character in a way that mirrors bash behavior. +/// +/// # Arguments +/// +/// * `ch` - The option flag character. +const fn option_flag_sort_key(ch: char) -> (u8, char) { + // NOTE: bash appears to sort in 3 groups. We mimic them: + // 1) Lowercase letters excluding 'c' and 's' (sorted) + // 2) Uppercase letters (sorted) + // 3) All other characters (sorted) + let group = if ch.is_ascii_lowercase() && !matches!(ch, 'c' | 's') { + 0 + } else if ch.is_ascii_uppercase() { + 1 + } else { + 2 + }; + + (group, ch) +} + +#[cfg(test)] +mod tests { + use super::option_flag_sort_key; + + #[test] + fn lowercase_excluding_c_and_s_sort_first() { + let mut flags = vec!['b', 'A', 'Z', 's', 'c', 'a']; + flags.sort_by_key(|flag| option_flag_sort_key(*flag)); + + assert_eq!(flags, vec!['a', 'b', 'A', 'Z', 'c', 's']); + } + + #[test] + fn uppercase_sorted_before_miscellaneous() { + let mut flags = vec!['P', 'B', '1', 'T']; + flags.sort_by_key(|flag| option_flag_sort_key(*flag)); + + assert_eq!(flags, vec!['B', 'P', 'T', '1']); + } + + #[test] + fn miscellaneous_characters_respect_ascii_order() { + let mut flags = vec!['s', 'c', '%', ':']; + flags.sort_by_key(|flag| option_flag_sort_key(*flag)); + + assert_eq!(flags, vec!['%', ':', 'c', 's']); + } +} diff --git a/local/recipes/shells/brush/source/brush-core/src/pathcache.rs b/local/recipes/shells/brush/source/brush-core/src/pathcache.rs new file mode 100644 index 0000000000..4cf71d6773 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/pathcache.rs @@ -0,0 +1,59 @@ +//! Path cache + +use crate::{error, variables}; +use std::path::PathBuf; + +/// A cache of paths associated with names. +#[derive(Clone, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct PathCache { + /// The cache itself. + cache: std::collections::HashMap, +} + +impl PathCache { + /// Clears all elements from the cache. + pub fn reset(&mut self) { + self.cache.clear(); + } + + /// Returns the path associated with the given name. + /// + /// # Arguments + /// + /// * `name` - The name to lookup. + pub fn get>(&self, name: S) -> Option { + self.cache.get(name.as_ref()).cloned() + } + + /// Sets the path associated with the given name. + /// + /// # Arguments + /// + /// * `name` - The name to set. + /// * `path` - The path to associate with the name. + pub fn set>(&mut self, name: T, path: PathBuf) { + self.cache.insert(name.into(), path); + } + + /// Projects the cache into a shell value. + pub fn to_value(&self) -> Result { + let pairs = self + .cache + .iter() + .map(|(k, v)| (Some(k.to_owned()), v.to_string_lossy().to_string())) + .collect::>(); + + variables::ShellValue::associative_array_from_literals(variables::ArrayLiteral(pairs)) + } + + /// Removes the path associated with the given name, if there is one. + /// Returns whether or not an entry was removed. + /// + /// # Arguments + /// + /// * `name` - The name to remove. + pub fn unset>(&mut self, name: S) -> bool { + self.cache.remove(name.as_ref()).is_some() + } +} diff --git a/local/recipes/shells/brush/source/brush-core/src/pathsearch.rs b/local/recipes/shells/brush/source/brush-core/src/pathsearch.rs new file mode 100644 index 0000000000..a52b441391 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/pathsearch.rs @@ -0,0 +1,137 @@ +//! Path searching utilities. + +use std::{ + collections::VecDeque, + path::{Path, PathBuf}, +}; + +use crate::sys; +use crate::sys::fs::PathExt; + +/// Encapsulates the result of a path search. +pub struct ExecutablePathSearch { + paths: VecDeque, + filename: N, +} + +impl Iterator for ExecutablePathSearch +where + PI: AsRef, + N: AsRef, +{ + type Item = PathBuf; + + fn next(&mut self) -> Option { + while let Some(path) = self.paths.pop_front() { + let path = PathBuf::from(path.as_ref()).join(self.filename.as_ref()); + // Skip directories outright, then ask the platform to resolve + // the path to an actual executable file (which, on Windows, may + // involve appending a PATHEXT extension). The helper takes + // ownership so Unix — where no resolution is needed — can return + // the path unchanged without allocating. + if path.is_dir() { + continue; + } + if let Some(resolved) = sys::fs::resolve_executable(path) { + return Some(resolved); + } + } + None + } +} + +pub(crate) struct ExecutablePathPrefixSearch { + paths: VecDeque, + queued_items: VecDeque, + filename_prefix: String, + case_insensitive: bool, +} + +impl Iterator for ExecutablePathPrefixSearch +where + PI: AsRef, +{ + type Item = PathBuf; + + fn next(&mut self) -> Option { + // If we already found some items and queued them, then yield one now. + if let Some(item) = self.queued_items.pop_front() { + return Some(item); + } + + while let Some(path) = self.paths.pop_front() { + let path = PathBuf::from(path.as_ref()); + + if let Ok(readdir) = path.read_dir() { + for entry in readdir.flatten() { + if let Ok(mut filename) = entry.file_name().into_string() { + if self.case_insensitive { + filename = filename.to_ascii_lowercase(); + } + + if !filename.starts_with(&self.filename_prefix) { + continue; + } + } + + let entry_path = entry.path(); + if let Ok(file_type) = entry.file_type() { + if file_type.is_file() && entry_path.executable() { + self.queued_items.push_back(entry_path); + continue; + } + if file_type.is_symlink() && entry_path.executable() { + self.queued_items.push_back(entry_path); + } + } + } + } + if let Some(item) = self.queued_items.pop_front() { + return Some(item); + } + } + + None + } +} + +/// Search for the given executable name in the provided paths. +/// +/// # Arguments +/// +/// * `paths` - An iterator over the paths to search. +/// * `filename` - The name of the executable file to search for. +pub fn search_for_executable(paths: P, filename: N) -> ExecutablePathSearch +where + P: Iterator, + PI: AsRef, + N: AsRef, +{ + ExecutablePathSearch { + paths: paths.collect(), + filename, + } +} + +pub(crate) fn search_for_executable_with_prefix( + paths: P, + filename_prefix: &str, + case_insensitive: bool, +) -> ExecutablePathPrefixSearch +where + P: Iterator, + PI: AsRef, +{ + let stored_prefix = if case_insensitive { + filename_prefix.to_ascii_lowercase() + } else { + filename_prefix.into() + }; + + ExecutablePathPrefixSearch { + paths: paths.collect(), + queued_items: VecDeque::new(), + filename_prefix: stored_prefix, + case_insensitive, + } +} diff --git a/local/recipes/shells/brush/source/brush-core/src/patterns.rs b/local/recipes/shells/brush/source/brush-core/src/patterns.rs new file mode 100644 index 0000000000..2564c6d46b --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/patterns.rs @@ -0,0 +1,1040 @@ +//! Shell patterns + +use crate::{error, regex, sys, trace_categories}; +use std::{collections::VecDeque, path::Path}; + +/// Represents a piece of a shell pattern. +#[derive(Clone, Debug)] +pub(crate) enum PatternPiece { + /// A pattern that should be interpreted as a shell pattern. + Pattern(String), + /// A literal string that should be matched exactly. + Literal(String), +} + +impl PatternPiece { + pub fn as_str(&self) -> &str { + match self { + Self::Pattern(s) => s, + Self::Literal(s) => s, + } + } +} + +type PatternWord = Vec; + +/// Options for filename expansion. +#[derive(Clone, Debug, Default)] +pub(crate) struct FilenameExpansionOptions { + pub require_dot_in_pattern_to_match_dot_files: bool, +} + +/// Result of a pattern expansion, distinguishing "no glob metacharacters" from +/// "glob expansion attempted but found no matches". +#[derive(Debug, Default)] +pub(crate) enum PatternExpansionResult { + /// No glob metacharacters found; no expansion was attempted. + #[default] + NoGlob, + /// Glob expansion was attempted. Contains matching paths (may be empty). + Expanded(Vec), +} + +impl PatternExpansionResult { + /// Returns the expansion results, regardless of variant. + pub fn into_paths(self) -> Vec { + match self { + Self::NoGlob => vec![], + Self::Expanded(paths) => paths, + } + } + + /// Returns true if glob expansion was attempted but produced no results. + pub const fn is_unmatched_glob(&self) -> bool { + matches!(self, Self::Expanded(paths) if paths.is_empty()) + } +} + +/// Encapsulates a shell pattern. +#[derive(Clone, Debug)] +pub struct Pattern { + pieces: PatternWord, + enable_extended_globbing: bool, + multiline: bool, + case_insensitive: bool, +} + +impl Default for Pattern { + fn default() -> Self { + Self { + pieces: vec![], + enable_extended_globbing: false, + multiline: true, + case_insensitive: false, + } + } +} + +impl From for Pattern { + fn from(pieces: PatternWord) -> Self { + Self { + pieces, + ..Default::default() + } + } +} + +impl From<&PatternWord> for Pattern { + fn from(value: &PatternWord) -> Self { + Self { + pieces: value.clone(), + ..Default::default() + } + } +} + +impl From<&str> for Pattern { + fn from(value: &str) -> Self { + Self { + pieces: vec![PatternPiece::Pattern(value.to_owned())], + ..Default::default() + } + } +} + +impl From for Pattern { + fn from(value: String) -> Self { + Self { + pieces: vec![PatternPiece::Pattern(value)], + ..Default::default() + } + } +} + +impl Pattern { + /// Enables (or disables) extended globbing support for this pattern. + /// + /// # Arguments + /// + /// * `value` - Whether or not to enable extended globbing (extglob). + #[must_use] + pub const fn set_extended_globbing(mut self, value: bool) -> Self { + self.enable_extended_globbing = value; + self + } + + /// Enables (or disables) multiline support for this pattern. + /// + /// # Arguments + /// + /// * `value` - Whether or not to enable multiline matching. + #[must_use] + pub const fn set_multiline(mut self, value: bool) -> Self { + self.multiline = value; + self + } + + /// Enables (or disables) case-insensitive matching for this pattern. + /// + /// # Arguments + /// + /// * `value` - Whether or not to enable case-insensitive matching. + #[must_use] + pub const fn set_case_insensitive(mut self, value: bool) -> Self { + self.case_insensitive = value; + self + } + + /// Returns whether or not the pattern is empty. + pub fn is_empty(&self) -> bool { + self.pieces.iter().all(|p| p.as_str().is_empty()) + } + + /// Placeholder function that always returns true. + pub(crate) const fn accept_all_expand_filter(_path: &Path) -> bool { + true + } + + /// Expands the pattern into a list of matching file paths. + /// + /// # Arguments + /// + /// * `working_dir` - The current working directory, used for relative paths. + /// * `path_filter` - Optionally provides a function that filters paths after expansion. + #[expect(clippy::too_many_lines)] + pub(crate) fn expand( + &self, + working_dir: &Path, + path_filter: Option<&PF>, + options: &FilenameExpansionOptions, + ) -> Result + where + PF: Fn(&Path) -> bool, + { + // If the pattern has no pieces at all, short-circuit; there's nothing to expand. + // Note: we intentionally do NOT short-circuit when pieces are present but empty + // (e.g. from a quoted empty string ""); those fall through to the literal branch + // below which correctly returns Expanded([""]) instead of NoGlob, preserving + // the argument even when nullglob is enabled. + if self.pieces.is_empty() { + return Ok(PatternExpansionResult::NoGlob); + + // Similarly, if we're *confident* the pattern doesn't require expansion, then we + // know there's a single expansion (before filtering). + } else if !self.pieces.iter().any(|piece| { + matches!(piece, PatternPiece::Pattern(_)) + && requires_expansion(piece.as_str(), self.enable_extended_globbing) + }) { + let concatenated: String = self.pieces.iter().map(|piece| piece.as_str()).collect(); + + if let Some(filter) = path_filter + && !filter(Path::new(&concatenated)) + { + // No globs, but the literal was filtered out. Return NoGlob + // (not Expanded) so that callers don't mistake this for a + // failed glob match (which would trigger failglob). + return Ok(PatternExpansionResult::NoGlob); + } + + return Ok(PatternExpansionResult::Expanded(vec![concatenated])); + } + + tracing::debug!(target: trace_categories::PATTERN, "expanding pattern: {self:?}"); + + let mut components: Vec = vec![]; + for piece in &self.pieces { + let mut split_result: VecDeque<_> = sys::fs::split_path_for_pattern(piece.as_str()) + .map(|s| match piece { + PatternPiece::Pattern(_) => PatternPiece::Pattern(s.to_owned()), + PatternPiece::Literal(_) => PatternPiece::Literal(s.to_owned()), + }) + .collect(); + + if let Some(first_piece) = split_result.pop_front() { + if let Some(last_component) = components.last_mut() { + last_component.push(first_piece); + } else { + components.push(vec![first_piece]); + } + } + + while let Some(piece) = split_result.pop_front() { + components.push(vec![piece]); + } + } + + // Check if the path appears to be absolute by inspecting the first component. + // On Unix, a leading `/` produces an empty first component. On Windows, a + // drive-letter prefix like `c:` is also recognized. The platform-specific + // logic lives in `sys::fs::pattern_path_root`. + let absolute_root = components.first().and_then(|first_component| { + let flattened: String = first_component.iter().map(|p| p.as_str()).collect(); + sys::fs::pattern_path_root(&flattened) + }); + + let prefix_to_remove; + let mut paths_so_far = if let Some(root) = absolute_root { + prefix_to_remove = None; + // Skip the first component; it was consumed to determine the root. + components.remove(0); + vec![root] + } else { + // Build a prefix to remove after glob expansion so results are + // returned relative to the working directory. The prefix is + // normalized to use `/` separators because `push_path_for_pattern` + // also uses `/` on Windows (to avoid `PathBuf::push` drive-letter + // semantics) — if we left `\` here, the strip_prefix below would + // miss on Windows and leave results as absolute paths. + let working_dir_str = working_dir.to_string_lossy(); + let mut working_dir_str = + sys::fs::normalize_path_separators(&working_dir_str).into_owned(); + if !working_dir_str.ends_with('/') { + working_dir_str.push('/'); + } + + prefix_to_remove = Some(working_dir_str); + vec![working_dir.to_path_buf()] + }; + + for component in components { + if !component.iter().any(|piece| { + matches!(piece, PatternPiece::Pattern(_)) + && requires_expansion(piece.as_str(), self.enable_extended_globbing) + }) { + let flattened = component + .iter() + .map(|piece| piece.as_str()) + .collect::(); + paths_so_far.retain_mut(|p| { + sys::fs::push_path_for_pattern(p, &flattened); + + // Drop candidates that don't name an existing directory entry. + // We use `symlink_metadata` (lstat) rather than `exists` (stat) so + // that we match on directory-entry existence -- consistent with the + // `read_dir`-based matching used for glob components below, and with + // bash. In particular this keeps dangling symlinks (which bash + // includes) while still rejecting a literal like `file/` whose + // trailing slash makes lstat fail with ENOTDIR for a regular file. + p.symlink_metadata().is_ok() + }); + continue; + } + + let current_paths = std::mem::take(&mut paths_so_far); + for current_path in current_paths { + let subpattern = Self::from(&component) + .set_extended_globbing(self.enable_extended_globbing) + .set_case_insensitive(self.case_insensitive); + + let subpattern_starts_with_dot = subpattern + .pieces + .first() + .is_some_and(|piece| piece.as_str().starts_with('.')); + + let allow_dot_files = !options.require_dot_in_pattern_to_match_dot_files + || subpattern_starts_with_dot; + + let matches_dotfile_policy = |dir_entry: &std::fs::DirEntry| { + !dir_entry.file_name().to_string_lossy().starts_with('.') || allow_dot_files + }; + + let regex = subpattern.to_regex(true, true)?; + let matches_regex = |dir_entry: &std::fs::DirEntry| { + regex + .is_match(dir_entry.file_name().to_string_lossy().as_ref()) + .unwrap_or(false) + }; + + let mut matching_paths_in_dir: Vec<_> = current_path + .read_dir() + .map_or_else(|_| vec![], |dir| dir.into_iter().collect()) + .into_iter() + .filter_map(|result| result.ok()) + .filter(matches_regex) + .filter(matches_dotfile_policy) + .map(|entry| entry.path()) + .collect(); + + matching_paths_in_dir.sort(); + + paths_so_far.append(&mut matching_paths_in_dir); + } + } + + let results: Vec<_> = paths_so_far + .into_iter() + .filter_map(|path| { + if let Some(filter) = path_filter + && !filter(path.as_path()) + { + return None; + } + + // Normalize separators *before* stripping the working-dir + // prefix so that `prefix_to_remove` (already normalized to + // use `/`) matches paths that may contain a mix of `\` and + // `/` on Windows. + let path_str = path.to_string_lossy(); + let normalized = sys::fs::normalize_path_separators(&path_str); + let mut path_ref: &str = normalized.as_ref(); + + if let Some(prefix_to_remove) = &prefix_to_remove + && let Some(stripped) = path_ref.strip_prefix(prefix_to_remove.as_str()) + { + path_ref = stripped; + } + + Some(path_ref.to_string()) + }) + .collect(); + + tracing::debug!(target: trace_categories::PATTERN, " => results: {results:?}"); + + Ok(PatternExpansionResult::Expanded(results)) + } + + /// Converts the pattern to a regular expression string. + /// + /// # Arguments + /// + /// * `strict_prefix_match` - Whether or not the pattern should strictly match the beginning of + /// the string. + /// * `strict_suffix_match` - Whether or not the pattern should strictly match the end of the + /// string. + pub(crate) fn to_regex_str( + &self, + strict_prefix_match: bool, + strict_suffix_match: bool, + ) -> Result { + let mut regex_str = String::new(); + + if strict_prefix_match { + regex_str.push('^'); + } + + let mut current_pattern = String::new(); + for piece in &self.pieces { + match piece { + PatternPiece::Pattern(s) => { + current_pattern.push_str(s); + } + PatternPiece::Literal(s) => { + for c in s.chars() { + if crate::regex::regex_char_is_special(c) { + current_pattern.push('\\'); + } + current_pattern.push(c); + } + } + } + } + + let regex_piece = + pattern_to_regex_str(current_pattern.as_str(), self.enable_extended_globbing)?; + regex_str.push_str(regex_piece.as_str()); + + if strict_suffix_match { + regex_str.push('$'); + } + + Ok(regex_str) + } + + /// Converts the pattern to a regular expression. + /// + /// # Arguments + /// + /// * `strict_prefix_match` - Whether or not the pattern should strictly match the beginning of + /// the string. + /// * `strict_suffix_match` - Whether or not the pattern should strictly match the end of the + /// string. + pub(crate) fn to_regex( + &self, + strict_prefix_match: bool, + strict_suffix_match: bool, + ) -> Result { + let regex_str = self.to_regex_str(strict_prefix_match, strict_suffix_match)?; + + tracing::debug!(target: trace_categories::PATTERN, "pattern: '{self:?}' => regex: '{regex_str}'"); + + let re = regex::compile_regex(regex_str, self.case_insensitive, self.multiline)?; + Ok(re) + } + + /// Checks if the pattern exactly matches the given string. An error result + /// is returned if the pattern is found to be invalid or malformed + /// during processing. + /// + /// # Arguments + /// + /// * `value` - The string to check for a match. + pub fn exactly_matches(&self, value: &str) -> Result { + let re = self.to_regex(true, true)?; + Ok(re.is_match(value)?) + } +} + +/// Checks whether a string contains glob metacharacters that would trigger +/// pathname expansion. Delegates to the pattern parser's grammar, which is +/// the single source of truth for what constitutes a glob metacharacter. +fn requires_expansion(s: &str, enable_extended_globbing: bool) -> bool { + brush_parser::pattern::pattern_has_glob_metacharacters(s, enable_extended_globbing) +} + +fn pattern_to_regex_str( + pattern: &str, + enable_extended_globbing: bool, +) -> Result { + Ok(brush_parser::pattern::pattern_to_regex_str( + pattern, + enable_extended_globbing, + )?) +} + +/// Removes the largest matching prefix from a string that matches the given pattern. +/// +/// # Arguments +/// +/// * `s` - The string to remove the prefix from. +/// * `pattern` - The pattern to match. +pub(crate) fn remove_largest_matching_prefix<'a>( + s: &'a str, + pattern: Option<&Pattern>, +) -> Result<&'a str, error::Error> { + if let Some(pattern) = pattern { + let re = pattern.to_regex(true, true)?; + let indices = s.char_indices().rev(); + let mut last_idx = s.len(); + + #[allow( + clippy::string_slice, + reason = "because we get the indices from char_indices()" + )] + for (idx, _) in indices { + let prefix = &s[0..last_idx]; + if re.is_match(prefix)? { + return Ok(&s[last_idx..]); + } + + last_idx = idx; + } + } + Ok(s) +} + +/// Removes the smallest matching prefix from a string that matches the given pattern. +/// +/// # Arguments +/// +/// * `s` - The string to remove the prefix from. +/// * `pattern` - The pattern to match. +pub(crate) fn remove_smallest_matching_prefix<'a>( + s: &'a str, + pattern: Option<&Pattern>, +) -> Result<&'a str, error::Error> { + if let Some(pattern) = pattern { + let re = pattern.to_regex(true, true)?; + let mut indices = s.char_indices(); + + #[allow( + clippy::string_slice, + reason = "because we get the indices from char_indices()" + )] + while indices.next().is_some() { + let next_index = indices.offset(); + let prefix = &s[0..next_index]; + if re.is_match(prefix)? { + return Ok(&s[next_index..]); + } + } + } + Ok(s) +} + +/// Removes the largest matching suffix from a string that matches the given pattern. +/// +/// # Arguments +/// +/// * `s` - The string to remove the suffix from. +/// * `pattern` - The pattern to match. +pub(crate) fn remove_largest_matching_suffix<'a>( + s: &'a str, + pattern: Option<&Pattern>, +) -> Result<&'a str, error::Error> { + if let Some(pattern) = pattern { + let re = pattern.to_regex(true, true)?; + #[allow( + clippy::string_slice, + reason = "because we get the indices from char_indices()" + )] + for (idx, _) in s.char_indices() { + let suffix = &s[idx..]; + if re.is_match(suffix)? { + return Ok(&s[..idx]); + } + } + } + Ok(s) +} + +/// Removes the smallest matching suffix from a string that matches the given pattern. +/// +/// # Arguments +/// +/// * `s` - The string to remove the suffix from. +/// * `pattern` - The pattern to match. +pub(crate) fn remove_smallest_matching_suffix<'a>( + s: &'a str, + pattern: Option<&Pattern>, +) -> Result<&'a str, error::Error> { + if let Some(pattern) = pattern { + let re = pattern.to_regex(true, true)?; + #[allow( + clippy::string_slice, + reason = "because we get the indices from char_indices()" + )] + for (idx, _) in s.char_indices().rev() { + let suffix = &s[idx..]; + if re.is_match(suffix)? { + return Ok(&s[..idx]); + } + } + } + Ok(s) +} + +#[cfg(test)] +#[expect(clippy::panic_in_result_fn)] +mod tests { + use super::*; + use anyhow::Result; + + fn pattern_to_exact_regex_str

(pattern: P) -> Result + where + P: Into, + { + let pattern: Pattern = pattern + .into() + .set_extended_globbing(true) + .set_multiline(false); + + pattern.to_regex_str(true, true) + } + + #[test] + fn test_pattern_translation() -> Result<()> { + assert_eq!(pattern_to_exact_regex_str("a")?.as_str(), "^a$"); + assert_eq!(pattern_to_exact_regex_str("a*")?.as_str(), "^a.*$"); + assert_eq!(pattern_to_exact_regex_str("a?")?.as_str(), "^a.$"); + assert_eq!(pattern_to_exact_regex_str("a@(b|c)")?.as_str(), "^a(b|c)$"); + assert_eq!(pattern_to_exact_regex_str("a?(b|c)")?.as_str(), "^a(b|c)?$"); + assert_eq!( + pattern_to_exact_regex_str("a*(ab|ac)")?.as_str(), + "^a(ab|ac)*$" + ); + assert_eq!( + pattern_to_exact_regex_str("a+(ab|ac)")?.as_str(), + "^a(ab|ac)+$" + ); + assert_eq!(pattern_to_exact_regex_str("[ab]")?.as_str(), "^[ab]$"); + assert_eq!(pattern_to_exact_regex_str("[ab]*")?.as_str(), "^[ab].*$"); + assert_eq!( + pattern_to_exact_regex_str("[<{().[]*")?.as_str(), + r"^[<{().\[].*$" + ); + assert_eq!(pattern_to_exact_regex_str("[a-d]")?.as_str(), "^[a-d]$"); + assert_eq!(pattern_to_exact_regex_str(r"\*")?.as_str(), r"^\*$"); + + Ok(()) + } + + #[test] + fn test_pattern_word_translation() -> Result<()> { + assert_eq!( + pattern_to_exact_regex_str(vec![PatternPiece::Pattern("a*".to_owned())])?.as_str(), + "^a.*$" + ); + assert_eq!( + pattern_to_exact_regex_str(vec![ + PatternPiece::Pattern("a*".to_owned()), + PatternPiece::Literal("b".to_owned()), + ])? + .as_str(), + "^a.*b$" + ); + assert_eq!( + pattern_to_exact_regex_str(vec![ + PatternPiece::Literal("a*".to_owned()), + PatternPiece::Pattern("b".to_owned()), + ])? + .as_str(), + r"^a\*b$" + ); + + Ok(()) + } + + #[test] + fn test_remove_largest_matching_prefix() -> Result<()> { + assert_eq!( + remove_largest_matching_prefix("ooof", Some(&Pattern::from("")))?, + "ooof" + ); + assert_eq!( + remove_largest_matching_prefix("ooof", Some(&Pattern::from("x")))?, + "ooof" + ); + assert_eq!( + remove_largest_matching_prefix("ooof", Some(&Pattern::from("o")))?, + "oof" + ); + assert_eq!( + remove_largest_matching_prefix("ooof", Some(&Pattern::from("o*o")))?, + "f" + ); + assert_eq!( + remove_largest_matching_prefix("ooof", Some(&Pattern::from("o*")))?, + "" + ); + assert_eq!( + remove_largest_matching_prefix("🚀🚀🚀rocket", Some(&Pattern::from("🚀")))?, + "🚀🚀rocket" + ); + Ok(()) + } + + #[test] + fn test_remove_smallest_matching_prefix() -> Result<()> { + assert_eq!( + remove_smallest_matching_prefix("ooof", Some(&Pattern::from("")))?, + "ooof" + ); + assert_eq!( + remove_smallest_matching_prefix("ooof", Some(&Pattern::from("x")))?, + "ooof" + ); + assert_eq!( + remove_smallest_matching_prefix("ooof", Some(&Pattern::from("o")))?, + "oof" + ); + assert_eq!( + remove_smallest_matching_prefix("ooof", Some(&Pattern::from("o*o")))?, + "of" + ); + assert_eq!( + remove_smallest_matching_prefix("ooof", Some(&Pattern::from("o*")))?, + "oof" + ); + assert_eq!( + remove_smallest_matching_prefix("ooof", Some(&Pattern::from("ooof")))?, + "" + ); + assert_eq!( + remove_smallest_matching_prefix("🚀🚀🚀rocket", Some(&Pattern::from("🚀")))?, + "🚀🚀rocket" + ); + Ok(()) + } + + #[test] + fn test_remove_largest_matching_suffix() -> Result<()> { + assert_eq!( + remove_largest_matching_suffix("foo", Some(&Pattern::from("")))?, + "foo" + ); + assert_eq!( + remove_largest_matching_suffix("foo", Some(&Pattern::from("x")))?, + "foo" + ); + assert_eq!( + remove_largest_matching_suffix("foo", Some(&Pattern::from("o")))?, + "fo" + ); + assert_eq!( + remove_largest_matching_suffix("foo", Some(&Pattern::from("o*")))?, + "f" + ); + assert_eq!( + remove_largest_matching_suffix("foo", Some(&Pattern::from("foo")))?, + "" + ); + assert_eq!( + remove_largest_matching_suffix("rocket🚀🚀🚀", Some(&Pattern::from("🚀")))?, + "rocket🚀🚀" + ); + Ok(()) + } + + #[test] + fn test_remove_smallest_matching_suffix() -> Result<()> { + assert_eq!( + remove_smallest_matching_suffix("fooo", Some(&Pattern::from("")))?, + "fooo" + ); + assert_eq!( + remove_smallest_matching_suffix("fooo", Some(&Pattern::from("x")))?, + "fooo" + ); + assert_eq!( + remove_smallest_matching_suffix("fooo", Some(&Pattern::from("o")))?, + "foo" + ); + assert_eq!( + remove_smallest_matching_suffix("fooo", Some(&Pattern::from("o*o")))?, + "fo" + ); + assert_eq!( + remove_smallest_matching_suffix("fooo", Some(&Pattern::from("o*")))?, + "foo" + ); + assert_eq!( + remove_smallest_matching_suffix("fooo", Some(&Pattern::from("fooo")))?, + "" + ); + assert_eq!( + remove_smallest_matching_suffix("rocket🚀🚀🚀", Some(&Pattern::from("🚀")))?, + "rocket🚀🚀" + ); + Ok(()) + } + + #[test] + #[expect(clippy::cognitive_complexity)] + fn test_matching() -> Result<()> { + assert!(Pattern::from("abc").exactly_matches("abc")?); + + assert!(!Pattern::from("abc").exactly_matches("ABC")?); + assert!(!Pattern::from("abc").exactly_matches("xabcx")?); + assert!(!Pattern::from("abc").exactly_matches("")?); + assert!(!Pattern::from("abc").exactly_matches("abcd")?); + assert!(!Pattern::from("abc").exactly_matches("def")?); + + assert!(Pattern::from("*").exactly_matches("")?); + assert!(Pattern::from("*").exactly_matches("abc")?); + assert!(Pattern::from("*").exactly_matches(" ")?); + + assert!(Pattern::from("a*").exactly_matches("a")?); + assert!(Pattern::from("a*").exactly_matches("ab")?); + assert!(Pattern::from("a*").exactly_matches("a ")?); + + assert!(!Pattern::from("a*").exactly_matches("A")?); + assert!(!Pattern::from("a*").exactly_matches("")?); + assert!(!Pattern::from("a*").exactly_matches("bc")?); + assert!(!Pattern::from("a*").exactly_matches("xax")?); + assert!(!Pattern::from("a*").exactly_matches(" a")?); + + assert!(Pattern::from("*a").exactly_matches("a")?); + assert!(Pattern::from("*a").exactly_matches("ba")?); + assert!(Pattern::from("*a").exactly_matches("aa")?); + assert!(Pattern::from("*a").exactly_matches(" a")?); + + assert!(!Pattern::from("*a").exactly_matches("BA")?); + assert!(!Pattern::from("*a").exactly_matches("")?); + assert!(!Pattern::from("*a").exactly_matches("ab")?); + assert!(!Pattern::from("*a").exactly_matches("xax")?); + + Ok(()) + } + + fn make_extglob(s: &str) -> Pattern { + let pattern = Pattern::from(s).set_extended_globbing(true); + let regex_str = pattern.to_regex_str(true, true).unwrap(); + eprintln!("pattern: '{s}' => regex: '{regex_str}'"); + + pattern + } + + #[test] + fn test_extglob_or_matching() -> Result<()> { + assert!(make_extglob("@(a|b)").exactly_matches("a")?); + assert!(make_extglob("@(a|b)").exactly_matches("b")?); + + assert!(!make_extglob("@(a|b)").exactly_matches("")?); + assert!(!make_extglob("@(a|b)").exactly_matches("c")?); + assert!(!make_extglob("@(a|b)").exactly_matches("ab")?); + + assert!(!make_extglob("@(a|b)").exactly_matches("")?); + assert!(make_extglob("@(a*b|b)").exactly_matches("ab")?); + assert!(make_extglob("@(a*b|b)").exactly_matches("axb")?); + assert!(make_extglob("@(a*b|b)").exactly_matches("b")?); + + assert!(!make_extglob("@(a*b|b)").exactly_matches("a")?); + + Ok(()) + } + + #[test] + fn test_extglob_not_matching() -> Result<()> { + // Basic cases. + assert!(make_extglob("!(a)").exactly_matches("")?); + assert!(make_extglob("!(a)").exactly_matches(" ")?); + assert!(make_extglob("!(a)").exactly_matches("x")?); + assert!(make_extglob("!(a)").exactly_matches(" a ")?); + assert!(make_extglob("!(a)").exactly_matches("a ")?); + assert!(make_extglob("!(a)").exactly_matches("aa")?); + assert!(!make_extglob("!(a)").exactly_matches("a")?); + + assert!(make_extglob("a!(a)a").exactly_matches("aa")?); + assert!(make_extglob("a!(a)a").exactly_matches("aaaa")?); + assert!(make_extglob("a!(a)a").exactly_matches("aba")?); + assert!(!make_extglob("a!(a)a").exactly_matches("a")?); + assert!(!make_extglob("a!(a)a").exactly_matches("aaa")?); + assert!(!make_extglob("a!(a)a").exactly_matches("baaa")?); + + // Alternates. + assert!(make_extglob("!(a|b)").exactly_matches("c")?); + assert!(make_extglob("!(a|b)").exactly_matches("ab")?); + assert!(make_extglob("!(a|b)").exactly_matches("aa")?); + assert!(make_extglob("!(a|b)").exactly_matches("bb")?); + assert!(!make_extglob("!(a|b)").exactly_matches("a")?); + assert!(!make_extglob("!(a|b)").exactly_matches("b")?); + + Ok(()) + } + + #[test] + fn test_extglob_advanced_not_matching() -> Result<()> { + assert!(make_extglob("!(a*)").exactly_matches("b")?); + assert!(make_extglob("!(a*)").exactly_matches("")?); + assert!(!make_extglob("!(a*)").exactly_matches("a")?); + assert!(!make_extglob("!(a*)").exactly_matches("abc")?); + assert!(!make_extglob("!(a*)").exactly_matches("aabc")?); + + Ok(()) + } + + #[test] + fn test_extglob_not_degenerate_matching() -> Result<()> { + // Degenerate case. + assert!(make_extglob("!()").exactly_matches("a")?); + assert!(!make_extglob("!()").exactly_matches("")?); + + Ok(()) + } + + #[test] + fn test_extglob_zero_or_more_matching() -> Result<()> { + assert!(make_extglob("x*(a)x").exactly_matches("xx")?); + assert!(make_extglob("x*(a)x").exactly_matches("xax")?); + assert!(make_extglob("x*(a)x").exactly_matches("xaax")?); + + assert!(!make_extglob("x*(a)x").exactly_matches("x")?); + assert!(!make_extglob("x*(a)x").exactly_matches("xa")?); + assert!(!make_extglob("x*(a)x").exactly_matches("xxx")?); + + assert!(make_extglob("*(a|b)").exactly_matches("")?); + assert!(make_extglob("*(a|b)").exactly_matches("a")?); + assert!(make_extglob("*(a|b)").exactly_matches("b")?); + assert!(make_extglob("*(a|b)").exactly_matches("aba")?); + assert!(make_extglob("*(a|b)").exactly_matches("aaa")?); + + assert!(!make_extglob("*(a|b)").exactly_matches("c")?); + assert!(!make_extglob("*(a|b)").exactly_matches("ca")?); + + Ok(()) + } + + #[test] + fn test_extglob_one_or_more_matching() -> Result<()> { + fn make_extglob(s: &str) -> Pattern { + Pattern::from(s).set_extended_globbing(true) + } + + assert!(make_extglob("x+(a)x").exactly_matches("xax")?); + assert!(make_extglob("x+(a)x").exactly_matches("xaax")?); + + assert!(!make_extglob("x+(a)x").exactly_matches("xx")?); + assert!(!make_extglob("x+(a)x").exactly_matches("x")?); + assert!(!make_extglob("x+(a)x").exactly_matches("xa")?); + assert!(!make_extglob("x+(a)x").exactly_matches("xxx")?); + + assert!(make_extglob("+(a|b)").exactly_matches("a")?); + assert!(make_extglob("+(a|b)").exactly_matches("b")?); + assert!(make_extglob("+(a|b)").exactly_matches("aba")?); + assert!(make_extglob("+(a|b)").exactly_matches("aaa")?); + + assert!(!make_extglob("+(a|b)").exactly_matches("")?); + assert!(!make_extglob("+(a|b)").exactly_matches("c")?); + assert!(!make_extglob("+(a|b)").exactly_matches("ca")?); + + assert!(make_extglob("+(x+(ab)y)").exactly_matches("xaby")?); + assert!(make_extglob("+(x+(ab)y)").exactly_matches("xababy")?); + assert!(make_extglob("+(x+(ab)y)").exactly_matches("xabababy")?); + assert!(make_extglob("+(x+(ab)y)").exactly_matches("xabababyxabababyxabababy")?); + + assert!(!make_extglob("+(x+(ab)y)").exactly_matches("xy")?); + assert!(!make_extglob("+(x+(ab)y)").exactly_matches("xay")?); + assert!(!make_extglob("+(x+(ab)y)").exactly_matches("xyxy")?); + + Ok(()) + } + + #[test] + fn test_requires_expansion() { + // Delegates to the PEG grammar; thorough coverage is in brush-parser. + // Here we just verify the integration works. + assert!(requires_expansion("*", false)); + assert!(requires_expansion("[abc]", false)); + assert!(!requires_expansion("]", false)); + assert!(!requires_expansion("hello", false)); + assert!(!requires_expansion("@(a)", false)); + assert!(requires_expansion("@(a)", true)); + } + + /// Extracts the `Expanded` payload from a `PatternExpansionResult`, + /// failing the test via `anyhow::bail!` otherwise. Avoids `panic!` which + /// is forbidden by the workspace clippy config. + fn expect_expanded(result: PatternExpansionResult) -> Result> { + let PatternExpansionResult::Expanded(paths) = result else { + anyhow::bail!("expected Expanded, got {result:?}"); + }; + Ok(paths) + } + + /// Regression test for the Windows prefix-strip mismatch fix. + /// + /// On Windows (pre-fix), `expand` would build the `prefix_to_remove` + /// using the platform `MAIN_SEPARATOR` (`\`) while `push_path_for_pattern` + /// appended components with `/`. The resulting mismatch made the + /// `strip_prefix` call a no-op, so relative globs produced absolute + /// paths. This test exercises the expansion path and verifies results + /// are returned relative to the working directory. + /// + /// On Unix, the same code path is exercised (both builds go through the + /// shared `normalize_path_separators` helpers), so this test serves as a + /// regression guard on both platforms. + #[test] + fn test_relative_glob_returns_relative_paths() -> Result<()> { + let scratch = tempfile::tempdir()?; + let sub = scratch.path().join("sub"); + std::fs::create_dir_all(&sub)?; + std::fs::write(sub.join("a.txt"), "")?; + std::fs::write(sub.join("b.txt"), "")?; + + let pattern = Pattern::from("sub/*.txt").set_extended_globbing(false); + let result = pattern.expand:: bool>( + scratch.path(), + None, + &FilenameExpansionOptions::default(), + )?; + + let paths = expect_expanded(result)?; + + let mut sorted = paths.clone(); + sorted.sort(); + assert_eq!( + sorted, + vec!["sub/a.txt".to_string(), "sub/b.txt".to_string()] + ); + + // None of the results should contain the absolute scratch path. + let scratch_str: String = scratch.path().to_string_lossy().into_owned(); + for p in &paths { + assert!( + !p.contains(scratch_str.as_str()), + "result {p:?} still contains absolute working-dir prefix {scratch_str:?}" + ); + } + + Ok(()) + } + + /// Verifies absolute-pattern expansion still works after the prefix + /// handling changes. + #[test] + fn test_absolute_glob_returns_absolute_paths() -> Result<()> { + let scratch = tempfile::tempdir()?; + std::fs::write(scratch.path().join("one.log"), "")?; + std::fs::write(scratch.path().join("two.log"), "")?; + + let abs_pattern = format!("{}/*.log", scratch.path().to_string_lossy()); + // Normalize to forward slashes so the test works consistently across + // platforms; the expander's `pattern_path_root` handles both. + let abs_pattern = abs_pattern.replace('\\', "/"); + + let pattern = Pattern::from(abs_pattern.as_str()).set_extended_globbing(false); + let result = pattern.expand:: bool>( + Path::new("/"), + None, + &FilenameExpansionOptions::default(), + )?; + + let paths = expect_expanded(result)?; + + assert_eq!(paths.len(), 2, "unexpected results: {paths:?}"); + let scratch_normalized: String = scratch.path().to_string_lossy().replace('\\', "/"); + for p in &paths { + // Use a plain byte-level suffix check rather than `Path::extension` + // since the results are strings and clippy flags `ends_with(".log")` + // as potentially case-sensitive. We explicitly wrote lowercase files. + assert!(p.as_bytes().ends_with(b".log"), "unexpected result {p:?}"); + // Should still reference the scratch directory (i.e., absolute). + assert!( + p.contains(scratch_normalized.as_str()), + "absolute result {p:?} should contain {scratch_normalized:?}" + ); + } + + Ok(()) + } +} diff --git a/local/recipes/shells/brush/source/brush-core/src/processes.rs b/local/recipes/shells/brush/source/brush-core/src/processes.rs new file mode 100644 index 0000000000..4b5535632d --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/processes.rs @@ -0,0 +1,90 @@ +//! Process management + +use futures::FutureExt; + +use crate::{error, sys}; + +/// A waitable future that will yield the results of a child process's execution. +pub(crate) type WaitableChildProcess = std::pin::Pin< + Box> + Send + Sync>, +>; + +/// Tracks a child process being awaited. +pub struct ChildProcess { + /// A waitable future that will yield the results of a child process's execution. + exec_future: WaitableChildProcess, + /// If available, the process ID of the child. + pid: Option, + /// If available, the process group ID of the child. + pgid: Option, +} + +impl ChildProcess { + /// Wraps a child process and its future. + pub fn new( + child: sys::process::Child, + pid: Option, + pgid: Option, + ) -> Self { + Self { + exec_future: Box::pin(child.wait_with_output()), + pid, + pgid, + } + } + + /// Returns the process's ID. + pub const fn pid(&self) -> Option { + self.pid + } + + /// Returns the process's group ID. + pub const fn pgid(&self) -> Option { + self.pgid + } + + /// Waits for the process to exit. + pub async fn wait(&mut self) -> Result { + #[allow(unused_mut, reason = "only mutated on some platforms")] + let mut sigtstp = sys::signal::tstp_signal_listener()?; + #[allow(unused_mut, reason = "only mutated on some platforms")] + let mut sigchld = sys::signal::chld_signal_listener()?; + + #[allow(clippy::ignored_unit_patterns)] + loop { + tokio::select! { + output = &mut self.exec_future => { + break Ok(ProcessWaitResult::Completed(output?)) + }, + _ = sigtstp.recv() => { + break Ok(ProcessWaitResult::Stopped) + }, + _ = sigchld.recv() => { + if sys::signal::poll_for_stopped_children()? { + break Ok(ProcessWaitResult::Stopped); + } + }, + _ = sys::signal::await_ctrl_c() => { + // SIGINT got thrown. Handle it and continue looping. The child should + // have received it as well, and either handled it or ended up getting + // terminated (in which case we'll see the child exit). + }, + } + } + } + + pub(crate) fn poll(&mut self) -> Option> { + let checkable_future = &mut self.exec_future; + checkable_future + .now_or_never() + .map(|result| result.map_err(Into::into)) + } +} + +/// Represents the result of waiting for an executing process. +pub enum ProcessWaitResult { + /// The process completed. + Completed(std::process::Output), + /// The process stopped and has not yet completed. + Stopped, +} diff --git a/local/recipes/shells/brush/source/brush-core/src/prompt.rs b/local/recipes/shells/brush/source/brush-core/src/prompt.rs new file mode 100644 index 0000000000..919dad2ec4 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/prompt.rs @@ -0,0 +1,287 @@ +use crate::{ + ExecutionParameters, error, expansion, extensions, + shell::Shell, + sys::{self, users}, +}; +use std::path::Path; + +const VERSION_MAJOR: &str = env!("CARGO_PKG_VERSION_MAJOR"); +const VERSION_MINOR: &str = env!("CARGO_PKG_VERSION_MINOR"); +const VERSION_PATCH: &str = env!("CARGO_PKG_VERSION_PATCH"); + +pub(crate) async fn expand_prompt( + shell: &mut Shell, + params: &ExecutionParameters, + spec: String, +) -> Result { + // Parse the prompt spec into its pieces. + let prompt_pieces = parse_prompt(spec)?; + + // Now, render each piece. + let mut formatted_prompt = String::new(); + for piece in prompt_pieces { + // Pieces that semantically represent a literal char (e.g. user wrote + // `\$` meaning a literal `$`, not input for pass-2 expansion). We + // prepend a `\` so pass 2 consumes it and leaves the char alone. + let semantically_literal = + matches!(piece, brush_parser::prompt::PromptPiece::DollarOrPound); + + let formatted_piece = format_prompt_piece(shell, piece)?; + + // Only useful when pass 2 actually consumes a `\` for that leading + // byte; otherwise the `\` would leak through. + if shell.options().expand_prompt_strings + && semantically_literal + && formatted_piece.starts_with(expansion::DOUBLE_QUOTED_ESCAPE_CHARS) + { + formatted_prompt.push('\\'); + } + + formatted_prompt.push_str(&formatted_piece); + } + + if shell.options().expand_prompt_strings { + // Now expand any remaining escape sequences, but without tilde-expansion. + // Use double-quote escape rules so that backslashes emitted in the + // previous step survive intact unless they precede a character that + // would also be escapable inside a double-quoted string. + let options = expansion::ExpanderOptions { + tilde_expand: false, + unquoted_backslash_handling: expansion::UnquotedBackslashHandling::DoubleQuoted, + ..Default::default() + }; + formatted_prompt = + expansion::basic_expand_word_with_options(shell, params, &formatted_prompt, &options) + .await?; + } + + Ok(formatted_prompt) +} + +#[cached::proc_macro::cached(size = 64, result = true)] +fn parse_prompt( + spec: String, +) -> Result, brush_parser::WordParseError> { + brush_parser::prompt::parse(spec.as_str()) +} + +fn format_prompt_piece( + shell: &Shell, + piece: brush_parser::prompt::PromptPiece, +) -> Result { + let formatted = match piece { + brush_parser::prompt::PromptPiece::EscapedSequence(s) => s, + brush_parser::prompt::PromptPiece::Literal(l) => l, + brush_parser::prompt::PromptPiece::AsciiCharacter(c) => { + char::from_u32(c).map_or_else(String::new, |c| c.to_string()) + } + brush_parser::prompt::PromptPiece::Backslash => "\\".to_owned(), + brush_parser::prompt::PromptPiece::BellCharacter => "\x07".to_owned(), + brush_parser::prompt::PromptPiece::CarriageReturn => "\r".to_owned(), + brush_parser::prompt::PromptPiece::CurrentCommandNumber => { + return error::unimp("prompt: current command number"); + } + brush_parser::prompt::PromptPiece::CurrentHistoryNumber => { + return error::unimp("prompt: current history number"); + } + brush_parser::prompt::PromptPiece::CurrentUser => users::get_current_username()?, + brush_parser::prompt::PromptPiece::CurrentWorkingDirectory { + tilde_replaced, + basename, + } => format_current_working_directory(shell, tilde_replaced, basename), + brush_parser::prompt::PromptPiece::Date(format) => { + format_date(&chrono::Local::now(), &format) + } + brush_parser::prompt::PromptPiece::DollarOrPound => { + if users::is_root() { + "#".to_owned() + } else { + "$".to_owned() + } + } + // NOTE: We mimic bash and convert \[ into \001, a.k.a. RL_PROMPT_START_IGNORE. + // It will need to get removed before it's actually displayed. While present it + // also has the important (compatible) side effect of ensuring the text on either + // side of it is not concatenated together, potentially resulting in incompatible + // variable expansions. Also, we *only* do this if the shell is interactive. + brush_parser::prompt::PromptPiece::EndNonPrintingSequence => { + if shell.options().interactive { + "\x02".to_owned() + } else { + String::new() + } + } + brush_parser::prompt::PromptPiece::EscapeCharacter => "\x1b".to_owned(), + brush_parser::prompt::PromptPiece::Hostname { + only_up_to_first_dot, + } => { + let hn = sys::network::get_hostname() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + if only_up_to_first_dot && let Some((first, _)) = hn.split_once('.') { + return Ok(first.to_owned()); + } + hn + } + brush_parser::prompt::PromptPiece::Newline => "\n".to_owned(), + brush_parser::prompt::PromptPiece::NumberOfManagedJobs => { + shell.jobs().jobs.len().to_string() + } + brush_parser::prompt::PromptPiece::ShellBaseName => { + if let Some(shell_name) = shell.current_shell_name() { + Path::new(shell_name.as_ref()) + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_default() + } else { + String::new() + } + } + brush_parser::prompt::PromptPiece::ShellRelease => { + std::format!("{VERSION_MAJOR}.{VERSION_MINOR}.{VERSION_PATCH}") + } + brush_parser::prompt::PromptPiece::ShellVersion => { + std::format!("{VERSION_MAJOR}.{VERSION_MINOR}") + } + // NOTE: See above note for EndNonPrintingSequence + brush_parser::prompt::PromptPiece::StartNonPrintingSequence => { + if shell.options().interactive { + "\x01".to_owned() + } else { + String::new() + } + } + brush_parser::prompt::PromptPiece::TerminalDeviceBaseName => { + sys::terminal::try_get_terminal_device_path() + .and_then(|p| p.file_name().map(|s| s.to_string_lossy().to_string())) + .unwrap_or_default() + } + brush_parser::prompt::PromptPiece::Time(time_fmt) => { + format_time(&chrono::Local::now(), &time_fmt) + } + }; + + Ok(formatted) +} + +fn format_current_working_directory( + shell: &Shell, + tilde_replaced: bool, + basename: bool, +) -> String { + let mut working_dir_str = shell.working_dir().to_string_lossy().to_string(); + + if tilde_replaced { + working_dir_str = shell.tilde_shorten(working_dir_str); + } + + if basename && let Some(filename) = Path::new(&working_dir_str).file_name() { + working_dir_str = filename.to_string_lossy().to_string(); + } + + if cfg!(windows) { + working_dir_str = working_dir_str.replace('\\', "/"); + } + + working_dir_str +} + +fn format_time( + datetime: &chrono::DateTime, + format: &brush_parser::prompt::PromptTimeFormat, +) -> String +where + Tz::Offset: std::fmt::Display, +{ + let formatted = match format { + brush_parser::prompt::PromptTimeFormat::TwelveHourAM => datetime.format("%I:%M %p"), + brush_parser::prompt::PromptTimeFormat::TwelveHourHHMMSS => datetime.format("%I:%M:%S"), + brush_parser::prompt::PromptTimeFormat::TwentyFourHourHHMM => datetime.format("%H:%M"), + brush_parser::prompt::PromptTimeFormat::TwentyFourHourHHMMSS => datetime.format("%H:%M:%S"), + }; + + formatted.to_string() +} + +fn format_date( + datetime: &chrono::DateTime, + format: &brush_parser::prompt::PromptDateFormat, +) -> String +where + Tz::Offset: std::fmt::Display, +{ + match format { + brush_parser::prompt::PromptDateFormat::WeekdayMonthDate => { + datetime.format("%a %b %d").to_string() + } + brush_parser::prompt::PromptDateFormat::Custom(fmt) => { + let fmt_items = chrono::format::StrftimeItems::new(fmt); + datetime.format_with_items(fmt_items).to_string() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_time() { + // Create a well-known test date/time. + let dt = chrono::DateTime::parse_from_rfc3339("2024-12-25T13:34:56.789Z").unwrap(); + + assert_eq!( + format_time(&dt, &brush_parser::prompt::PromptTimeFormat::TwelveHourAM), + "01:34 PM" + ); + + assert_eq!( + format_time( + &dt, + &brush_parser::prompt::PromptTimeFormat::TwentyFourHourHHMMSS + ), + "13:34:56" + ); + + assert_eq!( + format_time( + &dt, + &brush_parser::prompt::PromptTimeFormat::TwelveHourHHMMSS + ), + "01:34:56" + ); + } + + #[test] + fn test_format_date() { + // Create a well-known test date/time. + let dt = chrono::DateTime::parse_from_rfc3339("2024-12-25T12:34:56.789Z").unwrap(); + + assert_eq!( + format_date( + &dt, + &brush_parser::prompt::PromptDateFormat::WeekdayMonthDate + ), + "Wed Dec 25" + ); + + assert_eq!( + format_date( + &dt, + &brush_parser::prompt::PromptDateFormat::Custom(String::from("%Y-%m-%d")) + ), + "2024-12-25" + ); + + assert_eq!( + format_date( + &dt, + &brush_parser::prompt::PromptDateFormat::Custom(String::from( + "%Y-%m-%d %H:%M:%S.%f" + )) + ), + "2024-12-25 12:34:56.789000000" + ); + } +} diff --git a/local/recipes/shells/brush/source/brush-core/src/regex.rs b/local/recipes/shells/brush/source/brush-core/src/regex.rs new file mode 100644 index 0000000000..1e98372519 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/regex.rs @@ -0,0 +1,220 @@ +#![allow(clippy::needless_pass_by_value)] + +use std::borrow::Cow; +use std::cell::RefCell; + +use crate::error; +use cached::Cached; + +thread_local! { + static REGEX_CACHE: RefCell> = + RefCell::new(cached::SizedCache::with_size(64)); +} + +/// Represents a piece of a regular expression. +#[derive(Clone, Debug)] +pub(crate) enum RegexPiece { + /// A pattern that should be interpreted as a regular expression. + Pattern(String), + /// A literal string that should be matched exactly. + Literal(String), +} + +impl RegexPiece { + fn to_regex_str(&self) -> Cow<'_, str> { + match self { + Self::Pattern(s) => Cow::Borrowed(s.as_str()), + Self::Literal(s) => escape_literal_regex_piece(s.as_str()), + } + } +} + +type RegexWord = Vec; + +/// Encapsulates a regular expression usable in the shell. +#[derive(Clone, Debug)] +pub struct Regex { + pieces: RegexWord, + case_insensitive: bool, + multiline: bool, +} + +impl From for Regex { + fn from(pieces: RegexWord) -> Self { + Self { + pieces, + case_insensitive: false, + multiline: false, + } + } +} + +impl Regex { + /// Sets the regular expression's case sensitivity. + /// + /// # Arguments + /// + /// * `value` - The new case sensitivity value. + pub const fn set_case_insensitive(mut self, value: bool) -> Self { + self.case_insensitive = value; + self + } + + /// Enables (or disables) multiline support for this pattern. + /// This enables matching across lines as well as enables `.` + /// to match newline characters. + /// + /// # Arguments + /// + /// * `value` - The new multiline value. + pub const fn set_multiline(mut self, value: bool) -> Self { + self.multiline = value; + self + } + + /// Computes if the regular expression matches the given string. + /// + /// # Arguments + /// + /// * `value` - The string to check for a match. + pub fn matches(&self, value: &str) -> Result>>, error::Error> { + let regex_pattern: String = self + .pieces + .iter() + .map(|piece| piece.to_regex_str()) + .collect(); + + let re = compile_regex(regex_pattern, self.case_insensitive, self.multiline)?; + + Ok(re.captures(value)?.map(|captures| { + captures + .iter() + .map(|c| c.map(|m| m.as_str().to_owned())) + .collect() + })) + } +} + +pub(crate) fn compile_regex( + regex_str: String, + case_insensitive: bool, + multiline: bool, +) -> Result { + // Move regex_str into the key to avoid cloning on cache-hit path. + let key = (regex_str, case_insensitive, multiline); + + let cached_regex = REGEX_CACHE.with(|cache| cache.borrow_mut().cache_get(&key).cloned()); + if let Some(re) = cached_regex { + return Ok(re); + } + + // Handle identified cases where a shell-supported regex isn't supported directly by + // `fancy_regex` -- specifically, adding missing escape characters. + let mut regex_str = add_missing_escape_chars_to_regex(key.0.as_str()); + + // Handle multiline enablement. + if multiline { + // The fancy_regex crate internally seems to have flags that can be used + // to enable multiline support, but they're not exposed via its + // RegexBuilder. We instead just prefix with the right flags. + let updated_str = std::format!("(?ms){regex_str}"); + regex_str = updated_str.into(); + } + + let mut builder = fancy_regex::RegexBuilder::new(regex_str.as_ref()); + builder.case_insensitive(case_insensitive); + + let re = match builder.build() { + Ok(re) => re, + Err(e) => return Err(error::ErrorKind::InvalidRegexError(e, regex_str.to_string()).into()), + }; + + // Release borrow on key.0 before moving key into cache_set. + drop(regex_str); + + REGEX_CACHE.with(|cache| { + cache.borrow_mut().cache_set(key, re.clone()); + }); + + Ok(re) +} + +fn add_missing_escape_chars_to_regex(s: &str) -> Cow<'_, str> { + // We may see a character class with an unescaped '[' (open bracket) character. We need + // to escape that character. + let mut in_escape = false; + let mut in_brackets = false; + let mut insertion_positions = vec![]; + + let mut peekable = s.char_indices().peekable(); + while let Some((byte_offset, c)) = peekable.next() { + let next_is_colon = peekable.peek().is_some_and(|(_, c)| *c == ':'); + + match c { + '[' if !in_escape && !in_brackets => { + in_brackets = true; + } + '[' if !in_escape && in_brackets && !next_is_colon => { + // Need to escape. + insertion_positions.push(byte_offset); + } + ']' if !in_escape && in_brackets => { + in_brackets = false; + } + _ => (), + } + + in_escape = !in_escape && c == '\\'; + } + + if insertion_positions.is_empty() { + return s.into(); + } + + let mut updated = s.to_owned(); + for pos in insertion_positions.iter().rev() { + updated.insert(*pos, '\\'); + } + + updated.into() +} + +fn escape_literal_regex_piece(s: &str) -> Cow<'_, str> { + let mut result = String::new(); + + for c in s.chars() { + match c { + c if regex_char_is_special(c) => { + result.push('\\'); + result.push(c); + } + c => result.push(c), + } + } + + result.into() +} + +pub(crate) const fn regex_char_is_special(c: char) -> bool { + matches!( + c, + '\\' | '^' | '$' | '.' | '|' | '?' | '*' | '+' | '(' | ')' | '[' | ']' | '{' | '}' + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_add_missing_escape_chars_to_regex() { + // Negative cases -- where we don't need to escape. + assert_eq!(add_missing_escape_chars_to_regex("a[b]"), "a[b]"); + assert_eq!(add_missing_escape_chars_to_regex(r"a\[b\]"), r"a\[b\]"); + assert_eq!(add_missing_escape_chars_to_regex(r"a[b\[]"), r"a[b\[]"); + + // Positive case -- where we need to escape. + assert_eq!(add_missing_escape_chars_to_regex(r"a[b[]"), r"a[b\[]"); + assert_eq!(add_missing_escape_chars_to_regex(r"a[[]"), r"a[\[]"); + } +} diff --git a/local/recipes/shells/brush/source/brush-core/src/results.rs b/local/recipes/shells/brush/source/brush-core/src/results.rs new file mode 100644 index 0000000000..478363b8b9 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/results.rs @@ -0,0 +1,305 @@ +//! Encapsulation of execution results. + +#[cfg(unix)] +use std::os::unix::process::ExitStatusExt; + +use crate::{error, processes}; + +/// Represents the result of executing a command or similar item. +#[derive(Default)] +pub struct ExecutionResult { + /// The control flow transition to apply after execution. + pub next_control_flow: ExecutionControlFlow, + /// The exit code resulting from execution. + pub exit_code: ExecutionExitCode, +} + +impl ExecutionResult { + /// Returns a new `ExecutionResult` with the given exit code. + /// + /// # Arguments + /// + /// * `exit_code` - The exit code of the command. + pub fn new(exit_code: u8) -> Self { + Self { + exit_code: exit_code.into(), + ..Self::default() + } + } + + /// Returns a new `ExecutionResult` reflecting a process that was stopped. + pub fn stopped() -> Self { + // TODO(jobs): Decide how to sort this out in a platform-independent way. + const SIGTSTP: std::os::raw::c_int = 20; + + #[expect(clippy::cast_possible_truncation)] + Self::new(128 + SIGTSTP as u8) + } + + /// Returns a new `ExecutionResult` with an exit code of 0. + pub const fn success() -> Self { + Self { + next_control_flow: ExecutionControlFlow::Normal, + exit_code: ExecutionExitCode::Success, + } + } + + /// Returns a new `ExecutionResult` with a general error exit code. + pub const fn general_error() -> Self { + Self { + next_control_flow: ExecutionControlFlow::Normal, + exit_code: ExecutionExitCode::GeneralError, + } + } + + /// Returns whether the command was successful. + pub const fn is_success(&self) -> bool { + self.exit_code.is_success() + } + + /// Returns whether the execution result indicates normal control flow. + /// Returns `false` if there is any control flow transition requested. + pub const fn is_normal_flow(&self) -> bool { + matches!(self.next_control_flow, ExecutionControlFlow::Normal) + } + + /// Returns whether the execution result indicates a loop break. + pub const fn is_break(&self) -> bool { + matches!( + self.next_control_flow, + ExecutionControlFlow::BreakLoop { .. } + ) + } + + /// Returns whether the execution result indicates a loop continue. + pub const fn is_continue(&self) -> bool { + matches!( + self.next_control_flow, + ExecutionControlFlow::ContinueLoop { .. } + ) + } + + /// Returns whether the execution result indicates an early return + /// from a function or script, or an exit from the shell. Returns `false` + /// otherwise, including loop breaks or continues. + pub const fn is_return_or_exit(&self) -> bool { + matches!( + self.next_control_flow, + ExecutionControlFlow::ReturnFromFunctionOrScript | ExecutionControlFlow::ExitShell + ) + } +} + +impl From for ExecutionResult { + fn from(exit_code: ExecutionExitCode) -> Self { + Self { + next_control_flow: ExecutionControlFlow::Normal, + exit_code, + } + } +} + +impl From for ExecutionResult { + fn from(wait_result: ExecutionWaitResult) -> Self { + match wait_result { + ExecutionWaitResult::Completed(result) => result, + // TODO(jobs): We need to job-manage the stopped process. + ExecutionWaitResult::Stopped(..) => Self::stopped(), + } + } +} + +impl From for ExecutionResult { + fn from(output: std::process::Output) -> Self { + if let Some(code) = output.status.code() { + #[expect(clippy::cast_sign_loss)] + return Self::new((code & 0xFF) as u8); + } + + #[cfg(unix)] + if let Some(signal) = output.status.signal() { + #[expect(clippy::cast_sign_loss)] + return Self::new((signal & 0xFF) as u8 + 128); + } + + tracing::error!("unhandled process exit"); + Self::new(127) + } +} + +/// Represents an exit code from execution. +#[derive(Clone, Copy, Default)] +pub enum ExecutionExitCode { + /// Indicates successful execution. + #[default] + Success, + /// Indicates a general error. + GeneralError, + /// Indicates invalid usage. + InvalidUsage, + /// Cannot execute the command. + CannotExecute, + /// Indicates a command or similar item was not found. + NotFound, + /// Indicates execution was interrupted. + Interrupted, + /// Indicates a broken pipe (SIGPIPE) was encountered. + BrokenPipe, + /// Indicates unimplemented functionality was encountered. + Unimplemented, + /// A custom exit code. + Custom(u8), +} + +impl ExecutionExitCode { + /// Returns whether the exit code indicates success. + pub const fn is_success(&self) -> bool { + matches!(self, Self::Success) + } +} + +impl From for ExecutionExitCode { + fn from(code: u8) -> Self { + match code { + 0 => Self::Success, + 1 => Self::GeneralError, + 2 => Self::InvalidUsage, + 99 => Self::Unimplemented, + 126 => Self::CannotExecute, + 127 => Self::NotFound, + 130 => Self::Interrupted, + 141 => Self::BrokenPipe, + code => Self::Custom(code), + } + } +} + +impl From for u8 { + fn from(code: ExecutionExitCode) -> Self { + Self::from(&code) + } +} + +impl From<&ExecutionExitCode> for u8 { + fn from(code: &ExecutionExitCode) -> Self { + match code { + ExecutionExitCode::Success => 0, + ExecutionExitCode::GeneralError => 1, + ExecutionExitCode::InvalidUsage => 2, + ExecutionExitCode::Unimplemented => 99, + ExecutionExitCode::CannotExecute => 126, + ExecutionExitCode::NotFound => 127, + ExecutionExitCode::Interrupted => 130, + ExecutionExitCode::BrokenPipe => 141, + ExecutionExitCode::Custom(code) => *code, + } + } +} + +/// Represents a control flow transition to apply. +#[derive(Clone, Copy, Default)] +pub enum ExecutionControlFlow { + /// Continue normal execution. + #[default] + Normal, + /// Break out of an enclosing loop. + BreakLoop { + /// Identifies which level of nested loops to break out of. 0 indicates the innermost loop, + /// 1 indicates the next outer loop, and so on. + levels: usize, + }, + /// Continue to the next iteration of an enclosing loop. + ContinueLoop { + /// Identifies which level of nested loops to continue. 0 indicates the innermost loop, + /// 1 indicates the next outer loop, and so on. + levels: usize, + }, + /// Return from the current function or script. + ReturnFromFunctionOrScript, + /// Exit the shell. + ExitShell, +} + +impl ExecutionControlFlow { + /// Attempts to decrement the loop levels for `BreakLoop` or `ContinueLoop`. + /// If the levels reach zero, transitions to `Normal`. If the control flow is not + /// a loop break or continue, no changes are made. + #[must_use] + pub const fn try_decrement_loop_levels(&self) -> Self { + match self { + Self::BreakLoop { levels: 0 } | Self::ContinueLoop { levels: 0 } => Self::Normal, + Self::BreakLoop { levels } => Self::BreakLoop { + levels: *levels - 1, + }, + Self::ContinueLoop { levels } => Self::ContinueLoop { + levels: *levels - 1, + }, + control_flow => *control_flow, + } + } +} + +/// Represents the result of spawning an execution; captures both execution +/// that immediately returns as well as execution that starts a process +/// asynchronously. +pub enum ExecutionSpawnResult { + /// Indicates that the execution completed. + Completed(ExecutionResult), + /// Indicates that a process was started and had not yet completed. + StartedProcess(processes::ChildProcess), + /// Indicates that a task was started to handle the execution asynchronously. + StartedTask(tokio::task::JoinHandle>), +} + +impl From for ExecutionSpawnResult { + fn from(result: ExecutionResult) -> Self { + Self::Completed(result) + } +} + +impl ExecutionSpawnResult { + /// Waits for the command to complete. + pub async fn wait(self) -> Result { + let result = match self { + Self::StartedProcess(mut child) => { + // Wait for the process to exit or for a relevant signal, whichever happens + // first. + match child.wait().await? { + processes::ProcessWaitResult::Completed(output) => { + ExecutionWaitResult::Completed(ExecutionResult::from(output)) + } + processes::ProcessWaitResult::Stopped => ExecutionWaitResult::Stopped(child), + } + } + Self::Completed(result) => ExecutionWaitResult::Completed(result), + Self::StartedTask(join_handle) => { + let result = join_handle.await?; + ExecutionWaitResult::Completed(result?) + } + }; + + Ok(result) + } + + pub(crate) async fn poll(self) -> Result { + let result = match self { + Self::StartedProcess(child) => ExecutionWaitResult::Stopped(child), + Self::Completed(result) => ExecutionWaitResult::Completed(result), + Self::StartedTask(join_handle) => { + // TODO(jobs): This isn't right. + let result = join_handle.await?; + ExecutionWaitResult::Completed(result?) + } + }; + + Ok(result) + } +} + +/// Represents the result of waiting for an execution to complete. +pub enum ExecutionWaitResult { + /// Indicates that the execution completed. + Completed(ExecutionResult), + /// Indicates that the execution was stopped. + Stopped(processes::ChildProcess), +} diff --git a/local/recipes/shells/brush/source/brush-core/src/shell.rs b/local/recipes/shells/brush/source/brush-core/src/shell.rs new file mode 100644 index 0000000000..4969b97d8d --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/shell.rs @@ -0,0 +1,553 @@ +//! Module defining the core shell structure and behavior. + +use std::borrow::Cow; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use tokio::sync::Mutex; + +use crate::{ + ExecutionControlFlow, ExecutionResult, builtins, env::ShellEnvironment, error, extensions, + functions, interfaces, jobs, keywords, openfiles, options::RuntimeOptions, pathcache, + wellknownvars, +}; + +/// Type for storing a key bindings helper. +pub type KeyBindingsHelper = Arc>; + +/// Type alias for shell file descriptors. +pub type ShellFd = i32; + +// NOTE: The submodule files below (e.g., `shell/traps.rs`, `shell/callstack.rs`) contain +// `impl Shell` blocks that provide methods coordinating with types defined in the +// corresponding top-level modules (e.g., `traps.rs`, `callstack.rs`). This is an intentional +// layered architecture: top-level modules define domain types and data structures, while +// shell/ submodules implement Shell methods that operate on those types. + +mod builder; +mod builtin_registry; +mod callstack; +mod completion; +mod env; +mod execution; +mod expansion; +mod fs; +mod funcs; +mod history; +mod initscripts; +mod io; +mod job_control; +mod parsing; +mod prompts; +mod readline; +mod state; +mod traps; + +pub use builder::{CreateOptions, ShellBuilder, ShellBuilderState}; +pub use initscripts::{ProfileLoadBehavior, RcLoadBehavior}; +pub use state::ShellState; + +/// Represents an instance of a shell. +/// +/// # Type Parameters +/// +/// * `SE` - The shell extensions implementation to use. These extensions are statically injected +/// into the shell at compile time to provide custom behavior. When unspecified, defaults to +/// `DefaultShellExtensions`, which provide standard behavior. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Shell { + /// Injected error behavior. + #[cfg_attr(feature = "serde", serde(skip, default = "default_error_formatter"))] + error_formatter: SE::ErrorFormatter, + + /// Trap handler configuration for the shell. + traps: crate::traps::TrapHandlerConfig, + + /// Manages files opened and accessible via redirection operators. + open_files: openfiles::OpenFiles, + + /// The current working directory. + working_dir: PathBuf, + + /// The shell environment, containing shell variables. + env: ShellEnvironment, + + /// Shell function definitions. + funcs: functions::FunctionEnv, + + /// Runtime shell options. + options: RuntimeOptions, + + /// State of managed jobs. + /// TODO(serde): Need to warn somehow that jobs cannot be serialized. + #[cfg_attr(feature = "serde", serde(skip))] + jobs: jobs::JobManager, + + /// Shell aliases. + aliases: HashMap, + + /// The status of the last completed command. + last_exit_status: u8, + + /// Tracks changes to `last_exit_status`. Assignment-only commands observe it + /// to tell whether an expansion set a status (see `interp.rs`), so code that + /// rolls back `$?` must roll this back too. + last_exit_status_change_count: usize, + + /// The status of each of the commands in the last pipeline. + last_pipeline_statuses: Vec, + + /// Clone depth from the original ancestor shell. + depth: usize, + + /// Shell name + name: Option, + + /// Positional shell arguments (not including shell name). + args: Vec, + + /// Shell version + version: Option, + + /// Detailed display string for the shell + product_display_str: Option, + + /// Function/script call stack. + call_stack: crate::callstack::CallStack, + + /// Directory stack used by pushd et al. + directory_stack: Vec, + + /// Completion configuration. + completion_config: crate::completion::Config, + + /// Shell built-in commands. + #[cfg_attr(feature = "serde", serde(skip))] + builtins: HashMap>, + + /// Shell program location cache. + program_location_cache: pathcache::PathCache, + + /// Last "SECONDS" captured time. + last_stopwatch_time: std::time::SystemTime, + + /// Last "SECONDS" offset requested. + last_stopwatch_offset: u32, + + /// Parser implementation to use. + #[cfg_attr(feature = "serde", serde(skip))] + parser_impl: crate::parser::ParserImpl, + + /// Key bindings for the shell, optionally implemented by an interactive shell. + #[cfg_attr(feature = "serde", serde(skip))] + key_bindings: Option, + + /// History of commands executed in the shell. + history: Option, +} + +impl Clone for Shell { + fn clone(&self) -> Self { + Self { + error_formatter: self.error_formatter.clone(), + traps: self.traps.clone(), + open_files: self.open_files.clone(), + working_dir: self.working_dir.clone(), + env: self.env.clone(), + funcs: self.funcs.clone(), + options: self.options.clone(), + jobs: jobs::JobManager::new(), + aliases: self.aliases.clone(), + last_exit_status: self.last_exit_status, + last_exit_status_change_count: self.last_exit_status_change_count, + last_pipeline_statuses: self.last_pipeline_statuses.clone(), + name: self.name.clone(), + args: self.args.clone(), + version: self.version.clone(), + product_display_str: self.product_display_str.clone(), + call_stack: { + // Subshells must not inherit the parent's "currently handling signal X" + // state; otherwise a trap handler that spawns a subshell would see itself + // as already inside that handler and skip re-entrant delivery. + let mut cs = self.call_stack.clone(); + cs.clear_active_trap_signals(); + cs + }, + directory_stack: self.directory_stack.clone(), + completion_config: self.completion_config.clone(), + builtins: self.builtins.clone(), + program_location_cache: self.program_location_cache.clone(), + last_stopwatch_time: self.last_stopwatch_time, + last_stopwatch_offset: self.last_stopwatch_offset, + parser_impl: self.parser_impl, + key_bindings: self.key_bindings.clone(), + history: self.history.clone(), + depth: self.depth + 1, + } + } +} + +impl AsRef for Shell { + fn as_ref(&self) -> &Self { + self + } +} + +impl AsMut for Shell { + fn as_mut(&mut self) -> &mut Self { + self + } +} + +impl Shell { + /// Returns a new shell instance created with the given options. + /// Does *not* load any configuration files (e.g., bashrc). + /// + /// # Arguments + /// + /// * `options` - The options to use when creating the shell. + pub(crate) fn new(options: CreateOptions) -> Result { + // Compute runtime options before moving fields out of `options`. + let runtime_options = RuntimeOptions::defaults_from(&options); + + // Instantiate the shell with some defaults. + let mut shell = Self { + error_formatter: options.error_formatter, + open_files: openfiles::OpenFiles::new(), + options: runtime_options, + name: options.shell_name, + args: options.shell_args.unwrap_or_default(), + version: options.shell_version, + product_display_str: options.shell_product_display_str, + working_dir: options.working_dir.map_or_else(std::env::current_dir, Ok)?, + builtins: options.builtins, + parser_impl: options.parser, + key_bindings: options.key_bindings, + ..Self::default() + }; + + // Add in any open files provided. + shell.open_files.update_from(options.fds.into_iter()); + + // TODO(patterns): Without this a script that sets extglob will fail because we + // parse the entire script with the same settings. + shell.options.extended_globbing = true; + + // If requested, seed parameters from environment. + if !options.do_not_inherit_env { + wellknownvars::inherit_env_vars(&mut shell)?; + } + + // If requested, set well-known variables. + if !options.skip_well_known_vars { + wellknownvars::init_well_known_vars(&mut shell)?; + } + + // Set any provided variables. + for (var_name, var_value) in options.vars { + shell.env.set_global(var_name, var_value)?; + } + + // Set up history, if relevant. Do NOT fail if we can't load history. + if shell.options.enable_command_history { + shell.history = shell + .load_history() + .unwrap_or_default() + .or_else(|| Some(crate::history::History::default())); + } + + Ok(shell) + } +} + +impl Shell { + /// Increments the interactive line offset in the shell by the indicated number + /// of lines. + /// + /// # Arguments + /// + /// * `delta` - The number of lines to increment the current line offset by. + pub fn increment_interactive_line_offset(&mut self, delta: usize) { + self.call_stack.increment_current_line_offset(delta); + } + + /// Updates the currently executing command in the shell. + pub fn set_current_cmd(&mut self, cmd: &impl brush_parser::ast::Node) { + self.call_stack + .set_current_pos(cmd.location().map(|span| span.start)); + } + + /// Updates the `$_` shell variable (last-argument of the previous simple + /// command). + /// + /// Passes `Some(last_arg)` to record the last argument of the just-executed + /// command, or `None` to clear `$_` (used for assignment-only statements, + /// which bash treats as having no "last argument"). + /// + /// The update is applied in-place so that attributes on `_` (notably + /// `readonly`) are preserved: attempting to update a readonly `_` is a + /// silent no-op, matching bash's observable stdout behavior. + pub(crate) fn update_last_arg_variable(&mut self, last_arg: Option) { + // Bash refuses to update a readonly `_`, emitting an error to stderr + // on each attempt. We silently skip the update here — the observable + // stdout effect ($_ stays unchanged) matches bash; the missing stderr + // diagnostics are harmless. + if self + .env + .get_using_policy("_", crate::env::EnvironmentLookup::Anywhere) + .is_some_and(|v| v.is_readonly()) + { + return; + } + + // Replace the variable entirely (fresh, non-exported). This matches + // bash, which never exports `_` — even under `set -a` — and always + // clears any previously-set attributes (except readonly, handled + // above). + let value = last_arg.unwrap_or_default(); + let _ = self + .env + .set_global("_", crate::variables::ShellVariable::new(value)); + } + + /// Applies errexit semantics to a result if enabled and appropriate. + /// This should be called at "statement boundaries" where errexit should be checked. + /// + /// # Arguments + /// + /// * `result` - The execution result to potentially modify. + pub const fn apply_errexit_if_enabled(&self, result: &mut ExecutionResult) { + if self.options.exit_on_nonzero_command_exit + && !result.is_success() + && result.is_normal_flow() + { + result.next_control_flow = ExecutionControlFlow::ExitShell; + } + } + + /// Returns the keywords that are reserved by the shell. + pub(crate) fn get_keywords(&self) -> impl IntoIterator { + if self.options.sh_mode { + keywords::SH_MODE_KEYWORDS.iter().copied() + } else { + keywords::KEYWORDS.iter().copied() + } + } + + /// Checks if the given string is a keyword reserved in this shell. + /// + /// # Arguments + /// + /// * `s` - The string to check. + pub fn is_keyword(&self, s: &str) -> bool { + if self.options.sh_mode { + keywords::SH_MODE_KEYWORDS.contains(s) + } else { + keywords::KEYWORDS.contains(s) + } + } + + pub(crate) const fn last_exit_status_change_count(&self) -> usize { + self.last_exit_status_change_count + } +} + +#[inherent::inherent] +impl ShellState for Shell { + /// Returns whether or not this shell is a subshell. + pub fn is_subshell(&self) -> bool { + self.depth > 0 + } + + /// Returns the last "SECONDS" captured time. + pub fn last_stopwatch_time(&self) -> std::time::SystemTime { + self.last_stopwatch_time + } + + /// Returns the last "SECONDS" offset requested. + pub fn last_stopwatch_offset(&self) -> u32 { + self.last_stopwatch_offset + } + + /// Returns the shell environment containing variables. + pub fn env(&self) -> &ShellEnvironment { + &self.env + } + + /// Returns a mutable reference to the shell environment. + pub fn env_mut(&mut self) -> &mut ShellEnvironment { + &mut self.env + } + + /// Returns the shell's runtime options. + pub fn options(&self) -> &RuntimeOptions { + &self.options + } + + /// Returns a mutable reference to the shell's runtime options. + pub fn options_mut(&mut self) -> &mut RuntimeOptions { + &mut self.options + } + + /// Returns the shell's aliases. + pub fn aliases(&self) -> &HashMap { + &self.aliases + } + + /// Returns a mutable reference to the shell's aliases. + pub fn aliases_mut(&mut self) -> &mut HashMap { + &mut self.aliases + } + + /// Returns the shell's job manager. + pub fn jobs(&self) -> &jobs::JobManager { + &self.jobs + } + + /// Returns a mutable reference to the shell's job manager. + pub fn jobs_mut(&mut self) -> &mut jobs::JobManager { + &mut self.jobs + } + + /// Returns the shell's trap handler configuration. + pub fn traps(&self) -> &crate::traps::TrapHandlerConfig { + &self.traps + } + + /// Returns a mutable reference to the shell's trap handler configuration. + pub fn traps_mut(&mut self) -> &mut crate::traps::TrapHandlerConfig { + &mut self.traps + } + + /// Returns the shell's directory stack. + pub fn directory_stack(&self) -> &[PathBuf] { + &self.directory_stack + } + + /// Returns a mutable reference to the shell's directory stack. + pub fn directory_stack_mut(&mut self) -> &mut Vec { + &mut self.directory_stack + } + + /// Returns the statuses of commands in the last pipeline. + pub fn last_pipeline_statuses(&self) -> &[u8] { + &self.last_pipeline_statuses + } + + /// Returns a mutable reference to the statuses of commands in the last pipeline. + pub fn last_pipeline_statuses_mut(&mut self) -> &mut Vec { + &mut self.last_pipeline_statuses + } + + /// Returns the shell's program location cache. + pub fn program_location_cache(&self) -> &pathcache::PathCache { + &self.program_location_cache + } + + /// Returns a mutable reference to the shell's program location cache. + pub fn program_location_cache_mut(&mut self) -> &mut pathcache::PathCache { + &mut self.program_location_cache + } + + /// Returns the shell's completion configuration. + pub fn completion_config(&self) -> &crate::completion::Config { + &self.completion_config + } + + /// Returns a mutable reference to the shell's completion configuration. + pub fn completion_config_mut(&mut self) -> &mut crate::completion::Config { + &mut self.completion_config + } + + /// Returns the shell's open files. + pub fn open_files(&self) -> &openfiles::OpenFiles { + &self.open_files + } + + /// Returns a mutable reference to the shell's open files. + pub fn open_files_mut(&mut self) -> &mut openfiles::OpenFiles { + &mut self.open_files + } + + /// Returns the *current* name of the shell ($0). + /// Influenced by the current call stack. + pub fn current_shell_name(&self) -> Option> { + for frame in self.call_stack.iter() { + // Executed scripts shadow the shell name. + if frame.frame_type.is_run_script() { + return Some(frame.frame_type.name()); + } + } + + self.name.as_deref().map(|name| name.into()) + } + + /// Returns the current subshell depth; 0 is returned if this shell is not a subshell. + pub fn depth(&self) -> usize { + self.depth + } + + /// Returns the call stack for the shell. + pub fn call_stack(&self) -> &crate::callstack::CallStack { + &self.call_stack + } + + /// Returns the shell's history, if it exists. + pub fn history(&self) -> Option<&crate::history::History> { + self.history.as_ref() + } + + /// Returns a mutable reference to the shell's history, if it exists. + pub fn history_mut(&mut self) -> Option<&mut crate::history::History> { + self.history.as_mut() + } + + /// Returns the shell's official version string (if available). + pub fn version(&self) -> Option<&str> { + self.version.as_deref() + } + + /// Returns the exit status of the last command executed in this shell. + pub fn last_exit_status(&self) -> u8 { + self.last_exit_status + } + + /// Updates the last exit status. To *restore* a saved status, restore + /// `last_exit_status_change_count` as well. + pub fn set_last_exit_status(&mut self, status: u8) { + self.last_exit_status = status; + self.last_exit_status_change_count += 1; + } + + /// Returns the key bindings helper for the shell. + pub fn key_bindings(&self) -> Option<&KeyBindingsHelper> { + self.key_bindings.as_ref() + } + + /// Sets the key bindings helper for the shell. + pub fn set_key_bindings(&mut self, key_bindings: Option) { + self.key_bindings = key_bindings; + } + + /// Returns the shell's current working directory. + pub fn working_dir(&self) -> &Path { + &self.working_dir + } + + /// Returns a mutable reference to the shell's current working directory. + /// This is only accessible within the crate. + pub(crate) fn working_dir_mut(&mut self) -> &mut PathBuf { + &mut self.working_dir + } + + /// Returns the product display name for this shell. + pub fn product_display_str(&self) -> Option<&str> { + self.product_display_str.as_deref() + } +} + +#[cfg(feature = "serde")] +fn default_error_formatter() -> EF { + EF::default() +} diff --git a/local/recipes/shells/brush/source/brush-core/src/shell/builder.rs b/local/recipes/shells/brush/source/brush-core/src/shell/builder.rs new file mode 100644 index 0000000000..b45491c2f2 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/shell/builder.rs @@ -0,0 +1,283 @@ +//! Module defining the builder for creating shell instances. + +use std::{collections::HashMap, path::PathBuf}; + +pub use shell_builder::State as ShellBuilderState; + +use super::Shell; +use crate::{ + ProfileLoadBehavior, RcLoadBehavior, ShellFd, ShellVariable, builtins, callstack, completion, + env, error, extensions, functions, jobs, openfiles, options, pathcache, + shell::KeyBindingsHelper, traps, +}; + +impl ShellBuilder { + /// Returns a new shell instance created with the options provided. Runs any + /// configuration loading as well. + pub async fn build(self) -> Result, error::Error> { + let mut options = self.build_settings(); + + let profile = std::mem::take(&mut options.profile); + let rc = std::mem::take(&mut options.rc); + + // Construct the shell. + let mut shell = Shell::new(options)?; + + // Load profiles/configuration, unless skipped. + if !profile.skip() || !rc.skip() { + shell.load_config(&profile, &rc).await?; + } + + Ok(shell) + } +} + +impl ShellBuilder { + /// Add a disabled option + pub fn disable_option(mut self, option: impl Into) -> Self { + self.disabled_options.push(option.into()); + self + } + + /// Add an enabled option + pub fn enable_option(mut self, option: impl Into) -> Self { + self.enabled_options.push(option.into()); + self + } + + /// Add many disabled options + pub fn disable_options(mut self, options: impl IntoIterator>) -> Self { + self.disabled_options + .extend(options.into_iter().map(Into::into)); + self + } + + /// Add many enabled options + pub fn enable_options(mut self, options: impl IntoIterator>) -> Self { + self.enabled_options + .extend(options.into_iter().map(Into::into)); + self + } + + /// Add a disabled shopt option + pub fn disable_shopt_option(mut self, option: impl Into) -> Self { + self.disabled_shopt_options.push(option.into()); + self + } + + /// Add an enabled shopt option + pub fn enable_shopt_option(mut self, option: impl Into) -> Self { + self.enabled_shopt_options.push(option.into()); + self + } + + /// Add many disabled shopt options + pub fn disable_shopt_options(mut self, options: impl IntoIterator>) -> Self { + self.disabled_shopt_options + .extend(options.into_iter().map(Into::into)); + self + } + + /// Add many enabled shopt options + pub fn enable_shopt_options(mut self, options: impl IntoIterator>) -> Self { + self.enabled_shopt_options + .extend(options.into_iter().map(Into::into)); + self + } + + /// Add a single builtin registration + pub fn builtin(mut self, name: impl Into, reg: builtins::Registration) -> Self { + self.builtins.insert(name.into(), reg); + self + } + + /// Add many builtin registrations + pub fn builtins( + mut self, + builtins: impl IntoIterator)>, + ) -> Self { + self.builtins.extend(builtins); + self + } + + /// Adds a single variable to be initialized in the shell. + pub fn var(mut self, name: impl Into, variable: ShellVariable) -> Self { + self.vars.insert(name.into(), variable); + self + } +} + +/// Options for creating a new shell. +#[derive(Default, bon::Builder)] +#[builder( + builder_type( + name = ShellBuilder, + doc { + /// Builder for [Shell] + }), + finish_fn( + name = build_settings, + vis = "pub(self)", + ), + start_fn( + vis = "pub(self)" + ) +)] +pub struct CreateOptions { + /// Disabled options. + #[builder(field)] + pub disabled_options: Vec, + /// Enabled options. + #[builder(field)] + pub enabled_options: Vec, + /// Disabled shopt options. + #[builder(field)] + pub disabled_shopt_options: Vec, + /// Enabled shopt options. + #[builder(field)] + pub enabled_shopt_options: Vec, + /// Registered builtins. + #[builder(field)] + pub builtins: HashMap>, + /// Provides a set of variables to be initialized in the shell. If present, they + /// are assigned *after* inherited or well-known variables are set (when applicable). + #[builder(field)] + pub vars: HashMap, + /// Error behavior implementation. + #[builder(default)] + pub error_formatter: SE::ErrorFormatter, + /// Disallow overwriting regular files via output redirection. + #[builder(default)] + pub disallow_overwriting_regular_files_via_output_redirection: bool, + /// Do not execute commands. + #[builder(default)] + pub do_not_execute_commands: bool, + /// Exit after one command. + #[builder(default)] + pub exit_after_one_command: bool, + /// Whether the shell is interactive. + #[builder(default)] + pub interactive: bool, + /// Whether the shell is a login shell. + #[builder(default)] + pub login: bool, + /// Whether to skip using a readline-like interface for input. + #[builder(default)] + pub no_editing: bool, + /// System profile loading behavior. + #[builder(default)] + pub profile: ProfileLoadBehavior, + /// Rc file loading behavior. + #[builder(default)] + pub rc: RcLoadBehavior, + /// Whether to skip inheriting environment variables from the calling process. + #[builder(default)] + pub do_not_inherit_env: bool, + /// Whether to skip initializing well-known variables. + #[builder(default)] + pub skip_well_known_vars: bool, + /// Provides a set of initial open files to be tracked by the shell. + #[builder(default)] + pub fds: HashMap, + /// Whether to launch external commands as session leaders. + #[builder(default)] + pub external_cmd_leads_session: bool, + /// Whether externally spawned commands should be killed when their spawning + /// shell is dropped. Defaults to `false` (children outlive the shell, as a + /// real shell requires); enable for embedded shells, where a surviving child + /// leaks and can hold the shell's output pipe open past teardown. + #[builder(default)] + pub kill_external_commands_on_drop: bool, + /// Initial working dir for the shell. If left unspecified, will be populated from + /// the host environment. + pub working_dir: Option, + /// Whether the shell is in POSIX compliance mode. + #[builder(default)] + pub posix: bool, + /// Whether to print commands and arguments as they are read. + #[builder(default)] + pub print_commands_and_arguments: bool, + /// Whether commands are being read from stdin. + #[builder(default)] + pub read_commands_from_stdin: bool, + /// The name of the shell. + pub shell_name: Option, + /// Base positional arguments for the shell (not including the shell name). + pub shell_args: Option>, + /// Optionally provides a display string describing the version and variant of the shell. + pub shell_product_display_str: Option, + /// Whether to run in maximal POSIX sh compatibility mode. + #[builder(default)] + pub sh_mode: bool, + /// Whether to treat expansion of unset variables as an error. + #[builder(default)] + pub treat_unset_variables_as_error: bool, + /// Whether to enable error-on-exit behavior. + #[builder(default)] + pub exit_on_nonzero_command_exit: bool, + /// Whether to disable pathname expansion. + #[builder(default)] + pub disable_pathname_expansion: bool, + /// Whether to print verbose output. + #[builder(default)] + pub verbose: bool, + /// Parser implementation to use. + #[builder(default)] + pub parser: crate::parser::ParserImpl, + /// Whether the shell is in command string mode (-c). + #[builder(default)] + pub command_string_mode: bool, + /// Maximum function call depth. + pub max_function_call_depth: Option, + /// Key bindings helper for the shell to use. + pub key_bindings: Option, + /// Brush implementation version. + pub shell_version: Option, +} + +impl Default for Shell { + fn default() -> Self { + Self { + error_formatter: SE::ErrorFormatter::default(), + traps: traps::TrapHandlerConfig::default(), + open_files: openfiles::OpenFiles::default(), + working_dir: PathBuf::default(), + env: env::ShellEnvironment::default(), + funcs: functions::FunctionEnv::default(), + options: options::RuntimeOptions::default(), + jobs: jobs::JobManager::default(), + aliases: HashMap::default(), + last_exit_status: 0, + last_exit_status_change_count: 0, + last_pipeline_statuses: vec![0], + depth: 0, + name: None, + args: vec![], + version: None, + product_display_str: None, + call_stack: callstack::CallStack::new(), + directory_stack: vec![], + completion_config: completion::Config::default(), + builtins: HashMap::default(), + program_location_cache: pathcache::PathCache::default(), + last_stopwatch_time: std::time::SystemTime::now(), + last_stopwatch_offset: 0, + parser_impl: crate::parser::ParserImpl::default(), + key_bindings: None, + history: None, + } + } +} + +impl Shell { + /// Create an instance of [Shell] using the builder syntax + pub fn builder() -> ShellBuilder { + CreateOptions::builder() + } + + /// Create an instance of [Shell] using the builder syntax, with custom extensions. + pub fn builder_with_extensions() + -> ShellBuilder { + CreateOptions::builder() + } +} diff --git a/local/recipes/shells/brush/source/brush-core/src/shell/builtin_registry.rs b/local/recipes/shells/brush/source/brush-core/src/shell/builtin_registry.rs new file mode 100644 index 0000000000..c71a8b771b --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/shell/builtin_registry.rs @@ -0,0 +1,51 @@ +//! Builtin command management for shell instances. + +use std::collections::HashMap; + +use crate::{builtins, extensions}; + +impl crate::Shell { + /// Register a builtin to the shell's environment, replacing any existing + /// registration with the same name. + /// + /// # Arguments + /// + /// * `name` - The in-shell name of the builtin. + /// * `registration` - The registration handle for the builtin. + pub fn register_builtin>( + &mut self, + name: S, + registration: builtins::Registration, + ) { + self.builtins.insert(name.into(), registration); + } + + /// Register a builtin only if no builtin with that name is already registered. + /// + /// # Arguments + /// + /// * `name` - The in-shell name of the builtin. + /// * `registration` - The registration handle for the builtin. + pub fn register_builtin_if_unset>( + &mut self, + name: S, + registration: builtins::Registration, + ) { + self.builtins.entry(name.into()).or_insert(registration); + } + + /// Tries to retrieve a mutable reference to an existing builtin registration. + /// Returns `None` if no such registration exists. + /// + /// # Arguments + /// + /// * `name` - The name of the builtin to lookup. + pub fn builtin_mut(&mut self, name: &str) -> Option<&mut builtins::Registration> { + self.builtins.get_mut(name) + } + + /// Returns the registered builtins for the shell. + pub const fn builtins(&self) -> &HashMap> { + &self.builtins + } +} diff --git a/local/recipes/shells/brush/source/brush-core/src/shell/callstack.rs b/local/recipes/shells/brush/source/brush-core/src/shell/callstack.rs new file mode 100644 index 0000000000..91a3c97f74 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/shell/callstack.rs @@ -0,0 +1,186 @@ +//! Call stack management for the shell. + +use crate::{ExecutionParameters, callstack, env, error, functions, trace_categories}; + +impl crate::Shell { + /// Returns whether or not the shell is actively executing in a sourced script. + pub fn in_sourced_script(&self) -> bool { + self.call_stack.in_sourced_script() + } + + /// Returns whether or not the shell is actively executing in a shell function. + pub fn in_function(&self) -> bool { + self.call_stack.in_function() + } + + /// Updates the shell's internal tracking state to reflect that a new interactive + /// session is being started. + pub fn start_interactive_session(&mut self) -> Result<(), error::Error> { + self.call_stack.push_interactive_session(); + Ok(()) + } + + /// Updates the shell's internal tracking state to reflect that the current + /// interactive session is ending. + pub fn end_interactive_session(&mut self) -> Result<(), error::Error> { + if self + .call_stack + .current_frame() + .is_none_or(|frame| !frame.frame_type.is_interactive_session()) + { + return Err(error::ErrorKind::NotInInteractiveSession.into()); + } + + self.call_stack.pop(); + + Ok(()) + } + + /// Updates the shell's internal tracking state to reflect that command + /// string mode is being started. + pub fn start_command_string_mode(&mut self) { + self.call_stack.push_command_string(); + } + + /// Updates the shell's internal tracking state to reflect that command + /// string mode is ending. + pub fn end_command_string_mode(&mut self) -> Result<(), error::Error> { + if self + .call_stack + .current_frame() + .is_none_or(|frame| !frame.frame_type.is_command_string()) + { + return Err(error::ErrorKind::NotExecutingCommandString.into()); + } + + self.call_stack.pop(); + + Ok(()) + } + + pub(crate) fn enter_trap_handler( + &mut self, + signal: crate::traps::TrapSignal, + handler: Option<&crate::traps::TrapHandler>, + ) { + self.call_stack.push_trap_handler(signal, handler); + } + + pub(crate) fn leave_trap_handler(&mut self) { + self.call_stack.pop(); + } + + /// Acquires a block on trap delivery, preventing traps from being delivered until + /// the block is released. Multiple blocks may be acquired, and trap delivery will + /// remain suppressed until all blocks have been released. + pub(crate) const fn acquire_trap_delivery_block(&mut self) { + self.call_stack.acquire_trap_delivery_block(); + } + + /// Releases a block on trap delivery; note that trap delivery will remain + /// suppressed until all blocks have been released. + pub(crate) const fn release_trap_delivery_block(&mut self) { + self.call_stack.release_trap_delivery_block(); + } + + /// Updates the shell's internal tracking state to reflect that a new shell + /// function is being entered. + /// + /// # Arguments + /// + /// * `name` - The name of the function being entered. + /// * `function` - The function being entered. + /// * `args` - The arguments being passed to the function. + /// * `_params` - Current execution parameters. + pub(crate) fn enter_function( + &mut self, + name: &str, + function: &functions::Registration, + args: impl IntoIterator, + _params: &ExecutionParameters, + ) -> Result<(), error::Error> { + if let Some(max_call_depth) = self.options.max_function_call_depth + && self.call_stack.function_call_depth() >= max_call_depth + { + return Err(error::ErrorKind::MaxFunctionCallDepthExceeded.into()); + } + + if tracing::enabled!(target: trace_categories::FUNCTIONS, tracing::Level::DEBUG) { + let depth = self.call_stack.function_call_depth(); + let prefix = repeated_char_str(' ', depth); + tracing::debug!(target: trace_categories::FUNCTIONS, "Entering func [depth={depth}]: {prefix}{name}"); + } + + self.call_stack.push_function(name, function, args); + self.env.push_scope(env::EnvironmentScope::Local); + + Ok(()) + } + + /// Updates the shell's internal tracking state to reflect that the shell + /// has exited the top-most function on its call stack. + pub(crate) fn leave_function(&mut self) -> Result<(), error::Error> { + self.env.pop_scope(env::EnvironmentScope::Local)?; + + if let Some(exited_call) = self.call_stack.pop() { + if let callstack::FrameType::Function(func_call) = exited_call.frame_type { + if tracing::enabled!(target: trace_categories::FUNCTIONS, tracing::Level::DEBUG) { + let depth = self.call_stack.function_call_depth(); + let prefix = repeated_char_str(' ', depth); + tracing::debug!(target: trace_categories::FUNCTIONS, "Exiting func [depth={depth}]: {prefix}{}", func_call.function_name); + } + } else { + let err: error::Error = + error::ErrorKind::InternalError("mismatched call stack state".to_owned()) + .into(); + return Err(err.into_fatal()); + } + } + + Ok(()) + } + + /// Returns the *current* positional arguments for the shell ($1 and beyond). + /// Influenced by the current call stack. + pub fn current_shell_args(&self) -> &[String] { + for frame in self.call_stack.iter() { + match frame.frame_type { + // Function calls always shadow positional parameters. + crate::callstack::FrameType::Function(..) => return &frame.args, + // Executed scripts always shadow positional parameters. + _ if frame.frame_type.is_run_script() => return &frame.args, + // Sourced scripts shadow positional parameters if they have arguments. + _ if frame.frame_type.is_sourced_script() && !frame.args.is_empty() => { + return &frame.args; + } + _ => (), + } + } + + self.args.as_slice() + } + + /// Returns a mutable reference to *current* positional parameters for the shell + /// ($1 and beyond). + pub fn current_shell_args_mut(&mut self) -> &mut Vec { + for frame in self.call_stack.iter_mut() { + match frame.frame_type { + // Function calls always shadow positional parameters. + crate::callstack::FrameType::Function(..) => return &mut frame.args, + // Executed scripts always shadow positional parameters. + _ if frame.frame_type.is_run_script() => return &mut frame.args, + // Sourced scripts shadow positional parameters if they have arguments. + _ if frame.frame_type.is_sourced_script() && !frame.args.is_empty() => { + return &mut frame.args; + } + _ => (), + } + } + + &mut self.args + } +} + +fn repeated_char_str(c: char, count: usize) -> String { + (0..count).map(|_| c).collect() +} diff --git a/local/recipes/shells/brush/source/brush-core/src/shell/completion.rs b/local/recipes/shells/brush/source/brush-core/src/shell/completion.rs new file mode 100644 index 0000000000..5c211f177d --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/shell/completion.rs @@ -0,0 +1,22 @@ +//! Command completion support for shell instances. + +use crate::{completion, error, extensions}; + +impl crate::Shell { + /// Generates command completions for the shell. + /// + /// # Arguments + /// + /// * `input` - The input string to generate completions for. + /// * `position` - The position in the input string to generate completions at. + pub async fn complete( + &mut self, + input: &str, + position: usize, + ) -> Result { + let completion_config = self.completion_config.clone(); + completion_config + .get_completions(self, input, position) + .await + } +} diff --git a/local/recipes/shells/brush/source/brush-core/src/shell/env.rs b/local/recipes/shells/brush/source/brush-core/src/shell/env.rs new file mode 100644 index 0000000000..b4e096227f --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/shell/env.rs @@ -0,0 +1,36 @@ +//! Environment support for shell. + +use std::borrow::Cow; + +use crate::{ShellVariable, error}; + +impl crate::Shell { + /// Tries to retrieve a variable from the shell's environment, converting it into its + /// string form. + /// + /// # Arguments + /// + /// * `name` - The name of the variable to retrieve. + pub fn env_str(&self, name: &str) -> Option> { + self.env.get_str(name, self) + } + + /// Tries to retrieve a variable from the shell's environment. + /// + /// # Arguments + /// + /// * `name` - The name of the variable to retrieve. + pub fn env_var(&self, name: &str) -> Option<&ShellVariable> { + self.env.get(name).map(|(_, var)| var) + } + + /// Tries to set a global variable in the shell's environment. + /// + /// # Arguments + /// + /// * `name` - The name of the variable to add. + /// * `var` - The variable contents to add. + pub fn set_env_global(&mut self, name: &str, var: ShellVariable) -> Result<(), error::Error> { + self.env.set_global(name, var) + } +} diff --git a/local/recipes/shells/brush/source/brush-core/src/shell/execution.rs b/local/recipes/shells/brush/source/brush-core/src/shell/execution.rs new file mode 100644 index 0000000000..bea89e3993 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/shell/execution.rs @@ -0,0 +1,285 @@ +//! Execution support for shell. + +use std::{io::Read, path::Path}; + +use crate::{ + ExecutionControlFlow, ExecutionParameters, ExecutionResult, ProcessGroupPolicy, SourceInfo, + arithmetic::Evaluatable as _, callstack, error, interp::Execute as _, openfiles, + trace_categories, +}; + +impl crate::Shell { + /// Returns the default execution parameters for this shell. + pub fn default_exec_params(&self) -> ExecutionParameters { + let mut params = ExecutionParameters::default(); + + params.process_group_policy = if self.options.enable_job_control { + ProcessGroupPolicy::NewProcessGroup + } else { + ProcessGroupPolicy::SameProcessGroup + }; + + params + } + + pub(super) async fn source_if_exists( + &mut self, + path: impl AsRef, + params: &ExecutionParameters, + ) -> Result { + let path = path.as_ref(); + if path.exists() { + self.source_script(path, std::iter::empty::(), params) + .await?; + Ok(true) + } else { + tracing::debug!("skipping non-existent file: {}", path.display()); + Ok(false) + } + } + + /// Source the given file as a shell script, returning the execution result. + /// + /// # Arguments + /// + /// * `path` - The path to the file to source. + /// * `args` - The arguments to pass to the script as positional parameters. + /// * `params` - Execution parameters. + pub async fn source_script, P: AsRef, I: Iterator>( + &mut self, + path: P, + args: I, + params: &ExecutionParameters, + ) -> Result { + self.parse_and_execute_script_file( + path.as_ref(), + args, + params, + callstack::ScriptCallType::Source, + ) + .await + } + + /// Parse and execute the given file as a shell script, returning the execution result. + /// + /// # Arguments + /// + /// * `path` - The path to the file to source. + /// * `args` - The arguments to pass to the script as positional parameters. + /// * `params` - Execution parameters. + /// * `call_type` - The type of script call being made. + async fn parse_and_execute_script_file< + S: Into, + P: AsRef, + I: Iterator, + >( + &mut self, + path: P, + args: I, + params: &ExecutionParameters, + call_type: callstack::ScriptCallType, + ) -> Result { + let path = path.as_ref(); + tracing::debug!("sourcing: {}", path.display()); + + let mut options = std::fs::File::options(); + options.read(true); + + let opened_file: openfiles::OpenFile = self + .open_file(&options, path, params) + .map_err(|e| error::ErrorKind::FailedSourcingFile(path.to_owned(), e))?; + + if opened_file.is_dir() { + return Err(error::ErrorKind::FailedSourcingFile( + path.to_owned(), + std::io::Error::from(std::io::ErrorKind::IsADirectory), + ) + .into()); + } + + let source_info = crate::SourceInfo::from(path.to_owned()); + + let mut result = self + .source_file(opened_file, &source_info, args, params, call_type) + .await?; + + // Handle control flow at script execution boundary. If execution completed + // with a `return`, we need to clear it since it's already been "used". All + // other control flow types are preserved. + if matches!( + result.next_control_flow, + ExecutionControlFlow::ReturnFromFunctionOrScript + ) { + result.next_control_flow = ExecutionControlFlow::Normal; + } + + Ok(result) + } + + /// Source the given file as a shell script, returning the execution result. + /// + /// # Arguments + /// + /// * `file` - The file to source. + /// * `source_info` - Information about the source of the script. + /// * `args` - The arguments to pass to the script as positional parameters. + /// * `params` - Execution parameters. + /// * `call_type` - The type of script call being made. + async fn source_file, I: Iterator>( + &mut self, + file: F, + source_info: &crate::SourceInfo, + args: I, + params: &ExecutionParameters, + call_type: callstack::ScriptCallType, + ) -> Result { + let mut reader = std::io::BufReader::new(file); + let mut parser = brush_parser::Parser::new(&mut reader, &self.parser_options()); + + tracing::debug!(target: trace_categories::PARSE, "Parsing sourced file: {}", source_info.source); + let parse_result = parser.parse_program(); + + let script_positional_args = args.map(Into::into); + + self.call_stack + .push_script(call_type, source_info, script_positional_args); + + let result = self + .run_parsed_result(parse_result, source_info, params) + .await; + + self.call_stack.pop(); + + result + } + + /// Executes the given string as a shell program, returning the resulting exit status. + /// + /// # Arguments + /// + /// * `command` - The command to execute. + /// * `source_info` - Information about the source of the command text. + /// * `params` - Execution parameters. + pub async fn run_string>( + &mut self, + command: S, + source_info: &crate::SourceInfo, + params: &ExecutionParameters, + ) -> Result { + let parse_result = self.parse_string(command); + self.run_parsed_result(parse_result, source_info, params) + .await + } + + /// Executes the given command, provided to a shell executable on the command + /// line (i.e., via `-c`). + /// + /// It is expected that the shell will not be used for any further execution + /// after this command; this function will perform any necessary shell exit + /// handling. + /// + /// # Arguments + /// + /// * `command` - The command to execute. + pub async fn run_dash_c_command>( + &mut self, + command: S, + ) -> Result { + self.start_command_string_mode(); + + // Execute the command string. + let params = self.default_exec_params(); + let source_info = SourceInfo::from("-c"); + let result = self.run_string(command, &source_info, ¶ms).await?; + + self.end_command_string_mode()?; + + // Give the shell a chance to run on-exit tasks, but ignore the result. + let _ = self.on_exit().await; + + Ok(result) + } + + /// Executes the given script file, returning the resulting exit status. + /// + /// It is expected that the shell will not be used for any further execution + /// after this command; this function will perform any necessary shell exit + /// handling. + /// + /// # Arguments + /// + /// * `script_path` - The path to the script file to execute. + /// * `args` - The arguments to pass to the script as positional parameters. + pub async fn run_script, P: AsRef, I: Iterator>( + &mut self, + script_path: P, + args: I, + ) -> Result { + let params = self.default_exec_params(); + let result = self + .parse_and_execute_script_file( + script_path.as_ref(), + args, + ¶ms, + callstack::ScriptCallType::Run, + ) + .await?; + + // Give the shell a chance to run on-exit tasks, but ignore the result. + let _ = self.on_exit().await; + + Ok(result) + } + + pub(crate) async fn run_parsed_result( + &mut self, + parse_result: Result, + source_info: &crate::SourceInfo, + params: &ExecutionParameters, + ) -> Result { + // If parsing succeeded, run the program. If there's a parse error, it's fatal (per spec). + let result = match parse_result { + Ok(prog) => self.run_program(prog, params).await, + Err(parse_err) => Err(error::Error::from(error::ErrorKind::ParseError( + parse_err, + source_info.clone(), + )) + .into_fatal()), + }; + + // Report any errors. + match result { + Ok(result) => Ok(result), + Err(err) => { + let _ = self.display_error(&mut params.stderr(self), &err); + + let result = err.into_result(self); + self.set_last_exit_status(result.exit_code.into()); + + Ok(result) + } + } + } + + /// Executes the given parsed shell program, returning the resulting exit status. + /// + /// # Arguments + /// + /// * `program` - The program to execute. + /// * `params` - Execution parameters. + pub async fn run_program( + &mut self, + program: brush_parser::ast::Program, + params: &ExecutionParameters, + ) -> Result { + program.execute(self, params).await + } + + /// Evaluate the given arithmetic expression, returning the result. + pub fn eval_arithmetic( + &mut self, + expr: &brush_parser::ast::ArithmeticExpr, + ) -> Result { + Ok(expr.eval(self)?) + } +} diff --git a/local/recipes/shells/brush/source/brush-core/src/shell/expansion.rs b/local/recipes/shells/brush/source/brush-core/src/shell/expansion.rs new file mode 100644 index 0000000000..cc48b0a600 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/shell/expansion.rs @@ -0,0 +1,46 @@ +//! Expansion support for shell instances. + +use std::borrow::Cow; + +use crate::{error, expansion, extensions, interp::ExecutionParameters}; + +impl crate::Shell { + /// Returns the current value of the IFS variable, or the default value if it is not set. + pub fn ifs(&self) -> Cow<'_, str> { + self.env_str("IFS").unwrap_or_else(|| " \t\n".into()) + } + + /// Returns the first character of the IFS variable, or a space if it is not set. + pub(crate) fn get_ifs_first_char(&self) -> char { + self.ifs().chars().next().unwrap_or(' ') + } + + /// Applies basic shell expansion to the provided string. + /// + /// # Arguments + /// + /// * `s` - The string to expand. + pub async fn basic_expand_string>( + &mut self, + params: &ExecutionParameters, + s: S, + ) -> Result { + let result = expansion::basic_expand_word(self, params, s.as_ref()).await?; + Ok(result) + } + + /// Applies full shell expansion and field splitting to the provided string; returns + /// a sequence of fields. + /// + /// # Arguments + /// + /// * `s` - The string to expand and split. + pub async fn full_expand_and_split_string>( + &mut self, + params: &ExecutionParameters, + s: S, + ) -> Result, error::Error> { + let result = expansion::full_expand_and_split_word(self, params, s.as_ref()).await?; + Ok(result) + } +} diff --git a/local/recipes/shells/brush/source/brush-core/src/shell/fs.rs b/local/recipes/shells/brush/source/brush-core/src/shell/fs.rs new file mode 100644 index 0000000000..de4b75cffe --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/shell/fs.rs @@ -0,0 +1,244 @@ +//! Filesystem interaction in the shell. + +use std::path::{Path, PathBuf}; + +use normalize_path::NormalizePath as _; + +use crate::{ + ExecutionParameters, ShellFd, + env::{EnvironmentLookup, EnvironmentScope}, + error, openfiles, pathsearch, + sys::{fs::PathExt as _, users}, + variables, +}; + +impl crate::Shell { + /// Sets the shell's current working directory to the given path. + /// + /// # Arguments + /// + /// * `target_dir` - The path to set as the working directory. + pub fn set_working_dir(&mut self, target_dir: impl AsRef) -> Result<(), error::Error> { + let abs_path = self.absolute_path(target_dir.as_ref()); + + match std::fs::metadata(&abs_path) { + Ok(m) => { + if !m.is_dir() { + return Err(error::ErrorKind::NotADirectory(abs_path).into()); + } + } + Err(e) => { + return Err(e.into()); + } + } + + // Normalize the path (but don't canonicalize it). + let cleaned_path = abs_path.normalize(); + + let pwd = cleaned_path.to_string_lossy().to_string(); + + self.env.update_or_add( + "PWD", + variables::ShellValueLiteral::Scalar(pwd), + |_| Ok(()), + EnvironmentLookup::Anywhere, + EnvironmentScope::Global, + )?; + let oldpwd = std::mem::replace(self.working_dir_mut(), cleaned_path); + + self.env.update_or_add( + "OLDPWD", + variables::ShellValueLiteral::Scalar(oldpwd.to_string_lossy().to_string()), + |_| Ok(()), + EnvironmentLookup::Anywhere, + EnvironmentScope::Global, + )?; + + Ok(()) + } + + /// Tilde-shortens the given string, replacing the user's home directory with a tilde. + /// + /// # Arguments + /// + /// * `s` - The string to shorten. + pub fn tilde_shorten(&self, s: String) -> String { + if let Some(home_dir) = self.home_dir() + && let Some(stripped) = s.strip_prefix(home_dir.to_string_lossy().as_ref()) + { + return format!("~{stripped}"); + } + s + } + + /// Returns the shell's current home directory, if available. + pub(crate) fn home_dir(&self) -> Option { + if let Some(home) = self.env.get_str("HOME", self) { + Some(PathBuf::from(home.to_string())) + } else { + // HOME isn't set, so let's sort it out ourselves. + users::get_current_user_home_dir() + } + } + + /// Finds executables in the shell's current default PATH, matching the given glob pattern. + /// + /// # Arguments + /// + /// * `required_glob_pattern` - The glob pattern to match against. + pub fn find_executables_in_path<'a>( + &'a self, + filename: &'a str, + ) -> impl Iterator + 'a { + let path_var = self.env.get_str("PATH", self).unwrap_or_default(); + let paths = crate::sys::fs::split_paths(path_var.as_ref()); + + pathsearch::search_for_executable(paths, filename) + } + + /// Finds executables in the shell's current default PATH, with filenames matching the + /// given prefix. + /// + /// # Arguments + /// + /// * `filename_prefix` - The prefix to match against executable filenames. + pub fn find_executables_in_path_with_prefix( + &self, + filename_prefix: &str, + case_insensitive: bool, + ) -> impl Iterator { + let path_var = self.env.get_str("PATH", self).unwrap_or_default(); + let paths = crate::sys::fs::split_paths(path_var.as_ref()); + + pathsearch::search_for_executable_with_prefix(paths, filename_prefix, case_insensitive) + } + + /// Determines whether the given filename is the name of an executable in one of the + /// directories in the shell's current PATH. If found, returns the path. + /// + /// # Arguments + /// + /// * `candidate_name` - The name of the file to look for. + pub fn find_first_executable_in_path>( + &self, + candidate_name: S, + ) -> Option { + let path = self.env_str("PATH").unwrap_or_default(); + for one_dir in crate::sys::fs::split_paths(path.as_ref()) { + let candidate_path = one_dir.join(candidate_name.as_ref()); + if candidate_path.executable() { + return Some(candidate_path); + } + } + None + } + + /// Uses the shell's hash-based path cache to check whether the given filename is the name + /// of an executable in one of the directories in the shell's current PATH. If found, + /// ensures the path is in the cache and returns it. + /// + /// # Arguments + /// + /// * `candidate_name` - The name of the file to look for. + pub fn find_first_executable_in_path_using_cache>( + &mut self, + candidate_name: S, + ) -> Option + where + String: From, + { + if let Some(cached_path) = self.program_location_cache.get(&candidate_name) { + Some(cached_path) + } else if let Some(found_path) = self.find_first_executable_in_path(&candidate_name) { + self.program_location_cache + .set(candidate_name, found_path.clone()); + Some(found_path) + } else { + None + } + } + + /// Gets the absolute form of the given path. + /// + /// # Arguments + /// + /// * `path` - The path to get the absolute form of. + pub fn absolute_path(&self, path: impl AsRef) -> PathBuf { + let path = path.as_ref(); + if path.as_os_str().is_empty() || path.is_absolute() { + path.to_owned() + } else { + self.working_dir().join(path) + } + } + + /// Opens the given file, using the context of this shell and the provided execution parameters. + /// + /// # Arguments + /// + /// * `options` - The options to use opening the file. + /// * `path` - The path to the file to open; may be relative to the shell's working directory. + /// * `params` - Execution parameters. + pub(crate) fn open_file( + &self, + options: &std::fs::OpenOptions, + path: impl AsRef, + params: &ExecutionParameters, + ) -> Result { + // Give platform-specific code a chance to handle special files + // (e.g. /dev/null on Windows, which needs to open NUL instead). + // This is checked before absolute_path so that paths like /dev/null + // are intercepted on platforms where they aren't valid native paths. + if let Some(result) = crate::sys::fs::try_open_special_file(path.as_ref()) { + return result.map(openfiles::OpenFile::from); + } + + let path_to_open = self.absolute_path(path.as_ref()); + + // See if this is a reference to a file descriptor. These paths should + // reflect the shell's current execution fds, which can differ from the + // host process fds after redirections like here-docs. + if let Some(fd_num) = shell_fd_path_to_fd(&path_to_open) + && let Some(open_file) = params.try_fd(self, fd_num) + { + return Ok(open_file); + } + + Ok(options.open(path_to_open)?.into()) + } + + /// Replaces the shell's currently configured open files with the given set. + /// Typically only used by exec-like builtins. + /// + /// # Arguments + /// + /// * `open_files` - The new set of open files to use. + pub fn replace_open_files( + &mut self, + open_fds: impl Iterator, + ) { + self.open_files = openfiles::OpenFiles::from(open_fds); + } + + pub(crate) const fn persistent_open_files(&self) -> &openfiles::OpenFiles { + &self.open_files + } +} + +fn shell_fd_path_to_fd(path: &Path) -> Option { + match path.to_str()? { + "/dev/stdin" => return Some(openfiles::OpenFiles::STDIN_FD), + "/dev/stdout" => return Some(openfiles::OpenFiles::STDOUT_FD), + "/dev/stderr" => return Some(openfiles::OpenFiles::STDERR_FD), + _ => {} + } + + if let Some(parent) = path.parent() + && parent == Path::new("/dev/fd") + && let Some(filename) = path.file_name() + { + filename.to_string_lossy().parse::().ok() + } else { + None + } +} diff --git a/local/recipes/shells/brush/source/brush-core/src/shell/funcs.rs b/local/recipes/shells/brush/source/brush-core/src/shell/funcs.rs new file mode 100644 index 0000000000..8b224d31db --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/shell/funcs.rs @@ -0,0 +1,129 @@ +//! Function support for shells. + +use crate::{ + ExecutionParameters, commands, error, extensions, functions, results::ExecutionWaitResult, +}; + +impl crate::Shell { + /// Returns the function definition environment for this shell. + pub const fn funcs(&self) -> &functions::FunctionEnv { + &self.funcs + } + + /// Returns a mutable reference to the function definition environment for this shell. + pub const fn funcs_mut(&mut self) -> &mut functions::FunctionEnv { + &mut self.funcs + } + + /// Tries to undefine a function in the shell's environment. Returns whether or + /// not a definition was removed. + /// + /// # Arguments + /// + /// * `name` - The name of the function to undefine. + pub fn undefine_func(&mut self, name: &str) -> bool { + self.funcs.remove(name).is_some() + } + + /// Defines a function in the shell's environment. If a function already exists + /// with the given name, it is replaced with the new definition. + /// + /// # Arguments + /// + /// * `name` - The name of the function to define. + /// * `definition` - The function's definition. + /// * `source_info` - Source information for the function definition. + pub fn define_func( + &mut self, + name: impl Into, + definition: brush_parser::ast::FunctionDefinition, + source_info: &crate::SourceInfo, + ) { + let reg = functions::Registration::new(definition, source_info); + self.funcs.update(name.into(), reg); + } + + /// Tries to return a mutable reference to the registration for a named function. + /// Returns `None` if no such function was found. + /// + /// # Arguments + /// + /// * `name` - The name of the function to lookup + pub fn func_mut(&mut self, name: &str) -> Option<&mut functions::Registration> { + self.funcs.get_mut(name) + } + + /// Tries to define a function in the shell's environment using the given + /// string as its body. + /// + /// # Arguments + /// + /// * `name` - The name of the function + /// * `body_text` - The body of the function, expected to start with "()". + pub fn define_func_from_str( + &mut self, + name: impl Into, + body_text: &str, + ) -> Result<(), error::Error> { + let name = name.into(); + + let mut parser = + super::parsing::create_parser(body_text.as_bytes(), &self.parser_options()); + let func_body = parser.parse_function_parens_and_body().map_err(|e| { + error::Error::from(error::ErrorKind::FunctionParseError(name.clone(), e)) + })?; + + let def = brush_parser::ast::FunctionDefinition { + fname: name.clone().into(), + body: func_body, + }; + + self.define_func(name, def, &crate::SourceInfo::default()); + + Ok(()) + } + + /// Invokes a function defined in this shell, returning the resulting exit status. + /// + /// # Arguments + /// + /// * `name` - The name of the function to invoke. + /// * `args` - The arguments to pass to the function. + /// * `params` - Execution parameters to use for the invocation. + pub async fn invoke_function, I: IntoIterator, A: AsRef>( + &mut self, + name: N, + args: I, + params: ExecutionParameters, + ) -> Result { + let name = name.as_ref(); + let command_name = String::from(name); + + let func_registration = self + .funcs + .get(name) + .ok_or_else(|| error::ErrorKind::FunctionNotFound(name.to_owned()))? + .to_owned(); + + let context = commands::ExecutionContext { + shell: self, + command_name, + params, + }; + + let command_args = args + .into_iter() + .map(|s| commands::CommandArg::String(String::from(s.as_ref()))) + .collect::>(); + + let result = + commands::invoke_shell_function(func_registration, context, &command_args).await?; + + match result.wait().await? { + ExecutionWaitResult::Completed(result) => Ok(result.exit_code.into()), + ExecutionWaitResult::Stopped(..) => { + error::unimp("stopped child from function invocation") + } + } + } +} diff --git a/local/recipes/shells/brush/source/brush-core/src/shell/history.rs b/local/recipes/shells/brush/source/brush-core/src/shell/history.rs new file mode 100644 index 0000000000..54be623bff --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/shell/history.rs @@ -0,0 +1,96 @@ +//! History management for shells. + +use std::path::PathBuf; + +use crate::{error, openfiles}; + +impl crate::Shell { + pub(super) fn load_history(&self) -> Result, error::Error> { + const MAX_FILE_SIZE_FOR_HISTORY_IMPORT: u64 = 1024 * 1024 * 1024; // 1 GiB + + let Some(history_path) = self.history_file_path() else { + return Ok(None); + }; + + let mut options = std::fs::File::options(); + options.read(true); + + let mut history_file = + self.open_file(&options, history_path, &self.default_exec_params())?; + + // Check on the file's size. + if let openfiles::OpenFile::File(file) = &mut history_file { + let file_metadata = file.metadata()?; + let file_size = file_metadata.len(); + + // If the file is empty, no reason to try reading it. Note that this will also + // end up excluding non-regular files that report a 0 file size but appear + // to have contents when read. + if file_size == 0 { + return Ok(None); + } + + // Bail if the file is unrealistically large. For now we just refuse to import it. + if file_size > MAX_FILE_SIZE_FOR_HISTORY_IMPORT { + return Err(error::ErrorKind::HistoryFileTooLargeToImport.into()); + } + } + + Ok(Some(crate::history::History::import(history_file)?)) + } + + /// Returns the path to the history file used by the shell, if one is set. + pub fn history_file_path(&self) -> Option { + self.env_str("HISTFILE") + .map(|s| PathBuf::from(s.into_owned())) + } + + /// Returns the path to the history file used by the shell, if one is set. + pub fn history_time_format(&self) -> Option { + self.env_str("HISTTIMEFORMAT").map(|s| s.into_owned()) + } + + /// Saves history back to any backing storage. + pub fn save_history(&mut self) -> Result<(), error::Error> { + if let Some(history_file_path) = self.history_file_path() + && let Some(history) = &mut self.history + { + // See if there's *any* time format configured. That triggers writing out + // timestamps. + let write_timestamps = self.env.is_set("HISTTIMEFORMAT"); + + // TODO(history): Observe options.append_to_history_file + history.flush( + history_file_path, + true, /* append? */ + true, /* unsaved items only? */ + write_timestamps, + )?; + } + + Ok(()) + } + + /// Adds a command to history. + pub fn add_to_history(&mut self, command: &str) -> Result<(), error::Error> { + if let Some(history) = &mut self.history { + // Trim. + let command = command.trim(); + + // For now, discard empty commands. + if command.is_empty() { + return Ok(()); + } + + // Add it to history. + history.add(crate::history::Item { + id: 0, + command_line: command.to_owned(), + timestamp: Some(chrono::Utc::now()), + dirty: true, + })?; + } + + Ok(()) + } +} diff --git a/local/recipes/shells/brush/source/brush-core/src/shell/initscripts.rs b/local/recipes/shells/brush/source/brush-core/src/shell/initscripts.rs new file mode 100644 index 0000000000..782ecbcdeb --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/shell/initscripts.rs @@ -0,0 +1,142 @@ +//! Init script support for shells. + +use std::path::PathBuf; + +use crate::{Shell, error, extensions, interp}; + +/// Behavior for loading profile files. +#[derive(Default)] +pub enum ProfileLoadBehavior { + /// Load the default profile files. + #[default] + LoadDefault, + /// Skip loading profile files. + Skip, +} + +impl ProfileLoadBehavior { + /// Returns whether profile loading should be skipped. + pub const fn skip(&self) -> bool { + matches!(self, Self::Skip) + } +} + +/// Behavior for loading rc files. +#[derive(Default)] +pub enum RcLoadBehavior { + /// Load the default rc files. + #[default] + LoadDefault, + /// Load a custom rc file; do not load defaults. + LoadCustom(PathBuf), + /// Skip loading rc files. + Skip, +} + +impl RcLoadBehavior { + /// Returns whether rc loading should be skipped. + pub const fn skip(&self) -> bool { + matches!(self, Self::Skip) + } +} + +impl Shell { + /// Loads and executes standard shell configuration files (i.e., rc and profile). + /// + /// # Arguments + /// + /// * `profile_behavior` - Behavior for loading profile files. + /// * `rc_behavior` - Behavior for loading rc files. + pub async fn load_config( + &mut self, + profile_behavior: &ProfileLoadBehavior, + rc_behavior: &RcLoadBehavior, + ) -> Result<(), error::Error> { + let mut params = self.default_exec_params(); + params.process_group_policy = interp::ProcessGroupPolicy::SameProcessGroup; + + if self.options.login_shell { + // --noprofile means skip this. + if matches!(profile_behavior, ProfileLoadBehavior::Skip) { + return Ok(()); + } + + // + // Source the system profile if it exists. + // + // Next source the first of these that exists and is readable (if any): + // * ~/.bash_profile + // * ~/.bash_login + // * ~/.profile + // + if let Some(system_profile) = crate::sys::fs::get_system_profile_path() { + self.source_if_exists(system_profile, ¶ms).await?; + } + if let Some(home_path) = self.home_dir() { + if self.options.sh_mode { + self.source_if_exists(home_path.join(".profile").as_path(), ¶ms) + .await?; + } else { + if !self + .source_if_exists(home_path.join(".bash_profile").as_path(), ¶ms) + .await? + { + if !self + .source_if_exists(home_path.join(".bash_login").as_path(), ¶ms) + .await? + { + self.source_if_exists(home_path.join(".profile").as_path(), ¶ms) + .await?; + } + } + } + } + } else { + if self.options.interactive { + match rc_behavior { + _ if self.options.sh_mode => (), + RcLoadBehavior::Skip => (), + RcLoadBehavior::LoadCustom(rc_file) => { + // If an explicit rc file is provided, source it. + self.source_if_exists(rc_file, ¶ms).await?; + } + RcLoadBehavior::LoadDefault => { + // + // Otherwise, for non-login interactive shells, load in this order: + // + // system rc file (e.g. /etc/bash.bashrc on Unix) + // ~/.bashrc + // + if let Some(system_rc) = crate::sys::fs::get_system_rc_path() { + self.source_if_exists(system_rc, ¶ms).await?; + } + if let Some(home_path) = self.home_dir() { + self.source_if_exists(home_path.join(".bashrc").as_path(), ¶ms) + .await?; + self.source_if_exists(home_path.join(".brushrc").as_path(), ¶ms) + .await?; + } + } + } + } else { + let env_var_name = if self.options.sh_mode { + "ENV" + } else { + "BASH_ENV" + }; + + if self.env.is_set(env_var_name) { + // + // TODO(well-known-vars): look at $ENV/BASH_ENV; source its expansion if that + // file exists + // + return error::unimp( + "load config from $ENV/BASH_ENV for non-interactive, non-login shell", + ); + } + } + } + + Ok(()) + } +} diff --git a/local/recipes/shells/brush/source/brush-core/src/shell/io.rs b/local/recipes/shells/brush/source/brush-core/src/shell/io.rs new file mode 100644 index 0000000000..9037891754 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/shell/io.rs @@ -0,0 +1,89 @@ +//! I/O support for shell instances. + +use std::io::Write; + +use crate::{error, extensions, ioutils}; + +impl crate::Shell { + /// Returns a value that can be used to write to the shell's currently configured + /// standard output stream using `write!` et al. + pub fn stdout(&self) -> impl std::io::Write + 'static { + self.open_files.try_stdout().cloned().unwrap_or_else(|| { + ioutils::FailingReaderWriter::new("standard output not available").into() + }) + } + + /// Returns a value that can be used to write to the shell's currently configured + /// standard error stream using `write!` et al. + pub fn stderr(&self) -> impl std::io::Write + 'static { + self.open_files.try_stderr().cloned().unwrap_or_else(|| { + ioutils::FailingReaderWriter::new("standard error not available").into() + }) + } + + /// Outputs `set -x` style trace output for a command. Intentionally does not return + /// a result or error to avoid risk that a caller treats an error as fatal. Tracing + /// failure should generally always be ignored to avoid interfering with execution + /// flows. + /// + /// # Arguments + /// + /// * `command` - The command to trace. + pub(crate) async fn trace_command>( + &mut self, + params: &crate::interp::ExecutionParameters, + command: S, + ) { + // Expand the PS4 prompt variable to get our prefix. + let mut prefix = self + .as_mut() + .expand_prompt_var("PS4", "") + .await + .unwrap_or_default(); + + // Add additional depth-based prefixes using the first character of PS4. + let additional_depth = self.call_stack.script_source_depth() + self.depth; + if let Some(c) = prefix.chars().next() { + for _ in 0..additional_depth { + prefix.insert(0, c); + } + } + + // Resolve which file descriptor to use for tracing. We default to stderr, + // but if BASH_XTRACEFD is set and refers to a valid file descriptor, use that instead. + let trace_file = if let Some((_, xtracefd_var)) = self.env.get("BASH_XTRACEFD") + && let Ok(fd) = xtracefd_var + .value() + .to_cow_str(self) + .parse::() + && let Some(file) = self.open_files.try_fd(fd) + { + Some(file.clone()) + } else { + params.try_stderr(self) + }; + + // If we have a valid trace file, write to it. + if let Some(mut trace_file) = trace_file { + let _ = writeln!(trace_file, "{prefix}{}", command.as_ref()); + } + } + + /// Displays the given error to the user, using the shell's error display mechanisms. + /// + /// # Arguments + /// + /// * `file_table` - The open file table to use for any file descriptor references. + /// * `err` - The error to display. + pub fn display_error( + &self, + file: &mut impl std::io::Write, + err: &error::Error, + ) -> Result<(), error::Error> { + use crate::extensions::ErrorFormatter as _; + let str = self.error_formatter.format_error(err, self); + write!(file, "{str}")?; + + Ok(()) + } +} diff --git a/local/recipes/shells/brush/source/brush-core/src/shell/job_control.rs b/local/recipes/shells/brush/source/brush-core/src/shell/job_control.rs new file mode 100644 index 0000000000..2ddb350aea --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/shell/job_control.rs @@ -0,0 +1,20 @@ +//! Job management for shell instances. + +use std::io::Write; + +use crate::{error, extensions}; + +impl crate::Shell { + /// Checks for completed jobs in the shell, reporting any changes found. + pub fn check_for_completed_jobs(&mut self) -> Result<(), error::Error> { + let results = self.jobs.poll()?; + + if self.options.enable_job_control { + for (job, _result) in results { + writeln!(self.stderr(), "{job}")?; + } + } + + Ok(()) + } +} diff --git a/local/recipes/shells/brush/source/brush-core/src/shell/parsing.rs b/local/recipes/shells/brush/source/brush-core/src/shell/parsing.rs new file mode 100644 index 0000000000..b379a6b8cb --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/shell/parsing.rs @@ -0,0 +1,64 @@ +//! Parsing for shell instances. + +use std::io::Read; + +use crate::{Shell, extensions, trace_categories}; + +impl Shell { + /// Parses the given reader as a shell program, returning the resulting Abstract Syntax Tree + /// for the program. + pub fn parse( + &self, + reader: R, + ) -> Result { + let mut parser = create_parser(reader, &self.parser_options()); + + tracing::debug!(target: trace_categories::PARSE, "Parsing reader as program..."); + parser.parse_program() + } + + /// Parses the given string as a shell program, returning the resulting Abstract Syntax Tree + /// for the program. + /// + /// # Arguments + /// + /// * `s` - The string to parse as a program. + pub fn parse_string>( + &self, + s: S, + ) -> Result { + parse_string_impl(s.into(), self.parser_options()) + } + + /// Returns the options that should be used for parsing shell programs; reflects + /// the current configuration state of the shell and may change over time. + pub const fn parser_options(&self) -> brush_parser::ParserOptions { + brush_parser::ParserOptions { + enable_extended_globbing: self.options.extended_globbing, + posix_mode: self.options.posix_mode, + sh_mode: self.options.sh_mode, + tilde_expansion_at_word_start: true, + tilde_expansion_after_colon: false, + parser_impl: self.parser_impl, + } + } +} + +#[cached::proc_macro::cached(size = 64, result = true)] +fn parse_string_impl( + s: String, + parser_options: brush_parser::ParserOptions, +) -> Result { + let mut parser = create_parser(s.as_bytes(), &parser_options); + + tracing::debug!(target: trace_categories::PARSE, "Parsing string as program..."); + parser.parse_program() +} + +pub(super) fn create_parser( + r: R, + parser_options: &brush_parser::ParserOptions, +) -> brush_parser::Parser> { + let reader = std::io::BufReader::new(r); + brush_parser::Parser::new(reader, parser_options) +} diff --git a/local/recipes/shells/brush/source/brush-core/src/shell/prompts.rs b/local/recipes/shells/brush/source/brush-core/src/shell/prompts.rs new file mode 100644 index 0000000000..f14696adf2 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/shell/prompts.rs @@ -0,0 +1,80 @@ +//! Prompt handling for shell instances. + +use std::borrow::Cow; + +use crate::{Shell, error, extensions, prompt}; + +impl Shell { + /// Returns the default prompt string for the shell. + const fn default_prompt(&self) -> &'static str { + if self.options.sh_mode { + "$ " + } else { + "brush$ " + } + } + + /// Composes the shell's post-input, pre-command prompt, applying all appropriate expansions. + pub async fn compose_precmd_prompt(&mut self) -> Result { + self.expand_prompt_var("PS0", "").await + } + + /// Composes the shell's prompt, applying all appropriate expansions. + pub async fn compose_prompt(&mut self) -> Result { + self.expand_prompt_var("PS1", self.default_prompt()).await + } + + /// Composes the shell's alternate-side prompt, applying all appropriate expansions. + pub async fn compose_alt_side_prompt(&mut self) -> Result { + // This is a brush extension. + self.expand_prompt_var("BRUSH_PS_ALT", "").await + } + + /// Composes the shell's continuation prompt. + pub async fn compose_continuation_prompt(&mut self) -> Result { + self.expand_prompt_var("PS2", "> ").await + } + + pub(super) async fn expand_prompt_var( + &mut self, + var_name: &str, + default: &str, + ) -> Result { + // + // TODO(prompt): bash appears to do this in a subshell; we need to investigate + // if that's required. + // + + // Retrieve the spec. + let prompt_spec = self.parameter_or_default(var_name, default); + if prompt_spec.is_empty() { + return Ok(String::new()); + } + + // Save (and later restore) the last exit status, change count included, so + // that expanding here is invisible to `$?`. + let prev_last_result = self.last_exit_status(); + let prev_last_result_change_count = self.last_exit_status_change_count; + let prev_last_pipeline_statuses = self.last_pipeline_statuses.clone(); + + // Expand it. + let params = self.default_exec_params(); + let result = prompt::expand_prompt(self, ¶ms, prompt_spec.into_owned()).await; + + // Restore the last exit status. + self.last_pipeline_statuses = prev_last_pipeline_statuses; + self.set_last_exit_status(prev_last_result); + self.last_exit_status_change_count = prev_last_result_change_count; + + // Strip out special characters that readline would typically drop: + // \001 and \002 (start and end of non-printing sequences). + let mut expanded = result?; + expanded.retain(|c| c != '\x01' && c != '\x02'); + + Ok(expanded) + } + + fn parameter_or_default<'a>(&'a self, name: &str, default: &'a str) -> Cow<'a, str> { + self.env_str(name).unwrap_or_else(|| default.into()) + } +} diff --git a/local/recipes/shells/brush/source/brush-core/src/shell/readline.rs b/local/recipes/shells/brush/source/brush-core/src/shell/readline.rs new file mode 100644 index 0000000000..aaefb56638 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/shell/readline.rs @@ -0,0 +1,42 @@ +//! Readline edit buffer support for shell instances. + +use crate::{error, extensions, variables::ShellVariable}; + +impl crate::Shell { + /// Updates the shell state to reflect the given edit buffer contents. + /// + /// # Arguments + /// + /// * `contents` - The contents of the edit buffer. + /// * `cursor` - The cursor position in the edit buffer. + pub fn set_edit_buffer(&mut self, contents: String, cursor: usize) -> Result<(), error::Error> { + self.env + .set_global("READLINE_LINE", ShellVariable::new(contents))?; + + self.env + .set_global("READLINE_POINT", ShellVariable::new(cursor.to_string()))?; + + Ok(()) + } + + /// Returns the contents of the shell's edit buffer, if any. The buffer + /// state is cleared from the shell. + pub fn pop_edit_buffer(&mut self) -> Result, error::Error> { + let line = self + .env + .unset("READLINE_LINE")? + .map(|line| line.value().to_cow_str(self).to_string()); + + let point = self + .env + .unset("READLINE_POINT")? + .and_then(|point| point.value().to_cow_str(self).parse::().ok()) + .unwrap_or(0); + + if let Some(line) = line { + Ok(Some((line, point))) + } else { + Ok(None) + } + } +} diff --git a/local/recipes/shells/brush/source/brush-core/src/shell/state.rs b/local/recipes/shells/brush/source/brush-core/src/shell/state.rs new file mode 100644 index 0000000000..fb5ee5c4c0 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/shell/state.rs @@ -0,0 +1,124 @@ +//! Defines state traits for the shell. + +use std::{ + borrow::Cow, + collections::HashMap, + path::{Path, PathBuf}, +}; + +use crate::{ + completion, env::ShellEnvironment, jobs, openfiles, options::RuntimeOptions, pathcache, + shell::KeyBindingsHelper, +}; + +/// A dyn-safe trait for constrained access to shell state. +pub trait ShellState { + /// Returns whether or not this shell is a subshell. + fn is_subshell(&self) -> bool; + + /// Returns the last "SECONDS" captured time. + fn last_stopwatch_time(&self) -> std::time::SystemTime; + + /// Returns the last "SECONDS" offset requested. + fn last_stopwatch_offset(&self) -> u32; + + /// Returns the shell environment containing variables. + fn env(&self) -> &ShellEnvironment; + + /// Returns a mutable reference to the shell environment. + fn env_mut(&mut self) -> &mut ShellEnvironment; + + /// Returns the shell's runtime options. + fn options(&self) -> &RuntimeOptions; + + /// Returns a mutable reference to the shell's runtime options. + fn options_mut(&mut self) -> &mut RuntimeOptions; + + /// Returns the shell's aliases. + fn aliases(&self) -> &HashMap; + + /// Returns a mutable reference to the shell's aliases. + fn aliases_mut(&mut self) -> &mut HashMap; + + /// Returns the shell's job manager. + fn jobs(&self) -> &jobs::JobManager; + + /// Returns a mutable reference to the shell's job manager. + fn jobs_mut(&mut self) -> &mut jobs::JobManager; + + /// Returns the shell's trap handler configuration. + fn traps(&self) -> &crate::traps::TrapHandlerConfig; + + /// Returns a mutable reference to the shell's trap handler configuration. + fn traps_mut(&mut self) -> &mut crate::traps::TrapHandlerConfig; + + /// Returns the shell's directory stack. + fn directory_stack(&self) -> &[PathBuf]; + + /// Returns a mutable reference to the shell's directory stack. + fn directory_stack_mut(&mut self) -> &mut Vec; + + /// Returns the statuses of commands in the last pipeline. + fn last_pipeline_statuses(&self) -> &[u8]; + + /// Returns a mutable reference to the statuses of commands in the last pipeline. + fn last_pipeline_statuses_mut(&mut self) -> &mut Vec; + + /// Returns the shell's program location cache. + fn program_location_cache(&self) -> &pathcache::PathCache; + + /// Returns a mutable reference to the shell's program location cache. + fn program_location_cache_mut(&mut self) -> &mut pathcache::PathCache; + + /// Returns the shell's completion configuration. + fn completion_config(&self) -> &completion::Config; + + /// Returns a mutable reference to the shell's completion configuration. + fn completion_config_mut(&mut self) -> &mut completion::Config; + + /// Returns the shell's open files. + fn open_files(&self) -> &openfiles::OpenFiles; + + /// Returns a mutable reference to the shell's open files. + fn open_files_mut(&mut self) -> &mut openfiles::OpenFiles; + + /// Returns the *current* name of the shell ($0). + fn current_shell_name(&self) -> Option>; + + /// Returns the current subshell depth; 0 is returned if this shell is not a subshell. + fn depth(&self) -> usize; + + /// Returns the call stack for the shell. + fn call_stack(&self) -> &crate::callstack::CallStack; + + /// Returns the shell's history, if it exists. + fn history(&self) -> Option<&crate::history::History>; + + /// Returns a mutable reference to the shell's history, if it exists. + fn history_mut(&mut self) -> Option<&mut crate::history::History>; + + /// Returns the shell's official version string (if available). + fn version(&self) -> Option<&str>; + + /// Returns the exit status of the last command executed in this shell. + fn last_exit_status(&self) -> u8; + + /// Updates the last exit status. + fn set_last_exit_status(&mut self, status: u8); + + /// Returns the key bindings helper for the shell. + fn key_bindings(&self) -> Option<&KeyBindingsHelper>; + + /// Sets the key bindings helper for the shell. + fn set_key_bindings(&mut self, key_bindings: Option); + + /// Returns the shell's current working directory. + fn working_dir(&self) -> &Path; + + /// Returns a mutable reference to the shell's current working directory. + /// This is only accessible within the crate. + fn working_dir_mut(&mut self) -> &mut PathBuf; + + /// Returns the product display name for this shell. + fn product_display_str(&self) -> Option<&str>; +} diff --git a/local/recipes/shells/brush/source/brush-core/src/shell/traps.rs b/local/recipes/shells/brush/source/brush-core/src/shell/traps.rs new file mode 100644 index 0000000000..83c4400d0c --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/shell/traps.rs @@ -0,0 +1,105 @@ +//! Trap handling for the shell. + +use crate::{ExecutionParameters, ExecutionResult, ProcessGroupPolicy, error, traps::TrapSignal}; + +impl crate::Shell { + /// Runs any exit steps for the shell. + /// + /// This currently includes invoking the `EXIT` trap handler, if any. + pub async fn on_exit(&mut self) -> Result<(), error::Error> { + if self.traps.handles(TrapSignal::Exit) { + self.invoke_trap_handler(TrapSignal::Exit, &self.default_exec_params()) + .await?; + } + + Ok(()) + } + + /// Invokes the handler registered for `signal`, if any. + /// + /// Behavior varies by signal type: + /// + /// * **Per-signal recursion guard** — each trap guards against its own self-recursion, but + /// different traps *can* fire from within each other's handlers (matching bash semantics). + /// + /// * **Inheritance** — in functions and subshells, some traps are only inherited when the + /// corresponding shell option is enabled (e.g. `errtrace` / `set -E` for `ERR`, `functrace` / + /// `set -T` for `DEBUG`/`RETURN`). + /// + /// * **`$?` preservation** — `last_exit_status` is saved before and restored after the handler + /// runs so the trap does not clobber the status that triggered it. + /// + /// # Arguments + /// + /// * `signal`: Signal to run handler for. + /// + /// * `params`: Execution parameters to use for handler. + pub(crate) async fn invoke_trap_handler( + &mut self, + signal: TrapSignal, + params: &ExecutionParameters, + ) -> Result { + // Per-signal self-recursion guard: don't re-enter a trap that is + // already being handled. Different traps *can* fire from each + // other's handlers (e.g. ERR inside EXIT, EXIT inside ERR). + if self.call_stack().is_trap_signal_active(signal) { + return Ok(ExecutionResult::success()); + } + + // Don't fire traps that have been explicitly suppressed (e.g. DEBUG + // during programmable completion). + if self.call_stack().is_trap_delivery_suppressed() { + return Ok(ExecutionResult::success()); + } + + // In functions and subshells, some traps are only inherited when the + // corresponding option is enabled. + if (self.in_function() || self.is_subshell()) + && !self.is_trap_inherited_in_current_scope(signal) + { + return Ok(ExecutionResult::success()); + } + + let Some(handler) = self.traps.get_handler(signal).cloned() else { + return Ok(ExecutionResult::success()); + }; + + let mut params = params.clone(); + params.process_group_policy = ProcessGroupPolicy::SameProcessGroup; + + // Preserve $? across trap handler execution so the handler doesn't + // clobber the status that triggered it. + let orig_last_exit_status = self.last_exit_status; + + // N.B. We use manual enter/leave rather than an RAII guard because a guard + // would need to hold `&mut Shell`, preventing the mutable borrow required by + // `run_string()`. This is safe because `result` is captured into a variable + // (never early-returned with `?`), so `leave_trap_handler()` always runs. + self.enter_trap_handler(signal, Some(&handler)); + + let result = self + .run_string(&handler.command, &handler.source_info, ¶ms) + .await; + + self.leave_trap_handler(); + self.last_exit_status = orig_last_exit_status; + + result + } + + /// Returns whether the given trap signal is inherited in the current + /// function or subshell scope. + fn is_trap_inherited_in_current_scope(&self, signal: TrapSignal) -> bool { + match signal { + TrapSignal::Err => self.options().shell_functions_inherit_err_trap, + TrapSignal::Debug | TrapSignal::Return => { + self.options() + .shell_functions_inherit_debug_and_return_traps + } + // EXIT and system signals are always inherited — i.e. their visibility is + // not gated by errtrace/functrace options. (The actual trap *state* for + // subshells is managed separately via `Shell::clone`.) + TrapSignal::Exit | TrapSignal::Signal(_) => true, + } + } +} diff --git a/local/recipes/shells/brush/source/brush-core/src/sourceinfo.rs b/local/recipes/shells/brush/source/brush-core/src/sourceinfo.rs new file mode 100644 index 0000000000..909b7188f8 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/sourceinfo.rs @@ -0,0 +1,44 @@ +//! Source info. + +use std::{path::PathBuf, sync::Arc}; + +/// Source context. +#[derive(Clone, Debug, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct SourceInfo { + /// The name of the source. + pub source: String, + /// Optionally indicates a starting location after the beginning of the source. + /// If `None`, the start is the beginning of the source. + pub start: Option>, +} + +impl From<&str> for SourceInfo { + fn from(source: &str) -> Self { + Self { + source: source.to_owned(), + start: None, + } + } +} + +impl From for SourceInfo { + fn from(path: PathBuf) -> Self { + Self { + source: path.to_string_lossy().to_string(), + start: None, + } + } +} + +impl std::fmt::Display for SourceInfo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.source)?; + + if let Some(pos) = &self.start { + write!(f, ":{},{}", pos.line, pos.column)?; + } + + Ok(()) + } +} diff --git a/local/recipes/shells/brush/source/brush-core/src/sys.rs b/local/recipes/shells/brush/source/brush-core/src/sys.rs new file mode 100644 index 0000000000..8e8f41ed8c --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/sys.rs @@ -0,0 +1,43 @@ +//! Platform abstraction facilities + +#![allow(unused)] + +#[cfg(unix)] +pub(crate) mod unix; +#[cfg(unix)] +pub(crate) use unix as platform; + +#[cfg(windows)] +pub(crate) mod windows; +#[cfg(windows)] +pub(crate) use windows as platform; + +#[cfg(target_family = "wasm")] +pub(crate) mod wasm; +#[cfg(target_family = "wasm")] +pub(crate) use wasm as platform; + +#[cfg(not(unix))] +pub(crate) mod stubs; + +#[cfg(any(unix, windows))] +pub(crate) mod hostname; +#[cfg(any(unix, windows))] +pub mod tokio_process; + +pub mod fs; + +pub use platform::async_pipe; +pub use platform::commands; +pub(crate) use platform::env; +pub use platform::fd; +pub use platform::input; +pub(crate) use platform::network; +pub use platform::poll; +pub use platform::process; +pub use platform::resource; +pub use platform::signal; +pub use platform::terminal; +pub(crate) use platform::users; + +pub use platform::PlatformError; diff --git a/local/recipes/shells/brush/source/brush-core/src/sys/fs.rs b/local/recipes/shells/brush/source/brush-core/src/sys/fs.rs new file mode 100644 index 0000000000..a5dabfa258 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/sys/fs.rs @@ -0,0 +1,37 @@ +//! Filesystem utilities + +pub use super::platform::fs::*; + +/// Extension trait for path-related filesystem operations. +pub trait PathExt { + /// Returns true if the path exists and is readable by the current user. + fn readable(&self) -> bool; + /// Returns true if the path exists and is writable by the current user. + fn writable(&self) -> bool; + /// Returns true if the path exists and is executable by the current user. + /// + /// On Windows, this returns true if *either* the path itself is a file with + /// a `PATHEXT` extension *or* appending some `PATHEXT` extension resolves + /// to an existing file. To recover the actual on-disk path in the + /// latter case, use [`resolve_executable`] which takes ownership + /// and avoids copies on platforms where no resolution is needed. + fn executable(&self) -> bool; + + /// Returns true if the path exists and is a block device. + fn exists_and_is_block_device(&self) -> bool; + /// Returns true if the path exists and is a character device. + fn exists_and_is_char_device(&self) -> bool; + /// Returns true if the path exists and is a FIFO (named pipe). + fn exists_and_is_fifo(&self) -> bool; + /// Returns true if the path exists and is a socket. + fn exists_and_is_socket(&self) -> bool; + /// Returns true if the path exists and has the setgid bit set. + fn exists_and_is_setgid(&self) -> bool; + /// Returns true if the path exists and has the setuid bit set. + fn exists_and_is_setuid(&self) -> bool; + /// Returns true if the path exists and has the sticky bit set. + fn exists_and_is_sticky_bit(&self) -> bool; + + /// Returns the device ID and inode number for the path. + fn get_device_and_inode(&self) -> Result<(u64, u64), crate::error::Error>; +} diff --git a/local/recipes/shells/brush/source/brush-core/src/sys/hostname.rs b/local/recipes/shells/brush/source/brush-core/src/sys/hostname.rs new file mode 100644 index 0000000000..422b880c06 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/sys/hostname.rs @@ -0,0 +1,3 @@ +pub(crate) fn get() -> std::io::Result { + hostname::get() +} diff --git a/local/recipes/shells/brush/source/brush-core/src/sys/stubs.rs b/local/recipes/shells/brush/source/brush-core/src/sys/stubs.rs new file mode 100644 index 0000000000..db043e487a --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/sys/stubs.rs @@ -0,0 +1,22 @@ +#![allow(dead_code)] +#![allow(clippy::missing_const_for_fn)] +#![allow(clippy::needless_pass_by_ref_mut)] +#![allow(clippy::needless_pass_by_value)] +#![allow(clippy::unnecessary_wraps)] +#![allow(clippy::unused_async)] +#![allow(clippy::unused_self)] + +pub mod async_pipe; +pub mod commands; +pub(crate) mod env; +pub mod fd; +pub mod fs; +pub mod input; +pub(crate) mod network; +pub(crate) mod pipes; +pub mod poll; +pub mod process; +pub mod resource; +pub mod signal; +pub mod terminal; +pub(crate) mod users; diff --git a/local/recipes/shells/brush/source/brush-core/src/sys/stubs/async_pipe.rs b/local/recipes/shells/brush/source/brush-core/src/sys/stubs/async_pipe.rs new file mode 100644 index 0000000000..6b6ca14c46 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/sys/stubs/async_pipe.rs @@ -0,0 +1,30 @@ +//! Async pipe reading utilities for non-Unix platforms. +//! +//! Uses `spawn_blocking` internally for the I/O operation only, +//! not for the entire subshell execution. + +use std::io::{self, Read}; + +pub(crate) struct AsyncPipeReader { + inner: Option, +} + +impl AsyncPipeReader { + pub(crate) fn new(fd: std::io::PipeReader) -> io::Result { + Ok(Self { inner: Some(fd) }) + } + + pub(crate) async fn read_to_string(&mut self) -> io::Result { + let Some(reader) = self.inner.take() else { + return Ok(String::new()); + }; + + tokio::task::spawn_blocking(move || { + let mut s = String::new(); + { reader }.read_to_string(&mut s)?; + Ok(s) + }) + .await + .map_err(io::Error::other)? + } +} diff --git a/local/recipes/shells/brush/source/brush-core/src/sys/stubs/commands.rs b/local/recipes/shells/brush/source/brush-core/src/sys/stubs/commands.rs new file mode 100644 index 0000000000..03e9a3a7eb --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/sys/stubs/commands.rs @@ -0,0 +1,95 @@ +//! Command execution utilities. + +use std::ffi::OsStr; + +use crate::{ShellFd, error, openfiles}; + +/// Extension trait for Unix-like command extensions. +pub trait CommandExt { + /// Sets the zeroth argument (argv[0]) of the command. + /// + /// # Arguments + /// + /// * `arg` - The argument to set as argv[0]. + fn arg0(&mut self, arg: S) -> &mut Self + where + S: AsRef; + + /// Sets the process group ID of the command. + /// + /// # Arguments + /// + /// * `pgroup` - The process group ID to set. + fn process_group(&mut self, pgroup: i32) -> &mut Self; +} + +impl CommandExt for std::process::Command { + fn arg0(&mut self, _arg: S) -> &mut Self + where + S: AsRef, + { + // NOTE: no-op. + self + } + + fn process_group(&mut self, _pgroup: i32) -> &mut Self { + // NOTE: no-op. + self + } +} + +/// Extension trait for Unix-like exit status extensions. +pub trait ExitStatusExt { + /// Returns the signal that terminated the process, if any. + fn signal(&self) -> Option; +} + +impl ExitStatusExt for std::process::ExitStatus { + fn signal(&self) -> Option { + None + } +} + +/// Extension trait for injecting file descriptors into commands. +pub trait CommandFdInjectionExt { + /// Injects the given open files as file descriptors into the command. + /// + /// # Arguments + /// + /// * `open_files` - A mapping of child file descriptors to open files. + fn inject_fds( + &mut self, + open_files: impl Iterator, + ) -> Result<(), error::Error>; +} + +impl CommandFdInjectionExt for std::process::Command { + fn inject_fds( + &mut self, + mut open_files: impl Iterator, + ) -> Result<(), error::Error> { + if open_files.next().is_some() { + return Err(error::ErrorKind::NotSupportedOnThisPlatform("fd redirections").into()); + } + + Ok(()) + } +} + +/// Extension trait for arranging for commands to take the foreground. +pub trait CommandFgControlExt { + /// Arranges for the command to take the foreground when it is executed. + fn take_foreground(&mut self); + /// Arranges for the command to become a session leader when it is executed. + fn lead_session(&mut self); +} + +impl CommandFgControlExt for std::process::Command { + fn take_foreground(&mut self) { + // NOTE: This is a no-op. + } + + fn lead_session(&mut self) { + // NOTE: This is a no-op. + } +} diff --git a/local/recipes/shells/brush/source/brush-core/src/sys/stubs/env.rs b/local/recipes/shells/brush/source/brush-core/src/sys/stubs/env.rs new file mode 100644 index 0000000000..5666fafecd --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/sys/stubs/env.rs @@ -0,0 +1,8 @@ +//! Environment variable retrieval (stub implementation). + +/// Retrieves environment variables from the host process. +/// +/// Stub implementation that returns no variables. +pub(crate) fn get_host_env_vars() -> impl Iterator { + std::iter::empty() +} diff --git a/local/recipes/shells/brush/source/brush-core/src/sys/stubs/fd.rs b/local/recipes/shells/brush/source/brush-core/src/sys/stubs/fd.rs new file mode 100644 index 0000000000..88ad541776 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/sys/stubs/fd.rs @@ -0,0 +1,13 @@ +//! File descriptor utilities. + +use crate::{ShellFd, error, openfiles}; + +/// Stub implementation for platforms that do not support enumerating file descriptors. +pub fn try_iter_open_fds() -> impl Iterator { + std::iter::empty() +} + +/// Stub implementation for platforms that do not support opening file descriptors. +pub fn try_get_file_for_open_fd(_fd: ShellFd) -> Option { + None +} diff --git a/local/recipes/shells/brush/source/brush-core/src/sys/stubs/fs.rs b/local/recipes/shells/brush/source/brush-core/src/sys/stubs/fs.rs new file mode 100644 index 0000000000..13a500b456 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/sys/stubs/fs.rs @@ -0,0 +1,136 @@ +//! Filesystem utilities (stubs). + +use crate::error; + +pub(crate) trait MetadataExt { + fn gid(&self) -> u32 { + 0 + } + + fn uid(&self) -> u32 { + 0 + } +} + +impl MetadataExt for std::fs::Metadata {} + +pub(crate) fn get_default_executable_search_paths() -> Vec { + vec![] +} + +/// Returns the default paths where standard Unix utilities are typically installed. +/// This is a stub implementation that returns an empty vector. +pub fn get_default_standard_utils_paths() -> Vec { + vec![] +} + +/// Opens a null file that will discard all I/O. +/// +/// This is a stub implementation that returns an error. +pub fn open_null_file() -> Result { + Err(error::ErrorKind::NotSupportedOnThisPlatform("opening null file").into()) +} + +/// Gives the platform an opportunity to handle a special file path (e.g. `/dev/null`). +// +// This is a stub implementation that returns no result. +pub fn try_open_special_file( + _path: &std::path::Path, +) -> Option> { + None +} + +/// Returns the path to the system-wide shell profile script. +/// +/// Stub implementation that returns `None`. +pub fn get_system_profile_path() -> Option<&'static std::path::Path> { + None +} + +/// Returns the path to the system-wide shell rc script. +/// +/// Stub implementation that returns `None`. +pub fn get_system_rc_path() -> Option<&'static std::path::Path> { + None +} + +/// Returns the platform default for case-insensitive pathname expansion. +/// +/// In the stub implementation, this returns `false`. +pub const fn default_case_insensitive_path_expansion() -> bool { + false +} + +/// Returns true if the string contains a path separator character. +/// +/// In the stub implementation, only `/` is considered a path separator. +pub fn contains_path_separator(s: &str) -> bool { + s.contains('/') +} + +/// Returns true if the string ends with a path separator character. +/// +/// In the stub implementation, only `/` is considered a path separator. +pub fn ends_with_path_separator(s: &str) -> bool { + s.ends_with('/') +} + +/// Returns the string with a trailing path separator removed, if present. +/// +/// In the stub implementation, only `/` is considered a path separator. +pub fn strip_path_separator_suffix(s: &str) -> &str { + s.strip_suffix('/').unwrap_or(s) +} + +/// Finds the byte index of the last path separator in the string. +/// +/// In the stub implementation, only `/` is considered a path separator. +pub fn rfind_path_separator(s: &str) -> Option { + s.rfind('/') +} + +/// Splits a string on path separator characters, returning an iterator of components. +/// +/// In the stub implementation, only `/` is used as a separator. +pub fn split_path_for_pattern(s: &str) -> impl Iterator { + s.split('/') +} + +/// Returns the root path for an absolute pattern, if the first component indicates one. +/// +/// In the stub implementation, an empty first component indicates an absolute path. +pub fn pattern_path_root(first_component: &str) -> Option { + if first_component.is_empty() { + Some(std::path::PathBuf::from("/")) + } else { + None + } +} + +/// Pushes a component onto a path for pattern expansion. +/// +/// In the stub implementation, this delegates directly to `PathBuf::push`. +pub fn push_path_for_pattern(path: &mut std::path::PathBuf, component: &str) { + path.push(component); +} + +/// Normalizes path separators for shell output. +/// +/// In the stub implementation, this is a no-op. +pub const fn normalize_path_separators(s: &str) -> std::borrow::Cow<'_, str> { + std::borrow::Cow::Borrowed(s) +} + +/// Resolves an owned path to the actual on-disk executable file, if any. +/// +/// In the stub implementation, returns the path unchanged if it is +/// executable (per the stub `PathExt`, which considers every path +/// executable). +pub fn resolve_executable(path: std::path::PathBuf) -> Option { + use crate::sys::fs::PathExt; + if path.as_path().executable() { + Some(path) + } else { + None + } +} diff --git a/local/recipes/shells/brush/source/brush-core/src/sys/stubs/input.rs b/local/recipes/shells/brush/source/brush-core/src/sys/stubs/input.rs new file mode 100644 index 0000000000..a83795e562 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/sys/stubs/input.rs @@ -0,0 +1,16 @@ +//! Terminal input utilities + +use crate::{error, interfaces}; + +/// Translates a key code (byte sequence) into a `Key` enum value. Returns `None` +/// if the key code is not recognized. +/// +/// This is a stub implementation that recognizes single-byte non-control +/// characters but does not support terminal-specific key sequences. +pub fn try_get_key_from_key_code(key_code: &[u8]) -> Option { + if key_code.len() == 1 && !key_code[0].is_ascii_control() { + Some(interfaces::Key::Character(key_code[0] as char)) + } else { + None + } +} diff --git a/local/recipes/shells/brush/source/brush-core/src/sys/stubs/network.rs b/local/recipes/shells/brush/source/brush-core/src/sys/stubs/network.rs new file mode 100644 index 0000000000..73ed3156f8 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/sys/stubs/network.rs @@ -0,0 +1,3 @@ +pub(crate) fn get_hostname() -> std::io::Result { + Ok("".into()) +} diff --git a/local/recipes/shells/brush/source/brush-core/src/sys/stubs/pipes.rs b/local/recipes/shells/brush/source/brush-core/src/sys/stubs/pipes.rs new file mode 100644 index 0000000000..b4a50a4031 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/sys/stubs/pipes.rs @@ -0,0 +1,53 @@ +/// Stub implementation of a pipe reader. +#[derive(Clone)] +pub(crate) struct PipeReader {} + +impl PipeReader { + /// Tries to clone the reader. + pub fn try_clone(&self) -> std::io::Result { + Ok((*self).clone()) + } +} + +impl From for std::process::Stdio { + fn from(_reader: PipeReader) -> Self { + Self::null() + } +} + +impl std::io::Read for PipeReader { + fn read(&mut self, _buf: &mut [u8]) -> std::io::Result { + Ok(0) + } +} + +/// Stub implementation o a pipe writer. +#[derive(Clone)] +pub(crate) struct PipeWriter {} + +impl PipeWriter { + /// Tries to clone the writer. + pub fn try_clone(&self) -> std::io::Result { + Ok((*self).clone()) + } +} + +impl From for std::process::Stdio { + fn from(_writer: PipeWriter) -> Self { + Self::null() + } +} + +impl std::io::Write for PipeWriter { + fn write(&mut self, _buf: &[u8]) -> std::io::Result { + Ok(0) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +pub(crate) fn pipe() -> std::io::Result<(PipeReader, PipeWriter)> { + Ok((PipeReader {}, PipeWriter {})) +} diff --git a/local/recipes/shells/brush/source/brush-core/src/sys/stubs/poll.rs b/local/recipes/shells/brush/source/brush-core/src/sys/stubs/poll.rs new file mode 100644 index 0000000000..4877d2948f --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/sys/stubs/poll.rs @@ -0,0 +1,15 @@ +//! Stub file descriptor polling utilities for platforms without poll support. + +use std::time::Duration; + +use crate::openfiles::OpenFile; + +/// Stub implementation that always returns an unsupported error. +/// +/// Timeout-based reading is not supported on this platform. +pub fn poll_for_input(_file: &OpenFile, _timeout: Duration) -> std::io::Result { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "poll-based timeout is not supported on this platform", + )) +} diff --git a/local/recipes/shells/brush/source/brush-core/src/sys/stubs/process.rs b/local/recipes/shells/brush/source/brush-core/src/sys/stubs/process.rs new file mode 100644 index 0000000000..dfb76113be --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/sys/stubs/process.rs @@ -0,0 +1,39 @@ +//! Process management utilities + +pub(crate) type ProcessId = i32; + +/// Provides access to a child process. +pub struct Child { + inner: std::process::Child, +} + +pub(crate) use std::process::ExitStatus; +pub(crate) use std::process::Output; + +impl Child { + /// Returns the process ID of the child process, if available. + pub fn id(&self) -> Option { + None + } + + /// Asynchronously waits for the child process to exit. + pub async fn wait(&mut self) -> std::io::Result { + self.inner.wait() + } + + /// Asynchronously waits for the child process to exit and collects its + /// output. + pub async fn wait_with_output(self) -> std::io::Result { + self.inner.wait_with_output() + } +} + +pub(crate) fn spawn( + mut command: std::process::Command, + kill_on_drop: bool, +) -> std::io::Result { + // No kill-on-drop on this stub platform; accepted and ignored. + let _ = kill_on_drop; + let child = command.spawn()?; + Ok(Child { inner: child }) +} diff --git a/local/recipes/shells/brush/source/brush-core/src/sys/stubs/resource.rs b/local/recipes/shells/brush/source/brush-core/src/sys/stubs/resource.rs new file mode 100644 index 0000000000..756e0230fa --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/sys/stubs/resource.rs @@ -0,0 +1,19 @@ +//! Signal processing utilities + +use crate::error; + +/// Returns the user and system CPU time used by the current process. +/// +/// This is a stub implementation that returns zero durations. +pub fn get_self_user_and_system_time() +-> Result<(std::time::Duration, std::time::Duration), error::Error> { + Ok((std::time::Duration::ZERO, std::time::Duration::ZERO)) +} + +/// Returns the user and system CPU time used by child processes. +/// +/// This is a stub implementation that returns zero durations. +pub fn get_children_user_and_system_time() +-> Result<(std::time::Duration, std::time::Duration), error::Error> { + Ok((std::time::Duration::ZERO, std::time::Duration::ZERO)) +} diff --git a/local/recipes/shells/brush/source/brush-core/src/sys/stubs/signal.rs b/local/recipes/shells/brush/source/brush-core/src/sys/stubs/signal.rs new file mode 100644 index 0000000000..24cab4e818 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/sys/stubs/signal.rs @@ -0,0 +1,84 @@ +//! Signal processing utilities + +use crate::{error, sys, traps}; + +/// A stub enum representing system signals on unsupported platforms. +#[allow(unnameable_types)] +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum Signal {} + +impl Signal { + /// Returns an iterator over all possible signals. + pub fn iterator() -> impl Iterator { + std::iter::empty() + } + + /// Converts the signal into its corresponding name as a `&'static str`. + pub const fn as_str(self) -> &'static str { + "" + } + + /// Creates a `Signal` from a string representation. + pub fn from_str(s: &str) -> Result { + Err(error::ErrorKind::InvalidSignal(s.into()).into()) + } +} + +impl TryFrom for Signal { + type Error = error::Error; + + fn try_from(value: i32) -> Result { + Err(error::ErrorKind::InvalidSignal(std::format!("{value}")).into()) + } +} + +pub(crate) fn continue_process(_pid: sys::process::ProcessId) -> Result<(), error::Error> { + Err(error::ErrorKind::NotSupportedOnThisPlatform("continuing process").into()) +} + +/// Sends a signal to a specific process. +/// +/// This is a stub implementation that returns an error. +pub fn kill_process( + _pid: sys::process::ProcessId, + _signal: traps::TrapSignal, +) -> Result<(), error::Error> { + Err(error::ErrorKind::NotSupportedOnThisPlatform("killing process").into()) +} + +pub(crate) fn lead_new_process_group() -> Result<(), error::Error> { + Ok(()) +} + +pub(crate) struct FakeSignal {} + +impl FakeSignal { + fn new() -> Self { + Self {} + } + + pub async fn recv(&self) { + futures::future::pending::<()>().await; + } +} + +pub(crate) fn tstp_signal_listener() -> Result { + Ok(FakeSignal::new()) +} + +pub(crate) fn chld_signal_listener() -> Result { + Ok(FakeSignal::new()) +} + +pub(crate) async fn await_ctrl_c() -> std::io::Result<()> { + FakeSignal::new().recv().await; + Ok(()) +} + +pub(crate) fn mask_sigttou() -> Result<(), error::Error> { + Ok(()) +} + +pub(crate) fn poll_for_stopped_children() -> Result { + Ok(false) +} diff --git a/local/recipes/shells/brush/source/brush-core/src/sys/stubs/terminal.rs b/local/recipes/shells/brush/source/brush-core/src/sys/stubs/terminal.rs new file mode 100644 index 0000000000..73d614821d --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/sys/stubs/terminal.rs @@ -0,0 +1,79 @@ +//! Terminal utilities. + +use crate::{error, openfiles, sys, terminal}; + +/// Terminal configuration. +#[derive(Clone, Debug)] +pub struct Config; + +#[allow(clippy::unused_self)] +impl Config { + /// Creates a new `Config` from the actual terminal attributes of the terminal associated + /// with the given file descriptor. + /// + /// # Arguments + /// + /// * `_file` - A reference to the open terminal. + pub fn from_term(_file: &openfiles::OpenFile) -> Result { + Ok(Self) + } + + /// Applies the terminal settings to the terminal associated with the given file descriptor. + /// + /// # Arguments + /// + /// * `_file` - A reference to the open terminal. + pub fn apply_to_term(&self, _file: &openfiles::OpenFile) -> Result<(), error::Error> { + Ok(()) + } + + /// Applies the given high-level terminal settings to this configuration. Does not modify any + /// terminal itself. + /// + /// # Arguments + /// + /// * `_settings` - The high-level terminal settings to apply to this configuration. + pub fn update(&mut self, _settings: &terminal::Settings) {} +} + +/// Get the process ID of this process's parent. +/// +/// This is a stub implementation that returns `None`. +pub fn get_parent_process_id() -> Option { + None +} + +/// Get the process group ID for this process's process group. +/// +/// This is a stub implementation that returns `None`. +pub fn get_process_group_id() -> Option { + None +} + +/// Get the foreground process ID of the attached terminal. +/// +/// This is a stub implementation that returns `None`. +pub fn get_foreground_pid() -> Option { + None +} + +/// Move the specified process to the foreground of the attached terminal. +/// +/// This is a stub implementation that takes no action. +pub fn move_to_foreground(_pid: sys::process::ProcessId) -> Result<(), error::Error> { + Ok(()) +} + +/// Moves the current process to the foreground of the attached terminal. +/// +/// This is a stub implementation that returns `None`. +pub fn move_self_to_foreground() -> Result<(), std::io::Error> { + Ok(()) +} + +/// Tries to get the path of the terminal device associated with the attached terminal. +/// +/// This is a stub implementation that always returns `None`. +pub fn try_get_terminal_device_path() -> Option { + None +} diff --git a/local/recipes/shells/brush/source/brush-core/src/sys/stubs/users.rs b/local/recipes/shells/brush/source/brush-core/src/sys/stubs/users.rs new file mode 100644 index 0000000000..3b2e47e5be --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/sys/stubs/users.rs @@ -0,0 +1,50 @@ +use crate::error; +use std::path::PathBuf; + +pub(crate) fn get_user_home_dir(_username: &str) -> Option { + None +} + +pub(crate) fn get_current_user_home_dir() -> Option { + std::env::home_dir() +} + +pub(crate) fn get_current_user_default_shell() -> Option { + None +} + +pub(crate) fn is_root() -> bool { + false +} + +pub(crate) fn get_current_uid() -> Result { + Err(error::ErrorKind::NotSupportedOnThisPlatform("getting current uid").into()) +} + +pub(crate) fn get_current_gid() -> Result { + Err(error::ErrorKind::NotSupportedOnThisPlatform("getting current gid").into()) +} + +pub(crate) fn get_effective_uid() -> Result { + Err(error::ErrorKind::NotSupportedOnThisPlatform("getting effective uid").into()) +} + +pub(crate) fn get_effective_gid() -> Result { + Err(error::ErrorKind::NotSupportedOnThisPlatform("getting effective gid").into()) +} + +pub(crate) fn get_current_username() -> Result { + Err(error::ErrorKind::NotSupportedOnThisPlatform("getting current username").into()) +} + +pub(crate) fn get_user_group_ids() -> Result, error::Error> { + Ok(vec![]) +} + +pub(crate) fn get_all_users() -> Result, error::Error> { + Ok(vec![]) +} + +pub(crate) fn get_all_groups() -> Result, error::Error> { + Ok(vec![]) +} diff --git a/local/recipes/shells/brush/source/brush-core/src/sys/tokio_process.rs b/local/recipes/shells/brush/source/brush-core/src/sys/tokio_process.rs new file mode 100644 index 0000000000..9e4edbd52e --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/sys/tokio_process.rs @@ -0,0 +1,11 @@ +//! Process management utilities + +pub(crate) type ProcessId = i32; +pub(crate) use tokio::process::Child; + +// `kill_on_drop`: see `CreateOptions::kill_external_commands_on_drop` (false for ordinary shells). +pub(crate) fn spawn(command: std::process::Command, kill_on_drop: bool) -> std::io::Result { + let mut command = tokio::process::Command::from(command); + command.kill_on_drop(kill_on_drop); + command.spawn() +} diff --git a/local/recipes/shells/brush/source/brush-core/src/sys/unix.rs b/local/recipes/shells/brush/source/brush-core/src/sys/unix.rs new file mode 100644 index 0000000000..77732c9686 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/sys/unix.rs @@ -0,0 +1,28 @@ +pub mod async_pipe; +pub mod commands; +pub(crate) mod env; +pub mod fd; +pub mod fs; +pub mod input; +pub(crate) mod network; +pub mod poll; +use crate::error; +pub use crate::sys::tokio_process as process; +pub mod resource; +pub mod signal; +pub mod terminal; +pub(crate) mod users; + +/// Platform-specific errors. +#[derive(Debug, thiserror::Error)] +pub enum PlatformError { + /// A system error occurred. + #[error("system error: {0}")] + ErrnoError(#[from] nix::errno::Errno), +} + +impl From for error::ErrorKind { + fn from(err: nix::errno::Errno) -> Self { + PlatformError::ErrnoError(err).into() + } +} diff --git a/local/recipes/shells/brush/source/brush-core/src/sys/unix/async_pipe.rs b/local/recipes/shells/brush/source/brush-core/src/sys/unix/async_pipe.rs new file mode 100644 index 0000000000..f9511cf75d --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/sys/unix/async_pipe.rs @@ -0,0 +1,23 @@ +//! Async pipe reading utilities for Unix. + +use std::io; +use std::os::unix::io::OwnedFd; + +use tokio::net::unix::pipe; + +pub(crate) struct AsyncPipeReader(pipe::Receiver); + +impl AsyncPipeReader { + pub(crate) fn new(reader: std::io::PipeReader) -> io::Result { + Ok(Self(pipe::Receiver::from_file(std::fs::File::from( + OwnedFd::from(reader), + ))?)) + } + + pub(crate) async fn read_to_string(&mut self) -> io::Result { + use tokio::io::AsyncReadExt; + let mut s = String::new(); + self.0.read_to_string(&mut s).await?; + Ok(s) + } +} diff --git a/local/recipes/shells/brush/source/brush-core/src/sys/unix/commands.rs b/local/recipes/shells/brush/source/brush-core/src/sys/unix/commands.rs new file mode 100644 index 0000000000..b5554399a8 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/sys/unix/commands.rs @@ -0,0 +1,104 @@ +//! Command execution utilities. + +pub use std::os::unix::process::CommandExt; +pub use std::os::unix::process::ExitStatusExt; + +use command_fds::{CommandFdExt, FdMapping}; + +use crate::ShellFd; +use crate::error; +use crate::openfiles; + +/// Extension trait for injecting file descriptors into commands. +pub trait CommandFdInjectionExt { + /// Injects the given open files as file descriptors into the command. + /// + /// # Arguments + /// + /// * `open_files` - A mapping of child file descriptors to open files. + fn inject_fds( + &mut self, + open_files: impl Iterator, + ) -> Result<(), error::Error>; +} + +impl CommandFdInjectionExt for std::process::Command { + fn inject_fds( + &mut self, + open_files: impl Iterator, + ) -> Result<(), error::Error> { + let fd_mappings: Vec = open_files + .map(|(child_fd, open_file)| -> Result { + let parent_fd = open_file.try_clone_to_owned()?; + Ok(FdMapping { + child_fd, + parent_fd, + }) + }) + .collect::, _>>()?; + + self.fd_mappings(fd_mappings) + .map_err(|_e| error::ErrorKind::ChildCreationFailure)?; + + Ok(()) + } +} + +/// Extension trait for arranging for commands to take the foreground. +pub trait CommandFgControlExt { + /// Arranges for the command to take the foreground when it is executed. + fn take_foreground(&mut self); + /// Arranges for the command to become a session leader when it is executed. + fn lead_session(&mut self); +} + +impl CommandFgControlExt for std::process::Command { + fn take_foreground(&mut self) { + // SAFETY: + // This arranges for a provided function to run in the context of + // the forked process before it exec's the target command. In general, + // rust can't guarantee safety of code running in such a context. + unsafe { + self.pre_exec(pre_exec_take_foreground); + } + } + + fn lead_session(&mut self) { + // SAFETY: + // This arranges for a provided function to run in the context of + // the forked process before it exec's the target command. In general, + // rust can't guarantee safety of code running in such a context. + unsafe { + self.pre_exec(pre_exec_lead_session); + } + } +} + +fn pre_exec_take_foreground() -> Result<(), std::io::Error> { + use crate::sys; + + sys::terminal::move_self_to_foreground()?; + Ok(()) +} + +fn pre_exec_lead_session() -> Result<(), std::io::Error> { + if let Err(e) = nix::unistd::setsid() { + return Err(std::io::Error::other(format!( + "failed to become session leader: {e}" + ))); + } + + #[cfg(not(target_os = "macos"))] + let control = libc::TIOCSCTTY; + #[cfg(target_os = "macos")] + let control: u64 = libc::TIOCSCTTY.into(); + + // SAFETY: + // This is calling a libc function to set the controlling terminal. + let result = unsafe { libc::ioctl(0, control, 0) }; + if result != 0 { + return Err(std::io::Error::other("failed to set controlling terminal")); + } + + Ok(()) +} diff --git a/local/recipes/shells/brush/source/brush-core/src/sys/unix/env.rs b/local/recipes/shells/brush/source/brush-core/src/sys/unix/env.rs new file mode 100644 index 0000000000..33c85242f0 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/sys/unix/env.rs @@ -0,0 +1,8 @@ +//! Environment variable retrieval for Unix platforms. + +/// Retrieves environment variables from the host process. +/// +/// On Unix, this is a direct passthrough to [`std::env::vars()`]. +pub(crate) fn get_host_env_vars() -> impl Iterator { + std::env::vars() +} diff --git a/local/recipes/shells/brush/source/brush-core/src/sys/unix/fd.rs b/local/recipes/shells/brush/source/brush-core/src/sys/unix/fd.rs new file mode 100644 index 0000000000..d53ebec401 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/sys/unix/fd.rs @@ -0,0 +1,87 @@ +//! File descriptor utilities. + +use std::os::fd::RawFd; + +use crate::{ShellFd, error, openfiles}; + +cfg_if::cfg_if! { + if #[cfg(any(target_os = "linux", target_os = "android"))] { + const FD_DIR_PATH: &str = "/proc/self/fd"; + } else if #[cfg(any( + target_os = "freebsd", + target_os = "macos", + target_os = "netbsd", + target_os = "openbsd" + ))] { + const FD_DIR_PATH: &str = "/dev/fd"; + } else { + /// Returns an iterator over all open file descriptors for the shell. + pub fn iter_fds() + -> Result, error::Error> { + Ok(std::iter::empty()) + } + } +} + +/// Makes a best-effort attempt to iterate over all open file descriptors +/// for the current process. +/// +/// If the platform does not support enumerating file descriptors, an empty iterator +/// is returned. This function will skip any file descriptors that cannot be opened. +#[cfg(any( + target_os = "linux", + target_os = "android", + target_os = "freebsd", + target_os = "macos", + target_os = "netbsd", + target_os = "openbsd" +))] +pub fn try_iter_open_fds() -> impl Iterator { + std::fs::read_dir(FD_DIR_PATH) + .into_iter() + .flatten() + .filter_map(Result::ok) + .filter_map(|entry| { + let fd: RawFd = entry.file_name().to_str()?.parse().ok()?; + + // SAFETY: + // We are trying to open the file descriptor we found listed + // in the filesystem, but there's a risk that it's not the same one + // that we enumerated or that it's since been closed. For the purposes + // of this function, either of those outcomes are acceptable. We + // simply skip any fds that we can't open, and the function's purpose + // is to make a best-effort attempt to open all available fds. + let file = unsafe { open_file_by_fd(fd) }.ok()?; + + Some((fd, file)) + }) +} + +/// Attempts to retrieve an `OpenFile` representation for the given already-open file descriptor. +/// +/// If the file descriptor cannot be opened, `None` is returned. Note that there is no guarantee +/// that the returned file matches the original file descriptor, as the fd may have been closed +/// and potentially re-used in the meantime. +/// +/// # Arguments +/// +/// * `fd` - The file descriptor to open. +pub fn try_get_file_for_open_fd(fd: RawFd) -> Option { + // SAFETY: + // We are trying to open the file descriptor provided by the caller. There's a risk that the fd + // is invalid or has been closed since it was enumerated. For the purposes of this function, + // we simply return None if we can't open it. There's also a risk that the fd has been closed + // and re-used for a different file; again, for the purposes of this function, we accept that + // risk and document it as part of the function's contract. + unsafe { open_file_by_fd(fd).ok() } +} + +unsafe fn open_file_by_fd(fd: RawFd) -> Result { + // SAFETY: We are creating a BorrowedFd from a file descriptor. Callers typically + // enumerate available file descriptors from procfs, devfs, or similar, but there's + // still a risk that the fd has become invalid or closed since then -- or that this + // function gets used incorrectly. + let borrowed_fd = unsafe { std::os::fd::BorrowedFd::borrow_raw(fd) }; + let owned_fd = borrowed_fd.try_clone_to_owned()?; + Ok(std::fs::File::from(owned_fd).into()) +} diff --git a/local/recipes/shells/brush/source/brush-core/src/sys/unix/fs.rs b/local/recipes/shells/brush/source/brush-core/src/sys/unix/fs.rs new file mode 100644 index 0000000000..f5b6968cc8 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/sys/unix/fs.rs @@ -0,0 +1,413 @@ +//! Filesystem utilities. + +use std::os::unix::ffi::OsStringExt; +use std::os::unix::fs::FileTypeExt; +use std::path::{Path, PathBuf}; + +use crate::error; + +pub use std::os::unix::fs::MetadataExt; + +#[cfg(target_os = "android")] +// _PATH_DEFPATH in https://android.googlesource.com/platform/bionic/+/refs/heads/main/libc/include/paths.h +const ANDROID_DEFPATH: &str = "/product/bin:/apex/com.android.runtime/bin:/apex/com.android.art/bin:/apex/com.android.virt/bin:/system_ext/bin:/system/bin:/system/xbin:/odm/bin:/vendor/bin:/vendor/xbin"; + +impl crate::sys::fs::PathExt for Path { + fn readable(&self) -> bool { + nix::unistd::access(self, nix::unistd::AccessFlags::R_OK).is_ok() + } + + fn writable(&self) -> bool { + nix::unistd::access(self, nix::unistd::AccessFlags::W_OK).is_ok() + } + + fn executable(&self) -> bool { + nix::unistd::access(self, nix::unistd::AccessFlags::X_OK).is_ok() + } + + fn exists_and_is_block_device(&self) -> bool { + try_get_file_type(self).is_some_and(|ft| ft.is_block_device()) + } + + fn exists_and_is_char_device(&self) -> bool { + try_get_file_type(self).is_some_and(|ft| ft.is_char_device()) + } + + fn exists_and_is_fifo(&self) -> bool { + try_get_file_type(self).is_some_and(|ft: std::fs::FileType| ft.is_fifo()) + } + + fn exists_and_is_socket(&self) -> bool { + try_get_file_type(self).is_some_and(|ft| ft.is_socket()) + } + + fn exists_and_is_setgid(&self) -> bool { + const S_ISGID: u32 = 0o2000; + let file_mode = try_get_file_mode(self); + file_mode.is_some_and(|mode| mode & S_ISGID != 0) + } + + fn exists_and_is_setuid(&self) -> bool { + const S_ISUID: u32 = 0o4000; + let file_mode = try_get_file_mode(self); + file_mode.is_some_and(|mode| mode & S_ISUID != 0) + } + + fn exists_and_is_sticky_bit(&self) -> bool { + const S_ISVTX: u32 = 0o1000; + let file_mode = try_get_file_mode(self); + file_mode.is_some_and(|mode| mode & S_ISVTX != 0) + } + + fn get_device_and_inode(&self) -> Result<(u64, u64), crate::error::Error> { + let metadata = self.metadata()?; + Ok((metadata.dev(), metadata.ino())) + } +} + +fn try_get_file_type(path: &Path) -> Option { + path.metadata().map(|metadata| metadata.file_type()).ok() +} + +fn try_get_file_mode(path: &Path) -> Option { + path.metadata().map(|metadata| metadata.mode()).ok() +} + +/// Splits a platform-specific PATH-like value into individual paths. +/// +/// On Unix, this delegates to [`std::env::split_paths`]. +pub fn split_paths + ?Sized>(s: &T) -> std::env::SplitPaths<'_> { + std::env::split_paths(s) +} + +pub(crate) fn get_default_executable_search_paths() -> Vec { + #[cfg(target_os = "android")] + { + std::env::split_paths(ANDROID_DEFPATH).collect() + } + #[cfg(not(target_os = "android"))] + { + // standard hard-coded defaults for executable search path + vec![ + "/usr/local/sbin".into(), + "/usr/local/bin".into(), + "/usr/sbin".into(), + "/usr/bin".into(), + "/sbin".into(), + "/bin".into(), + ] + } +} + +/// Retrieves the platform-specific set of paths that should contain standard system +/// utilities. Used by `command -p`, for example. +pub fn get_default_standard_utils_paths() -> Vec { + // + // Try to call confstr(_CS_PATH). If that fails, can't find a string value, or + // finds an empty string, then we'll fall back to hard-coded defaults. + // + + if let Ok(Some(cs_path)) = confstr_cs_path() + && !cs_path.as_os_str().is_empty() + { + return split_paths(&cs_path).collect(); + } + + #[cfg(target_os = "android")] + { + std::env::split_paths(ANDROID_DEFPATH).collect() + } + #[cfg(not(target_os = "android"))] + { + // standard hard-coded defaults + vec![ + "/bin".into(), + "/usr/bin".into(), + "/sbin".into(), + "/usr/sbin".into(), + "/etc".into(), + "/usr/etc".into(), + ] + } +} + +#[allow(clippy::unnecessary_wraps)] +fn confstr_cs_path() -> Result, std::io::Error> { + #[cfg(target_os = "android")] + { + Ok(Some(PathBuf::from(ANDROID_DEFPATH))) + } + #[cfg(not(target_os = "android"))] + { + let value = confstr(nix::libc::_CS_PATH)?; + + if let Some(value) = value { + let value_str = PathBuf::from(value); + Ok(Some(value_str)) + } else { + Ok(None) + } + } +} + +/// A wrapper for [`nix::libc::confstr`]. Returns a value for the default PATH variable which +/// indicates where all the POSIX.2 standard utilities can be found. +/// +/// N.B. We would strongly prefer to use a safe API exposed (in an idiomatic way) by nix +/// or similar. Until that exists, we accept the need to make the unsafe call directly. +#[cfg(not(target_os = "android"))] +fn confstr(name: nix::libc::c_int) -> Result, std::io::Error> { + // SAFETY: + // Calling `confstr` with a null pointer and size 0 is a documented way to query + // the required size of the buffer to hold the value associated with `name`. It + // should not end up causing any undefined behavior. + let required_size = unsafe { nix::libc::confstr(name, std::ptr::null_mut(), 0) }; + + // When confstr returns 0, it either means there's no value associated with _CS_PATH, or + // _CS_PATH is considered invalid (and not present) on this platform. In both cases, we + // treat it as a non-existent value and return None. + if required_size == 0 { + return Ok(None); + } + + let mut buffer = Vec::::with_capacity(required_size); + + // SAFETY: + // We are calling `confstr` with a valid pointer and size that we obtained from the + // allocated buffer. Writing `c_char` (i8 or u8 depending on the platform) into + // `Vec` is fine, as i8 and u8 have compatible representations, and Rust does + // not support platforms where `c_char` is not 8-bit wide. + let final_size = + unsafe { nix::libc::confstr(name, buffer.as_mut_ptr().cast(), buffer.capacity()) }; + + if final_size == 0 { + return Err(std::io::Error::last_os_error()); + } + + // Per the docs on `confstr`, it *may* return a size larger than the provided buffer. + // In our usage we wouldn't expect to see this, as we've first queried the required size. + // However, we defensively check for this case and return an error if it happens. + if final_size > buffer.capacity() { + return Err(std::io::Error::other( + "confstr needed more space than advertised", + )); + } + + // SAFETY: + // We are trusting `confstr` to have written exactly `final_size` bytes into the buffer. + // We have checked above that it didn't return a value *larger* than the capacity of + // the buffer, and also checked for known error cases. Note that the returned length + // should include the null terminator. + unsafe { buffer.set_len(final_size) }; + + // The last byte is a null terminator. We assert that it is. + if !matches!(buffer.pop(), Some(0)) { + return Err(std::io::Error::other( + "confstr did not null-terminate the returned string", + )); + } + + Ok(Some(std::ffi::OsString::from_vec(buffer))) +} + +/// Opens a null file that will discard all I/O. +pub fn open_null_file() -> Result { + let f = std::fs::File::options() + .read(true) + .write(true) + .open("/dev/null")?; + + Ok(f) +} + +/// Gives the platform an opportunity to handle a special file path (e.g. `/dev/null`). +pub const fn try_open_special_file(_path: &Path) -> Option> { + None +} + +/// Returns the path to the system-wide shell profile script. +pub fn get_system_profile_path() -> Option<&'static Path> { + Some(Path::new("/etc/profile")) +} + +/// Returns the path to the system-wide shell rc script. +pub fn get_system_rc_path() -> Option<&'static Path> { + Some(Path::new("/etc/bash.bashrc")) +} + +/// Returns true if the string contains a path separator character. +/// +/// On Unix, only `/` is considered a path separator. +pub fn contains_path_separator(s: &str) -> bool { + s.contains('/') +} + +/// Returns true if the string ends with a path separator character. +/// +/// On Unix, only `/` is considered a path separator. +pub fn ends_with_path_separator(s: &str) -> bool { + s.ends_with('/') +} + +/// Returns the string with a trailing path separator removed, if present. +/// +/// On Unix, only `/` is considered a path separator. +pub fn strip_path_separator_suffix(s: &str) -> &str { + s.strip_suffix('/').unwrap_or(s) +} + +/// Returns the platform default for case-insensitive pathname expansion. +/// +/// On Unix, filesystems are typically case-sensitive, so this returns `false`. +pub const fn default_case_insensitive_path_expansion() -> bool { + false +} + +/// Finds the byte index of the last path separator in the string. +/// +/// On Unix, only `/` is considered a path separator. +pub fn rfind_path_separator(s: &str) -> Option { + s.rfind('/') +} + +/// Splits a string on path separator characters, returning an iterator of components. +/// +/// On Unix, only `/` is used as a separator. +pub fn split_path_for_pattern(s: &str) -> impl Iterator { + s.split('/') +} + +/// Returns the root path for an absolute pattern, if the first component indicates one. +/// +/// On Unix, an empty first component (from splitting a path like `/foo`) indicates +/// an absolute path rooted at `/`. +pub fn pattern_path_root(first_component: &str) -> Option { + if first_component.is_empty() { + Some(PathBuf::from("/")) + } else { + None + } +} + +/// Pushes a component onto a path for pattern expansion. +/// +/// On Unix, this delegates directly to `PathBuf::push`. +pub fn push_path_for_pattern(path: &mut std::path::PathBuf, component: &str) { + path.push(component); +} + +/// Normalizes path separators for shell output. +/// +/// On Unix, this is a no-op since paths already use `/`. +pub const fn normalize_path_separators(s: &str) -> std::borrow::Cow<'_, str> { + std::borrow::Cow::Borrowed(s) +} + +/// Resolves an owned path to the actual on-disk executable file, if any. +/// +/// On Unix this is a straight passthrough: if the path is executable, the +/// path is returned unchanged (no clone). This keeps `pathsearch::next` +/// allocation-free on the happy path. +/// +/// On Windows this function may append a `PATHEXT` extension and return a +/// possibly-different `PathBuf`. +pub fn resolve_executable(path: PathBuf) -> Option { + use crate::sys::fs::PathExt; + if path.as_path().executable() { + Some(path) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn path_separator_helpers() { + assert!(contains_path_separator("foo/bar")); + assert!(!contains_path_separator("foobar")); + // Backslashes are not separators on Unix. + assert!(!contains_path_separator(r"foo\bar")); + + assert!(ends_with_path_separator("foo/")); + assert!(!ends_with_path_separator("foo")); + assert!(!ends_with_path_separator(r"foo\")); + + assert_eq!(strip_path_separator_suffix("foo/"), "foo"); + assert_eq!(strip_path_separator_suffix("foo"), "foo"); + assert_eq!(strip_path_separator_suffix(r"foo\"), r"foo\"); + + assert_eq!(rfind_path_separator("a/b/c"), Some(3)); + assert_eq!(rfind_path_separator("abc"), None); + } + + #[test] + fn split_path_for_pattern_basic() { + let parts: Vec<_> = split_path_for_pattern("a/b/c").collect(); + assert_eq!(parts, vec!["a", "b", "c"]); + + let parts: Vec<_> = split_path_for_pattern("/a/b").collect(); + assert_eq!(parts, vec!["", "a", "b"]); + + // Backslashes are not split on Unix. + let parts: Vec<_> = split_path_for_pattern(r"a\b").collect(); + assert_eq!(parts, vec![r"a\b"]); + } + + #[test] + fn pattern_path_root_absolute() { + assert_eq!(pattern_path_root(""), Some(PathBuf::from("/"))); + } + + #[test] + fn pattern_path_root_relative() { + assert_eq!(pattern_path_root("foo"), None); + // Drive-letter syntax is not recognized on Unix. + assert_eq!(pattern_path_root("c:"), None); + } + + #[test] + fn push_path_for_pattern_appends_child() { + let mut p = PathBuf::from("/home/reuben"); + push_path_for_pattern(&mut p, "foo"); + assert_eq!(p, PathBuf::from("/home/reuben/foo")); + } + + #[test] + fn normalize_path_separators_is_noop() { + use std::borrow::Cow; + assert!(matches!( + normalize_path_separators("/foo/bar"), + Cow::Borrowed("/foo/bar") + )); + } + + #[test] + fn default_case_insensitive_is_false() { + assert!(!default_case_insensitive_path_expansion()); + } + + #[test] + fn resolve_executable_returns_input_unchanged() { + // /bin/sh exists and is executable on every supported Unix host. + let path = PathBuf::from("/bin/sh"); + let resolved = resolve_executable(path.clone()); + assert_eq!(resolved.as_deref(), Some(path.as_path())); + } + + #[test] + fn resolve_executable_returns_none_for_nonexistent() { + let path = PathBuf::from("/this/path/should/not/exist/brush-test"); + assert!(resolve_executable(path).is_none()); + } + + #[test] + fn resolve_executable_returns_none_for_non_executable() { + // /etc/hostname (or similar) is a regular file but not executable. + // Use /etc/passwd which is universally present and not executable. + let path = PathBuf::from("/etc/passwd"); + assert!(resolve_executable(path).is_none()); + } +} diff --git a/local/recipes/shells/brush/source/brush-core/src/sys/unix/input.rs b/local/recipes/shells/brush/source/brush-core/src/sys/unix/input.rs new file mode 100644 index 0000000000..d0dcfdc126 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/sys/unix/input.rs @@ -0,0 +1,84 @@ +//! Terminal input utilities + +use std::collections::HashMap; +use std::sync::LazyLock; +use terminfo::capability as cap; + +use crate::{error, interfaces}; + +macro_rules! key { + ( $terminfo:expr , $our_key:expr, $terminfo_key:ty ) => {{ + ( + $our_key, + $terminfo + .get::<$terminfo_key>() + .map(|k| k.expand().to_vec()), + ) + }}; +} + +fn build_terminfo_key_map() -> HashMap, interfaces::Key> { + let mut map: HashMap, interfaces::Key> = HashMap::new(); + + if let Ok(ti) = terminfo::Database::from_env() { + // Iterate over key capabilities and populate the map + let key_capabilities = [ + key!(ti, interfaces::Key::F(1), cap::KeyF1<'_>), + key!(ti, interfaces::Key::F(2), cap::KeyF2<'_>), + key!(ti, interfaces::Key::F(3), cap::KeyF3<'_>), + key!(ti, interfaces::Key::F(4), cap::KeyF4<'_>), + key!(ti, interfaces::Key::F(5), cap::KeyF5<'_>), + key!(ti, interfaces::Key::F(6), cap::KeyF6<'_>), + key!(ti, interfaces::Key::F(7), cap::KeyF7<'_>), + key!(ti, interfaces::Key::F(8), cap::KeyF8<'_>), + key!(ti, interfaces::Key::F(9), cap::KeyF9<'_>), + key!(ti, interfaces::Key::F(10), cap::KeyF10<'_>), + key!(ti, interfaces::Key::F(11), cap::KeyF11<'_>), + key!(ti, interfaces::Key::F(12), cap::KeyF12<'_>), + key!(ti, interfaces::Key::Backspace, cap::KeyBackspace<'_>), + key!(ti, interfaces::Key::Enter, cap::KeyEnter<'_>), + key!(ti, interfaces::Key::Left, cap::KeyLeft<'_>), + key!(ti, interfaces::Key::Right, cap::KeyRight<'_>), + key!(ti, interfaces::Key::Up, cap::KeyUp<'_>), + key!(ti, interfaces::Key::Down, cap::KeyDown<'_>), + key!(ti, interfaces::Key::Home, cap::KeyHome<'_>), + key!(ti, interfaces::Key::End, cap::KeyEnd<'_>), + key!(ti, interfaces::Key::PageUp, cap::KeyPPage<'_>), + key!(ti, interfaces::Key::PageDown, cap::KeyNPage<'_>), + key!(ti, interfaces::Key::BackTab, cap::BackTab<'_>), + // It's not clear if these belong here, because they're not + // strictly "key" capabilities. + key!(ti, interfaces::Key::Up, cap::CursorUp<'_>), + key!(ti, interfaces::Key::Down, cap::CursorDown<'_>), + key!(ti, interfaces::Key::Left, cap::CursorLeft<'_>), + key!(ti, interfaces::Key::Right, cap::CursorRight<'_>), + ]; + + for (key, v) in key_capabilities { + if let Some(Ok(v)) = v { + map.insert(v.clone(), key.clone()); + } + } + } + + map +} + +pub(crate) static TERMINFO_KEY_MAP: LazyLock, interfaces::Key>> = + LazyLock::new(build_terminfo_key_map); + +/// Translates a key code (byte sequence) into a `Key` enum value. Returns `None` +/// if the key code is not recognized. +/// +/// # Arguments +/// +/// * `key_code`: The byte sequence representing the key code. +pub fn try_get_key_from_key_code(key_code: &[u8]) -> Option { + if let Some(key) = TERMINFO_KEY_MAP.get(key_code) { + Some(key.clone()) + } else if key_code.len() == 1 && !key_code[0].is_ascii_control() { + Some(interfaces::Key::Character(key_code[0] as char)) + } else { + None + } +} diff --git a/local/recipes/shells/brush/source/brush-core/src/sys/unix/network.rs b/local/recipes/shells/brush/source/brush-core/src/sys/unix/network.rs new file mode 100644 index 0000000000..b2ae829ece --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/sys/unix/network.rs @@ -0,0 +1,3 @@ +pub(crate) fn get_hostname() -> std::io::Result { + crate::sys::hostname::get() +} diff --git a/local/recipes/shells/brush/source/brush-core/src/sys/unix/poll.rs b/local/recipes/shells/brush/source/brush-core/src/sys/unix/poll.rs new file mode 100644 index 0000000000..aca31516ea --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/sys/unix/poll.rs @@ -0,0 +1,108 @@ +//! File descriptor polling utilities for timeout support. + +use std::os::fd::BorrowedFd; +use std::time::{Duration, Instant}; + +use nix::poll::{PollFd, PollFlags, PollTimeout, poll}; + +use crate::openfiles::OpenFile; + +/// Polls an open file for input readability with a timeout. +/// +/// Returns `Ok(true)` if data is available for reading, `Ok(false)` if the timeout +/// elapsed without data becoming available. +/// +/// For regular files, always returns `Ok(true)` immediately since they're always +/// "ready" (matching bash behavior where `-t` has no effect on regular files). +/// +/// # Arguments +/// +/// * `file` - The open file to poll. +/// * `timeout` - Maximum time to wait. Use `Duration::ZERO` to check without blocking. +/// +/// # Errors +/// +/// Returns an error if polling fails or the file descriptor cannot be borrowed. +pub fn poll_for_input(file: &OpenFile, timeout: Duration) -> std::io::Result { + let fd = file + .try_borrow_as_fd() + .map_err(|e| std::io::Error::other(e.to_string()))?; + + // Regular files are always ready - timeout has no effect (bash behavior). + if is_regular_file(fd) { + return Ok(true); + } + + // Convert timeout to deadline for accurate time tracking across EINTR retries. + let deadline = if timeout.is_zero() { + // For zero timeout, use current instant so first check sees zero remaining. + Some(Instant::now()) + } else { + Some(Instant::now() + timeout) + }; + + poll_fd_for_input(fd, deadline) +} + +/// Polls a file descriptor for input readability with a deadline. +/// +/// Returns `Ok(true)` if data is available, `Ok(false)` if deadline passed. +/// +/// # Arguments +/// +/// * `fd` - File descriptor to poll +/// * `deadline` - Optional deadline; `None` indicates no deadline. +fn poll_fd_for_input(fd: BorrowedFd<'_>, deadline: Option) -> std::io::Result { + let mut poll_fds = [PollFd::new(fd, PollFlags::POLLIN)]; + let mut first_iteration = true; + + loop { + // Calculate remaining time on each iteration to handle EINTR correctly. + let timeout_ms = match deadline { + Some(d) => { + let remaining = d.saturating_duration_since(Instant::now()); + // On first iteration, always do at least one poll even with zero timeout. + // This allows `-t 0` to check if input is immediately available. + if remaining.is_zero() && !first_iteration { + return Ok(false); // Deadline passed after initial poll. + } + i32::try_from(remaining.as_millis()).unwrap_or(i32::MAX) + } + None => -1, // Block indefinitely. + }; + first_iteration = false; + let poll_timeout = PollTimeout::try_from(timeout_ms).unwrap_or(PollTimeout::MAX); + + match poll(&mut poll_fds, poll_timeout) { + Ok(0) => return Ok(false), // Timeout + Ok(_) => { + let revents = poll_fds[0].revents().unwrap_or(PollFlags::empty()); + // POLLIN means data available. POLLHUP/POLLERR without POLLIN means + // EOF/error - return true so caller reads and gets the proper result. + return Ok( + revents.intersects(PollFlags::POLLIN | PollFlags::POLLHUP | PollFlags::POLLERR) + ); + } + Err(nix::errno::Errno::EINTR) => (), // Retry on signal with recalculated timeout. + Err(e) => return Err(std::io::Error::from_raw_os_error(e as i32)), + } + } +} + +/// Checks if a file descriptor refers to a regular file. +/// +/// Regular files are always "ready" for reading (poll has no effect). +/// +/// # Arguments +/// +/// * `fd` - File descriptor to check +fn is_regular_file(fd: BorrowedFd<'_>) -> bool { + match nix::sys::stat::fstat(fd) { + Ok(stat) => { + use nix::sys::stat::{SFlag, mode_t}; + mode_t::try_from(stat.st_mode) + .is_ok_and(|mode| SFlag::from_bits_truncate(mode).contains(SFlag::S_IFREG)) + } + Err(_) => false, + } +} diff --git a/local/recipes/shells/brush/source/brush-core/src/sys/unix/resource.rs b/local/recipes/shells/brush/source/brush-core/src/sys/unix/resource.rs new file mode 100644 index 0000000000..7b9b8a2676 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/sys/unix/resource.rs @@ -0,0 +1,31 @@ +//! Resource utilities + +use crate::error; + +/// Returns the user and system CPU time used by the current process; +/// expressed as a tuple containing user time and system time, in that order. +pub fn get_self_user_and_system_time() +-> Result<(std::time::Duration, std::time::Duration), error::Error> { + let usage = nix::sys::resource::getrusage(nix::sys::resource::UsageWho::RUSAGE_SELF)?; + Ok(( + convert_rusage_time(usage.user_time()), + convert_rusage_time(usage.system_time()), + )) +} + +/// Returns the user and system CPU time used by child processes; expressed +/// as a tuple containing user time and system time, in that order. +pub fn get_children_user_and_system_time() +-> Result<(std::time::Duration, std::time::Duration), error::Error> { + let usage = nix::sys::resource::getrusage(nix::sys::resource::UsageWho::RUSAGE_CHILDREN)?; + Ok(( + convert_rusage_time(usage.user_time()), + convert_rusage_time(usage.system_time()), + )) +} + +const fn convert_rusage_time(time: nix::sys::time::TimeVal) -> std::time::Duration { + #[allow(clippy::cast_sign_loss)] + #[allow(clippy::cast_possible_truncation)] + std::time::Duration::new(time.tv_sec() as u64, time.tv_usec() as u32 * 1000) +} diff --git a/local/recipes/shells/brush/source/brush-core/src/sys/unix/signal.rs b/local/recipes/shells/brush/source/brush-core/src/sys/unix/signal.rs new file mode 100644 index 0000000000..36830d6a46 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/sys/unix/signal.rs @@ -0,0 +1,169 @@ +//! Signal processing utilities + +use crate::{error, sys, traps}; + +pub(crate) use nix::sys::signal::Signal; + +pub(crate) fn continue_process(pid: sys::process::ProcessId) -> Result<(), error::Error> { + nix::sys::signal::kill(nix::unistd::Pid::from_raw(pid), nix::sys::signal::SIGCONT) + .map_err(|_errno| error::ErrorKind::FailedToSendSignal)?; + Ok(()) +} + +/// Sends a signal to a specific process. +/// +/// # Arguments +/// * `pid` - The process ID to send the signal to +/// * `signal` - The signal to send (must be a real signal, not a trap signal) +pub fn kill_process( + pid: sys::process::ProcessId, + signal: traps::TrapSignal, +) -> Result<(), error::Error> { + let translated_signal = match signal { + traps::TrapSignal::Signal(signal) => signal, + traps::TrapSignal::Debug + | traps::TrapSignal::Err + | traps::TrapSignal::Exit + | traps::TrapSignal::Return => { + return Err(error::ErrorKind::InvalidSignal(signal.to_string()).into()); + } + }; + + nix::sys::signal::kill(nix::unistd::Pid::from_raw(pid), translated_signal) + .map_err(|_errno| error::ErrorKind::FailedToSendSignal)?; + + Ok(()) +} + +pub(crate) fn lead_new_process_group() -> Result<(), error::Error> { + nix::unistd::setpgid(nix::unistd::Pid::from_raw(0), nix::unistd::Pid::from_raw(0))?; + Ok(()) +} + +pub(crate) fn tstp_signal_listener() -> Result { + let signal = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::from_raw( + nix::libc::SIGTSTP, + ))?; + Ok(signal) +} + +pub(crate) fn chld_signal_listener() -> Result { + let signal = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::child())?; + Ok(signal) +} + +pub(crate) use tokio::signal::ctrl_c as await_ctrl_c; + +pub(crate) fn mask_sigttou() -> Result<(), error::Error> { + let ignore = nix::sys::signal::SigAction::new( + nix::sys::signal::SigHandler::SigIgn, + nix::sys::signal::SaFlags::empty(), + nix::sys::signal::SigSet::empty(), + ); + + // SAFETY: + // Setting the signal action should be safe here. The unsafe concerns + // for calling `sigaction` are primarily around ensuring that any provided + // signal handler functions are only performing operations that are + // safe to do in a signal handler context. Here we are not providing + // a custom handler, just asking the OS to ignore the signal. + unsafe { nix::sys::signal::sigaction(nix::sys::signal::Signal::SIGTTOU, &ignore) }?; + + Ok(()) +} + +pub(crate) fn poll_for_stopped_children() -> Result { + let mut found_stopped = false; + + loop { + let wait_status = waitid_all( + nix::sys::wait::WaitPidFlag::WUNTRACED | nix::sys::wait::WaitPidFlag::WNOHANG, + ); + match wait_status { + Ok(nix::sys::wait::WaitStatus::Stopped(_stopped_pid, _signal)) => { + found_stopped = true; + } + Ok(_) => break, + Err(nix::errno::Errno::ECHILD) => break, + Err(e) => return Err(e.into()), + } + } + + Ok(found_stopped) +} + +#[cfg(not(any(target_os = "macos", target_os = "netbsd", target_os = "openbsd")))] +fn waitid_all( + flags: nix::sys::wait::WaitPidFlag, +) -> Result { + nix::sys::wait::waitid(nix::sys::wait::Id::All, flags) +} + +// nix does not expose `waitid` on NetBSD/OpenBSD; `waitpid` for any child is +// equivalent for the flags used here. +#[cfg(any(target_os = "netbsd", target_os = "openbsd"))] +fn waitid_all( + flags: nix::sys::wait::WaitPidFlag, +) -> Result { + nix::sys::wait::waitpid(None, Some(flags)) +} + +// +// N.B. These functions were mostly copied from nix::sys::wait (https://github.com/nix-rust/nix, MIT license) +// to enable use of the `waitid` call on macOS. Ideally nix would expose it on macOS and we would +// remove this code. +// + +#[cfg(target_os = "macos")] +fn waitid_all( + flags: nix::sys::wait::WaitPidFlag, +) -> Result { + // SAFETY: + // Code copied from nix::sys::wait implementation of waitid for other platforms. + // The siginfo structure is valid when filled with zeroes. Memory is zeroed + // rather than uninitialized, as not all platforms initialize the memory in + // the StillAlive case. + let mut siginfo: nix::libc::siginfo_t = unsafe { std::mem::zeroed() }; + + // SAFETY: + // Code copied from nix::sys::wait implementation of waitid for other platforms. + nix::errno::Errno::result(unsafe { + nix::libc::waitid(nix::libc::P_ALL, 0, &raw mut siginfo, flags.bits()) + })?; + + siginfo_to_wait_status(siginfo) +} + +#[cfg(target_os = "macos")] +fn siginfo_to_wait_status( + siginfo: nix::libc::siginfo_t, +) -> Result { + // SAFETY: + // Code copied from nix::sys::wait implementation of waitid for other platforms. + let si_pid = unsafe { siginfo.si_pid() }; + if si_pid == 0 { + return Ok(nix::sys::wait::WaitStatus::StillAlive); + } + + let pid = nix::unistd::Pid::from_raw(si_pid); + + // SAFETY: + // Code copied from nix::sys::wait implementation of waitid for other platforms. + let si_status = unsafe { siginfo.si_status() }; + + let status = match siginfo.si_code { + nix::libc::CLD_EXITED => nix::sys::wait::WaitStatus::Exited(pid, si_status), + nix::libc::CLD_KILLED | nix::libc::CLD_DUMPED => nix::sys::wait::WaitStatus::Signaled( + pid, + nix::sys::signal::Signal::try_from(si_status)?, + siginfo.si_code == nix::libc::CLD_DUMPED, + ), + nix::libc::CLD_STOPPED => { + nix::sys::wait::WaitStatus::Stopped(pid, nix::sys::signal::Signal::try_from(si_status)?) + } + nix::libc::CLD_CONTINUED => nix::sys::wait::WaitStatus::Continued(pid), + _ => return Err(nix::errno::Errno::EINVAL), + }; + + Ok(status) +} diff --git a/local/recipes/shells/brush/source/brush-core/src/sys/unix/terminal.rs b/local/recipes/shells/brush/source/brush-core/src/sys/unix/terminal.rs new file mode 100644 index 0000000000..9410c0c9a8 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/sys/unix/terminal.rs @@ -0,0 +1,119 @@ +//! Terminal utilities. + +use crate::{error, openfiles, sys, terminal}; +use std::{io::IsTerminal, os::fd::AsFd, path::PathBuf}; + +/// Terminal configuration. +#[derive(Clone, Debug)] +pub struct Config { + termios: nix::sys::termios::Termios, +} + +impl Config { + /// Creates a new `Config` from the actual terminal attributes of the terminal associated + /// with the given file descriptor. + /// + /// # Arguments + /// + /// * `file` - A reference to the open terminal. + pub fn from_term(file: &openfiles::OpenFile) -> Result { + let fd = file.try_borrow_as_fd()?; + let termios = nix::sys::termios::tcgetattr(fd)?; + Ok(Self { termios }) + } + + /// Applies the terminal settings to the terminal associated with the given file descriptor. + /// + /// # Arguments + /// + /// * `file` - A reference to the open terminal. + pub fn apply_to_term(&self, file: &openfiles::OpenFile) -> Result<(), error::Error> { + let fd = file.try_borrow_as_fd()?; + nix::sys::termios::tcsetattr(fd, nix::sys::termios::SetArg::TCSANOW, &self.termios)?; + Ok(()) + } + + /// Applies the given high-level terminal settings to this configuration. Does not modify any + /// terminal itself. + /// + /// # Arguments + /// + /// * `settings` - The high-level terminal settings to apply to this configuration. + pub fn update(&mut self, settings: &terminal::Settings) { + if let Some(echo_input) = &settings.echo_input { + if *echo_input { + self.termios.local_flags |= nix::sys::termios::LocalFlags::ECHO; + } else { + self.termios.local_flags -= nix::sys::termios::LocalFlags::ECHO; + } + } + + if let Some(line_input) = &settings.line_input { + if *line_input { + self.termios.local_flags |= nix::sys::termios::LocalFlags::ICANON; + } else { + self.termios.local_flags -= nix::sys::termios::LocalFlags::ICANON; + } + } + + if let Some(interrupt_signals) = &settings.interrupt_signals { + if *interrupt_signals { + self.termios.local_flags |= nix::sys::termios::LocalFlags::ISIG; + } else { + self.termios.local_flags -= nix::sys::termios::LocalFlags::ISIG; + } + } + + if let Some(output_nl_as_nlcr) = &settings.output_nl_as_nlcr { + if *output_nl_as_nlcr { + self.termios.output_flags |= + nix::sys::termios::OutputFlags::OPOST | nix::sys::termios::OutputFlags::ONLCR; + } else { + self.termios.output_flags -= nix::sys::termios::OutputFlags::ONLCR; + } + } + } +} + +/// Get the process ID of this process's parent. +pub fn get_parent_process_id() -> Option { + Some(nix::unistd::getppid().as_raw()) +} + +/// Get the process group ID for this process's process group. +pub fn get_process_group_id() -> Option { + Some(nix::unistd::getpgrp().as_raw()) +} + +/// Get the foreground process ID of the attached terminal. +pub fn get_foreground_pid() -> Option { + nix::unistd::tcgetpgrp(std::io::stdin()) + .ok() + .map(|pgid| pgid.as_raw()) +} + +/// Move the specified process to the foreground of the attached terminal. +pub fn move_to_foreground(pid: sys::process::ProcessId) -> Result<(), error::Error> { + nix::unistd::tcsetpgrp(std::io::stdin(), nix::unistd::Pid::from_raw(pid))?; + Ok(()) +} + +/// Moves the current process to the foreground of the attached terminal. +// This function needs to return `std::io::Error` so that the OS error code can be recovered. +pub fn move_self_to_foreground() -> Result<(), std::io::Error> { + if std::io::stdin().is_terminal() { + let pgid = nix::unistd::getpgid(None)?; + + // TODO(jobs): This sometimes fails with ENOTTY even though we checked that stdin is a + // terminal. We should investigate why this is happening. + let _ = nix::unistd::tcsetpgrp(std::io::stdin(), pgid); + } + + Ok(()) +} + +/// Tries to get the path of the terminal device associated with the attached terminal. +/// Returns `None` if there is no terminal attached or the lookup failed. +pub fn try_get_terminal_device_path() -> Option { + nix::unistd::ttyname(std::io::stdin()).ok() +} diff --git a/local/recipes/shells/brush/source/brush-core/src/sys/unix/users.rs b/local/recipes/shells/brush/source/brush-core/src/sys/unix/users.rs new file mode 100644 index 0000000000..74d1437bbc --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/sys/unix/users.rs @@ -0,0 +1,90 @@ +use crate::{error, trace_categories}; +use std::path::PathBuf; + +use uzers::os::unix::UserExt; + +pub(crate) fn is_root() -> bool { + uzers::get_current_uid() == 0 +} + +pub(crate) fn get_user_home_dir(username: &str) -> Option { + if let Some(user_info) = uzers::get_user_by_name(username) { + return Some(user_info.home_dir().to_path_buf()); + } + + None +} + +pub(crate) fn get_current_user_home_dir() -> Option { + if let Some(username) = uzers::get_current_username() + && let Some(user_info) = uzers::get_user_by_name(&username) + { + return Some(user_info.home_dir().to_path_buf()); + } + + None +} + +pub(crate) fn get_current_user_default_shell() -> Option { + if let Some(username) = uzers::get_current_username() + && let Some(user_info) = uzers::get_user_by_name(&username) + { + return Some(user_info.shell().to_path_buf()); + } + + None +} + +#[expect(clippy::unnecessary_wraps)] +pub(crate) fn get_current_uid() -> Result { + Ok(uzers::get_current_uid()) +} + +#[expect(clippy::unnecessary_wraps)] +pub(crate) fn get_current_gid() -> Result { + Ok(uzers::get_current_gid()) +} + +#[expect(clippy::unnecessary_wraps)] +pub(crate) fn get_effective_uid() -> Result { + Ok(uzers::get_effective_uid()) +} + +#[expect(clippy::unnecessary_wraps)] +pub(crate) fn get_effective_gid() -> Result { + Ok(uzers::get_effective_gid()) +} + +pub(crate) fn get_current_username() -> Result { + let username = uzers::get_current_username().ok_or_else(|| error::ErrorKind::NoCurrentUser)?; + Ok(username.to_string_lossy().to_string()) +} + +pub(crate) fn get_user_group_ids() -> Result, error::Error> { + let groups = get_current_user_groups()?; + Ok(groups.into_iter().map(|g| g.gid()).collect()) +} + +pub(crate) fn get_all_users() -> Result, error::Error> { + // TODO(#475): uzers::all_users() is available but unsafe; for now we just return the current + // user. That's better than nothing. + let user = get_current_username()?; + Ok(vec![user]) +} + +pub(crate) fn get_all_groups() -> Result, error::Error> { + // TODO(#475): uzers::all_groups() is available but unsafe; for now we just return the current + // user's groups. That's better than nothing. + let groups = get_current_user_groups()?; + let group_names = groups + .into_iter() + .map(|g| g.name().to_string_lossy().to_string()); + Ok(group_names.collect()) +} + +fn get_current_user_groups() -> Result, error::Error> { + let username = uzers::get_current_username().ok_or_else(|| error::ErrorKind::NoCurrentUser)?; + let gid = uzers::get_current_gid(); + let groups = uzers::get_user_groups(&username, gid).unwrap_or_default(); + Ok(groups) +} diff --git a/local/recipes/shells/brush/source/brush-core/src/sys/wasm/fs.rs b/local/recipes/shells/brush/source/brush-core/src/sys/wasm/fs.rs new file mode 100644 index 0000000000..423ab3cadc --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/sys/wasm/fs.rs @@ -0,0 +1,65 @@ +//! Filesystem utilities for WASM. + +pub use crate::sys::stubs::fs::*; + +impl crate::sys::fs::PathExt for std::path::Path { + fn readable(&self) -> bool { + true + } + + fn writable(&self) -> bool { + true + } + + fn executable(&self) -> bool { + true + } + + fn exists_and_is_block_device(&self) -> bool { + false + } + + fn exists_and_is_char_device(&self) -> bool { + false + } + + fn exists_and_is_fifo(&self) -> bool { + false + } + + fn exists_and_is_socket(&self) -> bool { + false + } + + fn exists_and_is_setgid(&self) -> bool { + false + } + + fn exists_and_is_setuid(&self) -> bool { + false + } + + fn exists_and_is_sticky_bit(&self) -> bool { + false + } + + fn get_device_and_inode(&self) -> Result<(u64, u64), crate::error::Error> { + Ok((0, 0)) + } +} + +/// Splits a PATH-like value into individual paths. +/// +/// On WASM, `std::env::split_paths` is not available, so this +/// implementation splits by the `:` separator. +pub fn split_paths + ?Sized>( + s: &T, +) -> impl Iterator { + s.as_ref() + .to_str() + .unwrap_or_default() + .split(':') + .map(std::path::PathBuf::from) + .collect::>() + .into_iter() +} diff --git a/local/recipes/shells/brush/source/brush-core/src/sys/wasm/mod.rs b/local/recipes/shells/brush/source/brush-core/src/sys/wasm/mod.rs new file mode 100644 index 0000000000..9e863eca68 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/sys/wasm/mod.rs @@ -0,0 +1,18 @@ +pub use crate::sys::stubs::async_pipe; +pub use crate::sys::stubs::commands; +pub(crate) use crate::sys::stubs::env; +pub use crate::sys::stubs::fd; +pub(crate) mod fs; +pub use crate::sys::stubs::input; +pub(crate) use crate::sys::stubs::network; +pub(crate) use crate::sys::stubs::pipes; +pub use crate::sys::stubs::poll; +pub use crate::sys::stubs::process; +pub use crate::sys::stubs::resource; +pub use crate::sys::stubs::signal; +pub use crate::sys::stubs::terminal; +pub(crate) use crate::sys::stubs::users; + +/// Platform-specific errors. +#[derive(Debug, thiserror::Error)] +pub enum PlatformError {} diff --git a/local/recipes/shells/brush/source/brush-core/src/sys/windows.rs b/local/recipes/shells/brush/source/brush-core/src/sys/windows.rs new file mode 100644 index 0000000000..9b9aa3d3f6 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/sys/windows.rs @@ -0,0 +1,23 @@ +pub use crate::sys::stubs::async_pipe; +pub use crate::sys::stubs::commands; +pub(crate) mod env; +pub use crate::sys::stubs::fd; +pub(crate) mod fs; +pub use crate::sys::stubs::input; +pub(crate) mod network; +pub use crate::sys::stubs::poll; +pub use crate::sys::stubs::resource; + +/// Signal processing utilities +pub mod signal { + pub(crate) use crate::sys::stubs::signal::*; + pub(crate) use tokio::signal::ctrl_c as await_ctrl_c; +} + +pub use crate::sys::stubs::terminal; +pub use crate::sys::tokio_process as process; +pub(crate) mod users; + +/// Platform-specific errors. +#[derive(Debug, thiserror::Error)] +pub enum PlatformError {} diff --git a/local/recipes/shells/brush/source/brush-core/src/sys/windows/env.rs b/local/recipes/shells/brush/source/brush-core/src/sys/windows/env.rs new file mode 100644 index 0000000000..900da98d7b --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/sys/windows/env.rs @@ -0,0 +1,207 @@ +//! Environment variable retrieval for Windows. +//! +//! On Windows, well-known environment variable names are normalized to their +//! canonical POSIX forms (e.g. `Path` → `PATH`), and `HOME` is synthesized +//! from `USERPROFILE` or `HOMEDRIVE`+`HOMEPATH` if not already present. + +use std::collections::BTreeMap; + +/// Retrieves environment variables from the host process, applying +/// Windows-specific fixups. +/// +/// Normalizes well-known variable names to POSIX conventions, copies +/// `TEMP`/`TMP` to `TMPDIR` (preserving originals for native Windows apps), +/// and synthesizes `HOME` if it is not natively defined. +/// +/// A [`BTreeMap`] is used (rather than `HashMap`) so iteration order is +/// deterministic across runs. If two source variables collide under the same +/// canonical name (e.g. both `Path` and `PATH` are set to different values), +/// the conflict is logged and the last-seen value wins. +pub(crate) fn get_host_env_vars() -> impl Iterator { + collect_host_env_vars(std::env::vars()) +} + +/// Collects and normalizes a set of environment variables. Exposed as a +/// pure function (taking the source iterator) so it can be unit-tested +/// without touching the process environment. +fn collect_host_env_vars(source: I) -> std::collections::btree_map::IntoIter +where + I: IntoIterator, +{ + let mut vars = BTreeMap::new(); + + // Normalize host env vars and inject them into the map. + for (k, v) in source { + let normalized = normalize_env_name(&k); + if let Some(existing) = vars.get(&normalized) + && existing != &v + { + tracing::warn!( + "environment variable collision under canonical name {normalized}: \ + two different values were supplied (last-write wins)" + ); + } + vars.insert(normalized, v); + } + + // Synthesize HOME from Windows-native variables if not already present. + if !vars.contains_key("HOME") { + let home = vars.get("USERPROFILE").cloned().or_else(|| { + let d = vars.get("HOMEDRIVE")?; + let p = vars.get("HOMEPATH")?; + Some(format!("{d}{p}")) + }); + if let Some(home) = home { + vars.insert("HOME".to_string(), home); + } + } + + // Copy TEMP/TMP to TMPDIR if TMPDIR doesn't already exist. + if !vars.contains_key("TMPDIR") + && let Some(tmp) = vars.get("TEMP").or_else(|| vars.get("TMP")).cloned() + { + vars.insert("TMPDIR".to_string(), tmp); + } + + vars.into_iter() +} + +/// Normalizes the case of well-known environment variable names to their +/// canonical POSIX forms (`Path` → `PATH`, `home` → `HOME`). +/// +/// # Arguments +/// +/// * `name` - The environment variable name to normalize. +fn normalize_env_name(name: &str) -> String { + // Normalize well-known variable names so that later lookups by + // canonical (uppercase) spelling always succeed regardless of the + // host's original casing. + const WELL_KNOWN: &[&str] = &[ + "PATH", + "HOME", + "USERPROFILE", + "HOMEDRIVE", + "HOMEPATH", + "TEMP", + "TMP", + "TMPDIR", + ]; + + for &canonical in WELL_KNOWN { + if name.eq_ignore_ascii_case(canonical) { + return canonical.to_string(); + } + } + + name.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn run(source: &[(&str, &str)]) -> BTreeMap { + collect_host_env_vars( + source + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())), + ) + .collect() + } + + #[test] + fn normalize_env_name_canonicalizes_well_known() { + assert_eq!(normalize_env_name("Path"), "PATH"); + assert_eq!(normalize_env_name("path"), "PATH"); + assert_eq!(normalize_env_name("PATH"), "PATH"); + assert_eq!(normalize_env_name("Home"), "HOME"); + assert_eq!(normalize_env_name("UserProfile"), "USERPROFILE"); + assert_eq!(normalize_env_name("Temp"), "TEMP"); + assert_eq!(normalize_env_name("Tmp"), "TMP"); + assert_eq!(normalize_env_name("TmpDir"), "TMPDIR"); + } + + #[test] + fn normalize_env_name_leaves_unknown_alone() { + assert_eq!(normalize_env_name("FOO"), "FOO"); + assert_eq!(normalize_env_name("myVar"), "myVar"); + // Does not uppercase unknown names. + assert_eq!(normalize_env_name("AppData"), "AppData"); + } + + #[test] + fn synthesizes_home_from_userprofile() { + let vars = run(&[("UserProfile", r"C:\Users\reuben")]); + assert_eq!( + vars.get("HOME").map(String::as_str), + Some(r"C:\Users\reuben") + ); + assert_eq!( + vars.get("USERPROFILE").map(String::as_str), + Some(r"C:\Users\reuben") + ); + } + + #[test] + fn synthesizes_home_from_homedrive_homepath_when_no_userprofile() { + let vars = run(&[("HomeDrive", "C:"), ("HomePath", r"\Users\reuben")]); + assert_eq!( + vars.get("HOME").map(String::as_str), + Some(r"C:\Users\reuben") + ); + } + + #[test] + fn preserves_existing_home() { + let vars = run(&[("HOME", "/already/set"), ("UserProfile", r"C:\Users\other")]); + assert_eq!(vars.get("HOME").map(String::as_str), Some("/already/set")); + } + + #[test] + fn copies_temp_to_tmpdir() { + let vars = run(&[("Temp", r"C:\Windows\Temp")]); + assert_eq!( + vars.get("TMPDIR").map(String::as_str), + Some(r"C:\Windows\Temp") + ); + } + + #[test] + fn prefers_temp_over_tmp_for_tmpdir() { + let vars = run(&[("TEMP", "one"), ("TMP", "two")]); + assert_eq!(vars.get("TMPDIR").map(String::as_str), Some("one")); + } + + #[test] + fn falls_back_to_tmp_when_no_temp() { + let vars = run(&[("TMP", "two")]); + assert_eq!(vars.get("TMPDIR").map(String::as_str), Some("two")); + } + + #[test] + fn preserves_existing_tmpdir() { + let vars = run(&[("TMPDIR", "original"), ("TEMP", "other")]); + assert_eq!(vars.get("TMPDIR").map(String::as_str), Some("original")); + } + + #[test] + fn deterministic_iteration_order() { + // BTreeMap iteration order is determined by the sorted key order, + // so the outputs must be identical across invocations regardless of + // input ordering. + let a = run(&[("Path", "first"), ("ZETA", "zz"), ("Alpha", "aa")]); + let b = run(&[("ZETA", "zz"), ("Alpha", "aa"), ("Path", "first")]); + let keys_a: Vec<_> = a.keys().collect(); + let keys_b: Vec<_> = b.keys().collect(); + assert_eq!(keys_a, keys_b); + } + + #[test] + fn collision_last_write_wins() { + // When two source names normalize to the same canonical key, + // the later one should overwrite the earlier one (and it should not + // panic). + let vars = run(&[("Path", "first"), ("PATH", "second")]); + assert_eq!(vars.get("PATH").map(String::as_str), Some("second")); + } +} diff --git a/local/recipes/shells/brush/source/brush-core/src/sys/windows/fs.rs b/local/recipes/shells/brush/source/brush-core/src/sys/windows/fs.rs new file mode 100644 index 0000000000..78650089f9 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/sys/windows/fs.rs @@ -0,0 +1,455 @@ +//! Filesystem utilities for Windows. + +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; +use std::sync::LazyLock; + +use crate::error; + +// Selectively re-export items from stubs that we don't override. +pub(crate) use crate::sys::stubs::fs::MetadataExt; + +/// Cached list of executable extensions from the `PATHEXT` environment +/// variable. Each entry retains its leading dot (e.g. `".exe"`) and is stored +/// lowercased so case-insensitive comparisons can be done without allocating. +/// +/// NOTE: This is cached for the process lifetime. Changes to `PATHEXT` made +/// inside the running shell are not reflected here. Bash itself has no +/// `PATHEXT` semantics, so this is generally acceptable for now. +static PATHEXT_EXTENSIONS: LazyLock> = LazyLock::new(|| { + std::env::var("PATHEXT") + .unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string()) + .split(';') + .filter(|s| !s.is_empty()) + .map(|s| s.to_ascii_lowercase()) + .collect() +}); + +/// Returns the stem of a PATHEXT entry (with any leading `.` removed). +/// +/// `PATHEXT` canonically stores entries like `.EXE`, but tolerant parsing +/// accepts entries without the leading dot too. +fn pathext_entry_stem(entry: &str) -> &str { + entry.strip_prefix('.').unwrap_or(entry) +} + +/// Returns true if the path's extension is in the PATHEXT list. +/// +/// Performs case-insensitive comparison against the cached PATHEXT entries +/// without allocating. +fn has_executable_extension(path: &Path) -> bool { + path.extension().is_some_and(|ext| { + PATHEXT_EXTENSIONS + .iter() + .any(|e| ext.eq_ignore_ascii_case(pathext_entry_stem(e))) + }) +} + +/// Returns true if `path` is, by itself, an existing executable file. +/// +/// Used both for the initial check in [`resolve_executable`] and for +/// [`PathExt::executable`]. +fn is_executable_file(path: &Path) -> bool { + has_executable_extension(path) && path.is_file() +} + +/// Resolves an owned path to the actual on-disk executable file, if any. +/// +/// If the path is already a file with a `PATHEXT` extension, it is returned +/// unchanged (no allocation). Otherwise, each `PATHEXT` extension is appended +/// in turn and the first existing file is returned. +pub fn resolve_executable(path: PathBuf) -> Option { + if is_executable_file(&path) { + return Some(path); + } + // Try appending each PATHEXT extension. + for ext in PATHEXT_EXTENSIONS.iter() { + let mut name = path.as_os_str().to_owned(); + name.push(ext); + let candidate = PathBuf::from(name); + if candidate.is_file() { + return Some(candidate); + } + } + None +} + +impl crate::sys::fs::PathExt for Path { + fn readable(&self) -> bool { + self.exists() + } + + fn writable(&self) -> bool { + self.metadata().is_ok_and(|m| !m.permissions().readonly()) + } + + fn executable(&self) -> bool { + if is_executable_file(self) { + return true; + } + // Try each PATHEXT extension without allocating a separate PathBuf + // per candidate until one exists. + PATHEXT_EXTENSIONS.iter().any(|ext| { + let mut name = self.as_os_str().to_owned(); + name.push(ext); + Self::new(&name).is_file() + }) + } + + fn exists_and_is_block_device(&self) -> bool { + false + } + + fn exists_and_is_char_device(&self) -> bool { + false + } + + fn exists_and_is_fifo(&self) -> bool { + false + } + + fn exists_and_is_socket(&self) -> bool { + false + } + + fn exists_and_is_setgid(&self) -> bool { + false + } + + fn exists_and_is_setuid(&self) -> bool { + false + } + + fn exists_and_is_sticky_bit(&self) -> bool { + false + } + + fn get_device_and_inode(&self) -> Result<(u64, u64), crate::error::Error> { + // TODO(windows): implement using file index / volume serial number. + Err(error::ErrorKind::NotSupportedOnThisPlatform("get_device_and_inode").into()) + } +} + +/// Splits a platform-specific PATH-like value into individual paths. +/// +/// On Windows, this delegates to [`std::env::split_paths`]. +pub fn split_paths + ?Sized>(s: &T) -> std::env::SplitPaths<'_> { + std::env::split_paths(s) +} + +/// Opens a null file that will discard all I/O. +pub fn open_null_file() -> Result { + let f = std::fs::File::options() + .read(true) + .write(true) + .open("NUL")?; + Ok(f) +} + +/// Gives the platform an opportunity to handle a special file path (e.g. `/dev/null`). +pub fn try_open_special_file(path: &Path) -> Option> { + if path.ends_with("dev/null") && path.is_absolute() { + Some(open_null_file().map_err(std::io::Error::other)) + } else { + None + } +} + +/// Returns the default paths where executables are typically found on Windows. +pub(crate) fn get_default_executable_search_paths() -> Vec { + default_system_paths() +} + +/// Returns the default paths where standard system utilities are found on Windows. +pub fn get_default_standard_utils_paths() -> Vec { + default_system_paths() +} + +fn default_system_paths() -> Vec { + let mut paths = Vec::new(); + if let Ok(sysroot) = std::env::var("SystemRoot") { + paths.push(PathBuf::from(&sysroot).join("system32")); + paths.push(PathBuf::from(&sysroot)); + paths.push(PathBuf::from(&sysroot).join("System32").join("Wbem")); + paths.push( + PathBuf::from(&sysroot) + .join("System32") + .join("WindowsPowerShell") + .join("v1.0"), + ); + } + if let Ok(userprofile) = std::env::var("USERPROFILE") { + paths.push( + PathBuf::from(userprofile) + .join("AppData") + .join("Local") + .join("Microsoft") + .join("WindowsApps"), + ); + } + paths +} + +/// Returns the path to the system-wide shell profile script. +/// +/// On Windows, no system profile is loaded by default. +pub const fn get_system_profile_path() -> Option<&'static Path> { + None +} + +/// Returns the path to the system-wide shell rc script. +/// +/// On Windows, no system rc file is loaded by default. +pub const fn get_system_rc_path() -> Option<&'static Path> { + None +} + +/// Returns the platform default for case-insensitive pathname expansion. +/// +/// On Windows, filesystems are typically case-insensitive, so this returns `true`. +pub const fn default_case_insensitive_path_expansion() -> bool { + true +} + +/// Path separator characters on Windows. +const PATH_SEPARATORS: [char; 2] = ['/', '\\']; + +/// Returns true if the string contains a path separator character. +/// +/// On Windows, both `/` and `\` are considered path separators. +pub fn contains_path_separator(s: &str) -> bool { + s.contains(PATH_SEPARATORS) +} + +/// Returns true if the string ends with a path separator character. +/// +/// On Windows, both `/` and `\` are considered path separators. +pub fn ends_with_path_separator(s: &str) -> bool { + s.ends_with(PATH_SEPARATORS) +} + +/// Returns the string with a trailing path separator removed, if present. +/// +/// On Windows, both `/` and `\` are considered path separators. +pub fn strip_path_separator_suffix(s: &str) -> &str { + s.strip_suffix(PATH_SEPARATORS).unwrap_or(s) +} + +/// Finds the byte index of the last path separator in the string. +/// +/// On Windows, both `/` and `\` are considered path separators. +pub fn rfind_path_separator(s: &str) -> Option { + s.rfind(PATH_SEPARATORS) +} + +/// Splits a string on path separator characters, returning an iterator of components. +/// +/// On Windows, both `/` and `\` are used as separators. +pub fn split_path_for_pattern(s: &str) -> impl Iterator { + s.split(PATH_SEPARATORS) +} + +/// Returns the root path for an absolute pattern, if the first component indicates one. +/// +/// On Windows, recognizes both a leading separator (empty first component from splitting +/// a path like `/foo`) and a drive-letter prefix like `C:` as absolute. +/// +/// TODO(windows): UNC paths like `\\server\share\foo` are not yet handled +/// specially; they split into `["", "", "server", "share", "foo"]`, and the +/// leading empty component causes them to be treated as if they were rooted +/// at `/`, which drops the server/share portion. Supporting UNC requires +/// peeking further into the component list. +pub fn pattern_path_root(first_component: &str) -> Option { + if first_component.is_empty() { + // Leading separator, e.g. `/foo` split into ["", "foo"]. + Some(PathBuf::from("/")) + } else if first_component.len() == 2 + && first_component.as_bytes()[0].is_ascii_alphabetic() + && first_component.as_bytes()[1] == b':' + { + // Drive letter prefix, e.g. `c:/foo` split into ["c:", "foo"]. + let mut root = String::with_capacity(3); + root.push_str(first_component); + root.push('/'); + Some(PathBuf::from(root)) + } else { + None + } +} + +/// Pushes a component onto a path for pattern expansion. +/// +/// On Windows, `PathBuf::push` has special drive-letter and root-replacement +/// semantics that conflict with shell path construction (e.g. pushing `C:foo` +/// onto `D:\bar` replaces the whole path). This function always appends the +/// component as a child, operating on the underlying `OsString` so non-UTF-8 +/// content in the path is preserved and no reallocation is needed. +pub fn push_path_for_pattern(path: &mut PathBuf, component: &str) { + // Separator characters are ASCII, and WTF-8-encoded OsStr bytes are a + // superset of UTF-8, so checking the last byte directly is safe. + let bytes = path.as_os_str().as_encoded_bytes(); + let needs_sep = !bytes.is_empty() && !matches!(bytes.last(), Some(b'/' | b'\\')); + + let buf = path.as_mut_os_string(); + if needs_sep { + buf.push("/"); + } + buf.push(component); +} + +/// Normalizes path separators for shell output. +/// +/// On Windows, replaces `\` with `/` since backslash is the shell escape character. +pub fn normalize_path_separators(s: &str) -> std::borrow::Cow<'_, str> { + if s.contains('\\') { + std::borrow::Cow::Owned(s.replace('\\', "/")) + } else { + std::borrow::Cow::Borrowed(s) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn path_separator_helpers_both_slashes() { + assert!(contains_path_separator("foo/bar")); + assert!(contains_path_separator(r"foo\bar")); + assert!(contains_path_separator(r"mixed/and\back")); + assert!(!contains_path_separator("foobar")); + + assert!(ends_with_path_separator("foo/")); + assert!(ends_with_path_separator(r"foo\")); + assert!(!ends_with_path_separator("foo")); + + assert_eq!(strip_path_separator_suffix("foo/"), "foo"); + assert_eq!(strip_path_separator_suffix(r"foo\"), "foo"); + assert_eq!(strip_path_separator_suffix("foo"), "foo"); + + assert_eq!(rfind_path_separator("a/b/c"), Some(3)); + assert_eq!(rfind_path_separator(r"a\b\c"), Some(3)); + assert_eq!(rfind_path_separator(r"a/b\c"), Some(3)); + assert_eq!(rfind_path_separator("abc"), None); + } + + #[test] + fn split_path_for_pattern_both_slashes() { + let parts: Vec<_> = split_path_for_pattern("a/b/c").collect(); + assert_eq!(parts, vec!["a", "b", "c"]); + + let parts: Vec<_> = split_path_for_pattern(r"a\b\c").collect(); + assert_eq!(parts, vec!["a", "b", "c"]); + + let parts: Vec<_> = split_path_for_pattern(r"a/b\c").collect(); + assert_eq!(parts, vec!["a", "b", "c"]); + + let parts: Vec<_> = split_path_for_pattern("/a/b").collect(); + assert_eq!(parts, vec!["", "a", "b"]); + } + + #[test] + fn pattern_path_root_leading_separator() { + assert_eq!(pattern_path_root(""), Some(PathBuf::from("/"))); + } + + #[test] + fn pattern_path_root_drive_letters() { + assert_eq!(pattern_path_root("c:"), Some(PathBuf::from("c:/"))); + assert_eq!(pattern_path_root("C:"), Some(PathBuf::from("C:/"))); + assert_eq!(pattern_path_root("Z:"), Some(PathBuf::from("Z:/"))); + } + + #[test] + fn pattern_path_root_rejects_non_drive_two_char_prefix() { + // "1:" is not a valid drive letter — must be alphabetic. + assert_eq!(pattern_path_root("1:"), None); + // Longer drive-like strings are not treated as roots. + assert_eq!(pattern_path_root("cd"), None); + assert_eq!(pattern_path_root("c:\\"), None); + assert_eq!(pattern_path_root("foo"), None); + } + + #[test] + fn push_path_for_pattern_appends_with_forward_slash() { + let mut p = PathBuf::from(r"C:\Users\reuben"); + push_path_for_pattern(&mut p, "foo"); + // Forward slash is used as the appended separator, yielding mixed + // separators — acceptable because `normalize_path_separators` is + // applied downstream before display. + assert_eq!(p, PathBuf::from(r"C:\Users\reuben/foo")); + } + + #[test] + fn push_path_for_pattern_no_double_separator() { + let mut p = PathBuf::from("C:/Users/reuben/"); + push_path_for_pattern(&mut p, "foo"); + assert_eq!(p, PathBuf::from("C:/Users/reuben/foo")); + + let mut p = PathBuf::from(r"C:\Users\reuben\"); + push_path_for_pattern(&mut p, "foo"); + assert_eq!(p, PathBuf::from(r"C:\Users\reuben\foo")); + } + + #[test] + fn push_path_for_pattern_onto_drive_root() { + let mut p = PathBuf::from("c:/"); + push_path_for_pattern(&mut p, "foo"); + assert_eq!(p, PathBuf::from("c:/foo")); + } + + #[test] + fn push_path_for_pattern_onto_empty() { + let mut p = PathBuf::new(); + push_path_for_pattern(&mut p, "foo"); + // Empty path stays un-prefixed — we only add a separator between + // existing content and the new component. + assert_eq!(p, PathBuf::from("foo")); + } + + #[test] + fn normalize_path_separators_converts_backslashes() { + use std::borrow::Cow; + // Already-forward-slashed input is borrowed (no allocation). + assert!(matches!( + normalize_path_separators("c:/foo/bar"), + Cow::Borrowed("c:/foo/bar") + )); + // Mixed or backslashed input becomes owned and fully forward-slashed. + let normalized = normalize_path_separators(r"c:\foo\bar"); + assert_eq!(normalized.as_ref(), "c:/foo/bar"); + let normalized = normalize_path_separators(r"c:\foo/bar"); + assert_eq!(normalized.as_ref(), "c:/foo/bar"); + } + + #[test] + fn default_case_insensitive_is_true() { + assert!(default_case_insensitive_path_expansion()); + } + + #[test] + fn has_executable_extension_is_case_insensitive() { + // Force the PATHEXT cache for this test's defaults. + assert!(has_executable_extension(Path::new("foo.exe"))); + assert!(has_executable_extension(Path::new("foo.EXE"))); + assert!(has_executable_extension(Path::new("foo.Cmd"))); + assert!(!has_executable_extension(Path::new("foo.txt"))); + assert!(!has_executable_extension(Path::new("foo"))); + } + + #[test] + fn pathext_entry_stem_strips_dot() { + assert_eq!(pathext_entry_stem(".exe"), "exe"); + assert_eq!(pathext_entry_stem(".cmd"), "cmd"); + // Tolerant: entries without a leading dot are returned as-is. + assert_eq!(pathext_entry_stem("exe"), "exe"); + assert_eq!(pathext_entry_stem(""), ""); + } + + #[test] + fn resolve_executable_for_nonexistent_returns_none() { + // A path that cannot exist on any test host. + let path = PathBuf::from(r"C:\__brush_test_definitely_missing__"); + assert!(resolve_executable(path).is_none()); + } +} diff --git a/local/recipes/shells/brush/source/brush-core/src/sys/windows/network.rs b/local/recipes/shells/brush/source/brush-core/src/sys/windows/network.rs new file mode 100644 index 0000000000..b2ae829ece --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/sys/windows/network.rs @@ -0,0 +1,3 @@ +pub(crate) fn get_hostname() -> std::io::Result { + crate::sys::hostname::get() +} diff --git a/local/recipes/shells/brush/source/brush-core/src/sys/windows/users.rs b/local/recipes/shells/brush/source/brush-core/src/sys/windows/users.rs new file mode 100644 index 0000000000..97fde33d42 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/sys/windows/users.rs @@ -0,0 +1,86 @@ +#![allow(clippy::missing_const_for_fn)] +#![allow(clippy::unnecessary_wraps)] + +use crate::error; +use std::path::PathBuf; +use std::sync::LazyLock; + +/// Placeholder UID for non-elevated Windows processes. +/// +/// Real Unix-style UIDs don't exist on Windows; this value is a +/// conventional non-root sentinel (matching the typical first +/// regular-user UID on Linux). +const NON_ELEVATED_UID: u32 = 1000; + +/// Placeholder GID for non-elevated Windows processes (see [`NON_ELEVATED_UID`]). +const NON_ELEVATED_GID: u32 = 1000; + +/// Cached elevation status. The underlying check queries the process token, +/// which can't change after process start, so it's safe to memoize. +static IS_ELEVATED: LazyLock = LazyLock::new(|| { + check_elevation::is_elevated().unwrap_or_else(|err| { + tracing::warn!("failed to determine process elevation: {err}"); + false + }) +}); + +pub(crate) fn get_user_home_dir(_username: &str) -> Option { + // std::env::home_dir() doesn't support getting home dir for arbitrary users + // For now, we only support getting the current user's home dir + None +} + +pub(crate) fn get_current_user_home_dir() -> Option { + std::env::home_dir() +} + +pub(crate) fn get_current_user_default_shell() -> Option { + None +} + +fn is_elevated() -> bool { + *IS_ELEVATED +} + +pub(crate) fn is_root() -> bool { + is_elevated() +} + +pub(crate) fn get_current_uid() -> Result { + Ok(if is_elevated() { 0 } else { NON_ELEVATED_UID }) +} + +pub(crate) fn get_current_gid() -> Result { + Ok(if is_elevated() { 0 } else { NON_ELEVATED_GID }) +} + +pub(crate) fn get_effective_uid() -> Result { + Ok(if is_elevated() { 0 } else { NON_ELEVATED_UID }) +} + +pub(crate) fn get_effective_gid() -> Result { + Ok(if is_elevated() { 0 } else { NON_ELEVATED_GID }) +} + +pub(crate) fn get_current_username() -> Result { + let username = whoami::username().map_err(std::io::Error::from)?; + Ok(username) +} + +#[allow(clippy::unnecessary_wraps)] +pub(crate) fn get_user_group_ids() -> Result, error::Error> { + // TODO(windows): implement some version of this for Windows + Ok(vec![]) +} + +#[expect(clippy::unnecessary_wraps)] +pub(crate) fn get_all_users() -> Result, error::Error> { + // TODO(windows): implement some version of this for Windows + Ok(vec![]) +} + +#[expect(clippy::unnecessary_wraps)] +pub(crate) fn get_all_groups() -> Result, error::Error> { + // TODO(windows): implement some version of this for Windows + Ok(vec![]) +} diff --git a/local/recipes/shells/brush/source/brush-core/src/terminal.rs b/local/recipes/shells/brush/source/brush-core/src/terminal.rs new file mode 100644 index 0000000000..f3050bab42 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/terminal.rs @@ -0,0 +1,99 @@ +//! Terminal control utilities. + +use crate::{error, openfiles, sys}; + +/// Encapsulates the state of a controlled terminal. +pub struct TerminalControl { + prev_fg_pid: Option, +} + +impl TerminalControl { + /// Acquire the terminal for the shell. + pub fn acquire() -> Result { + // Mask out SIGTTOU *first*. If `lead_new_process_group` succeeds in + // moving us into a new process group, the subsequent `tcsetpgrp` call + // in `move_self_to_foreground` is a "write to the controlling + // terminal from a background process," which the kernel signals with + // SIGTTOU. The default action for SIGTTOU is to stop the process, + // leaving brush (and any downstream reads from the terminal) hung. + // Installing the SIG_IGN handler before the tcsetpgrp makes that call + // succeed instead of stopping us. + sys::signal::mask_sigttou()?; + + let prev_fg_pid = sys::terminal::get_foreground_pid(); + + // Break out into new process group. + // TODO(jobs): Investigate why this sometimes fails with EPERM. + let _ = sys::signal::lead_new_process_group(); + + // Take ownership. + sys::terminal::move_self_to_foreground()?; + + Ok(Self { prev_fg_pid }) + } + + fn try_release(&mut self) { + // Restore the previous foreground process group. + if let Some(pid) = self.prev_fg_pid + && sys::terminal::move_to_foreground(pid).is_ok() + { + self.prev_fg_pid = None; + } + } +} + +impl Drop for TerminalControl { + fn drop(&mut self) { + self.try_release(); + } +} + +/// Describes high-level terminal settings that can be requested. +#[derive(Default, bon::Builder)] +pub struct Settings { + /// Whether to enable input echoing. + pub echo_input: Option, + /// Whether to enable line input (sometimes known as canonical mode). + pub line_input: Option, + /// Whether to disable interrupt signals and instead yield the control characters. + pub interrupt_signals: Option, + /// Whether to output newline characters as CRLF pairs. + pub output_nl_as_nlcr: Option, +} + +/// Guard that automatically restores terminal settings on drop. +pub struct AutoModeGuard { + initial: sys::terminal::Config, + file: openfiles::OpenFile, +} + +impl AutoModeGuard { + /// Creates a new `AutoModeGuard` for the given file. + /// + /// # Arguments + /// + /// * `file` - The file representing the terminal to control. + pub fn new(file: openfiles::OpenFile) -> Result { + let initial = sys::terminal::Config::from_term(&file)?; + Ok(Self { initial, file }) + } + + /// Applies the given terminal settings. + /// + /// # Arguments + /// + /// * `settings` - The terminal settings to apply. + pub fn apply_settings(&self, settings: &Settings) -> Result<(), error::Error> { + let mut config = sys::terminal::Config::from_term(&self.file)?; + config.update(settings); + config.apply_to_term(&self.file)?; + + Ok(()) + } +} + +impl Drop for AutoModeGuard { + fn drop(&mut self) { + let _ = self.initial.apply_to_term(&self.file); + } +} diff --git a/local/recipes/shells/brush/source/brush-core/src/tests.rs b/local/recipes/shells/brush/source/brush-core/src/tests.rs new file mode 100644 index 0000000000..2d3c5e1863 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/tests.rs @@ -0,0 +1,37 @@ +//! Shell test conditional expressions + +use crate::{ExecutionParameters, Shell, error, extendedtests, extensions}; + +/// Evaluate the given test expression within the provided shell and +/// execution context. Returns true if the expression evaluates to true, +/// false otherwise. +/// +/// # Arguments +/// +/// * `expr` - The test expression to evaluate. +/// * `shell` - The shell context in which to evaluate the expression. +/// * `params` - The execution parameters to use during evaluation. +pub fn eval_expr( + expr: &brush_parser::ast::TestExpr, + shell: &mut Shell, + params: &ExecutionParameters, +) -> Result { + match expr { + brush_parser::ast::TestExpr::False => Ok(false), + brush_parser::ast::TestExpr::Literal(s) => Ok(!s.is_empty()), + brush_parser::ast::TestExpr::And(left, right) => { + Ok(eval_expr(left, shell, params)? && eval_expr(right, shell, params)?) + } + brush_parser::ast::TestExpr::Or(left, right) => { + Ok(eval_expr(left, shell, params)? || eval_expr(right, shell, params)?) + } + brush_parser::ast::TestExpr::Not(expr) => Ok(!eval_expr(expr, shell, params)?), + brush_parser::ast::TestExpr::Parenthesized(expr) => eval_expr(expr, shell, params), + brush_parser::ast::TestExpr::UnaryTest(op, operand) => { + extendedtests::apply_unary_predicate_to_str(op, operand, shell, params) + } + brush_parser::ast::TestExpr::BinaryTest(op, left, right) => { + extendedtests::apply_binary_predicate_to_strs(op, left.as_str(), right.as_str(), shell) + } + } +} diff --git a/local/recipes/shells/brush/source/brush-core/src/timing.rs b/local/recipes/shells/brush/source/brush-core/src/timing.rs new file mode 100644 index 0000000000..e2faf672c9 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/timing.rs @@ -0,0 +1,137 @@ +//! Command timing + +use crate::error; + +struct StopwatchTime { + now: std::time::SystemTime, + self_user: std::time::Duration, + self_system: std::time::Duration, + children_user: std::time::Duration, + children_system: std::time::Duration, +} + +impl StopwatchTime { + #[allow(clippy::unchecked_time_subtraction)] + fn minus(&self, other: &Self) -> Result { + let user = (self.self_user - other.self_user) + (self.children_user - other.children_user); + let system = + (self.self_system - other.self_system) + (self.children_system - other.children_system); + + Ok(StopwatchTiming { + wall: self.now.duration_since(other.now)?, + user, + system, + }) + } +} + +pub(crate) struct Stopwatch { + start: StopwatchTime, +} + +impl Stopwatch { + pub fn stop(&self) -> Result { + let end = get_current_stopwatch_time()?; + end.minus(&self.start) + } +} +pub(crate) struct StopwatchTiming { + pub wall: std::time::Duration, + pub user: std::time::Duration, + pub system: std::time::Duration, +} + +pub(crate) fn start_timing() -> Result { + Ok(Stopwatch { + start: get_current_stopwatch_time()?, + }) +} + +fn get_current_stopwatch_time() -> Result { + let now = std::time::SystemTime::now(); + let (self_user, self_system) = crate::sys::resource::get_self_user_and_system_time()?; + let (children_user, children_system) = + crate::sys::resource::get_children_user_and_system_time()?; + + Ok(StopwatchTime { + now, + self_user, + self_system, + children_user, + children_system, + }) +} + +/// Format the given duration in a non-POSIX-y way. +/// +/// # Arguments +/// +/// * `duration` - The duration to format. +pub fn format_duration_non_posixly(duration: &std::time::Duration) -> String { + let minutes = duration.as_secs() / 60; + let seconds = duration.as_secs() % 60; + let millis = duration.subsec_millis(); + format!("{minutes}m{seconds}.{millis:03}s") +} + +/// Format the given duration in a POSIX-y way. +/// +/// # Arguments +/// +/// * `duration` - The duration to format. +pub fn format_duration_posixly(duration: &std::time::Duration) -> String { + let seconds = duration.as_secs(); + let ten_millis = duration.subsec_millis() / 10; + format!("{seconds}.{ten_millis:02}") +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + #[test] + fn test_format_time() { + assert_eq!( + format_duration_non_posixly(&Duration::from_millis(0)), + "0m0.000s" + ); + assert_eq!( + format_duration_non_posixly(&Duration::from_millis(1)), + "0m0.001s" + ); + assert_eq!( + format_duration_non_posixly(&Duration::from_millis(123)), + "0m0.123s" + ); + assert_eq!( + format_duration_non_posixly(&Duration::from_millis(1234)), + "0m1.234s" + ); + assert_eq!( + format_duration_non_posixly(&Duration::from_millis(12345)), + "0m12.345s" + ); + assert_eq!( + format_duration_non_posixly(&Duration::from_millis(123_456)), + "2m3.456s" + ); + assert_eq!( + format_duration_non_posixly(&Duration::from_millis(1_234_567)), + "20m34.567s" + ); + + assert_eq!( + format_duration_non_posixly(&Duration::from_micros(1)), + "0m0.000s" + ); + assert_eq!( + format_duration_non_posixly(&Duration::from_micros(999)), + "0m0.000s" + ); + assert_eq!( + format_duration_non_posixly(&Duration::from_micros(1001)), + "0m0.001s" + ); + } +} diff --git a/local/recipes/shells/brush/source/brush-core/src/trace_categories.rs b/local/recipes/shells/brush/source/brush-core/src/trace_categories.rs new file mode 100644 index 0000000000..a3106993cc --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/trace_categories.rs @@ -0,0 +1,20 @@ +//! Trace utilities + +/// Trace category for command execution. +pub const COMMANDS: &str = "commands"; +/// Trace category for completion. +pub const COMPLETION: &str = "completion"; +/// Trace category for word expansion. +pub const EXPANSION: &str = "expansion"; +/// Trace category for function calls. +pub const FUNCTIONS: &str = "functions"; +/// Trace category for user input. +pub const INPUT: &str = "input"; +/// Trace category for job control. +pub const JOBS: &str = "jobs"; +/// Trace category for parsing. +pub const PARSE: &str = "parse"; +/// Trace category for shell patterns. +pub const PATTERN: &str = "pattern"; +/// Trace category for unimplemented behavior. +pub const UNIMPLEMENTED: &str = "unimplemented"; diff --git a/local/recipes/shells/brush/source/brush-core/src/traps.rs b/local/recipes/shells/brush/source/brush-core/src/traps.rs new file mode 100644 index 0000000000..e432488904 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/traps.rs @@ -0,0 +1,235 @@ +//! Facilities for configuring trap handlers. + +use std::str::FromStr; +use std::{collections::HashMap, fmt::Display}; + +use itertools::Itertools as _; + +use crate::{error, sys}; + +/// Type of signal that can be trapped in the shell. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum TrapSignal { + /// A system signal. + Signal(sys::signal::Signal), + /// The `DEBUG` trap. + Debug, + /// The `ERR` trap. + Err, + /// The `EXIT` trap. + Exit, + /// The `RETURN` trp. + Return, +} + +#[cfg(feature = "serde")] +impl serde::Serialize for TrapSignal { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for TrapSignal { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Self::try_from(s.as_str()).map_err(serde::de::Error::custom) + } +} + +impl Display for TrapSignal { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +impl TrapSignal { + /// Returns all possible values of [`TrapSignal`]. + pub fn iterator() -> impl Iterator { + const SIGNALS: &[TrapSignal] = &[TrapSignal::Debug, TrapSignal::Err, TrapSignal::Exit]; + + let iter = itertools::chain!( + SIGNALS.iter().copied(), + sys::signal::Signal::iterator().map(TrapSignal::Signal) + ); + + iter + } + + /// Converts [`TrapSignal`] into its corresponding signal name as a [`&'static str`](str) + pub const fn as_str(self) -> &'static str { + match self { + Self::Signal(s) => s.as_str(), + Self::Debug => "DEBUG", + Self::Err => "ERR", + Self::Exit => "EXIT", + Self::Return => "RETURN", + } + } +} + +/// Formats [`Iterator`](TrapSignal) to the provided writer. +/// +/// # Arguments +/// +/// * `f` - Any type that implements [`std::io::Write`]. +/// * `it` - An iterator over the signals that will be formatted into the `f`. +pub fn format_signals( + mut f: impl std::io::Write, + it: impl Iterator, +) -> Result<(), error::Error> { + let it = it + .filter_map(|s| i32::try_from(s).ok().map(|n| (s, n))) + .sorted_by(|a, b| Ord::cmp(&a.1, &b.1)) + .format_with("\n", |s, f| f(&format_args!("{}) {}", s.1, s.0))); + write!(f, "{it}")?; + Ok(()) +} + +// implement s.parse::() +impl FromStr for TrapSignal { + type Err = error::Error; + fn from_str(s: &str) -> Result::Err> { + if let Ok(n) = s.parse::() { + Self::try_from(n) + } else { + Self::try_from(s) + } + } +} + +// from a signal number +impl TryFrom for TrapSignal { + type Error = error::Error; + fn try_from(value: i32) -> Result { + // NOTE: DEBUG and ERR are real-time signals, defined based on NSIG or SIGRTMAX (is not + // available on bsd-like systems), + // and don't have persistent numbers across platforms, so we skip them here. + Ok(match value { + 0 => Self::Exit, + value => Self::Signal( + sys::signal::Signal::try_from(value) + .map_err(|_| error::ErrorKind::InvalidSignal(value.to_string()))?, + ), + }) + } +} + +// from a signal name +impl TryFrom<&str> for TrapSignal { + type Error = error::Error; + fn try_from(value: &str) -> Result { + #[allow(unused_mut, reason = "only mutated on some platforms")] + let mut s = value.to_ascii_uppercase(); + + Ok(match s.as_str() { + "DEBUG" => Self::Debug, + "ERR" => Self::Err, + "EXIT" => Self::Exit, + "RETURN" => Self::Return, + _ => { + // Bash compatibility: + // support for signal names without the `SIG` prefix, for example `HUP` -> `SIGHUP` + if !s.starts_with("SIG") { + s.insert_str(0, "SIG"); + } + sys::signal::Signal::from_str(s.as_str()) + .map(TrapSignal::Signal) + .map_err(|_| error::ErrorKind::InvalidSignal(value.into()))? + } + }) + } +} + +/// Error type used when failing to convert a `TrapSignal` to a number. +#[derive(Debug, Clone, Copy)] +pub struct TrapSignalNumberError; + +impl TryFrom for i32 { + type Error = TrapSignalNumberError; + fn try_from(value: TrapSignal) -> Result { + Ok(match value { + TrapSignal::Signal(s) => s as Self, + TrapSignal::Exit => 0, + _ => return Err(TrapSignalNumberError), + }) + } +} + +/// A handler for a trap signal. +#[derive(Clone, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct TrapHandler { + /// The source text of the command to invoke. + pub command: String, + /// Source information for where the trap handler was defined. + pub source_info: crate::SourceInfo, +} + +/// Configuration for trap handlers in the shell. +#[derive(Clone, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct TrapHandlerConfig { + /// Registered handlers for traps; maps signal type to command. + handlers: HashMap, +} + +impl TrapHandlerConfig { + /// Iterates over the registered handlers for trap signals. + pub fn iter_handlers(&self) -> impl Iterator { + self.handlers + .iter() + .map(|(signal, handler)| (*signal, handler)) + } + + /// Tries to find the handler associated with the given signal. + /// + /// # Arguments + /// + /// * `signal_type` - The type of signal to get the handler for. + pub fn get_handler(&self, signal_type: TrapSignal) -> Option<&TrapHandler> { + self.handlers.get(&signal_type) + } + + /// Returns whether a handler is registered for the given signal. + pub fn handles(&self, signal_type: TrapSignal) -> bool { + self.handlers.contains_key(&signal_type) + } + + /// Registers a handler for a trap signal. + /// + /// # Arguments + /// + /// * `signal_type` - The type of signal to register a handler for. + /// * `command` - The command to execute when the signal is trapped. + /// * `source_info` - The source info for where the trap handler was defined. + pub fn register_handler( + &mut self, + signal_type: TrapSignal, + command: String, + source_info: crate::SourceInfo, + ) { + let _ = self.handlers.insert( + signal_type, + TrapHandler { + command, + source_info, + }, + ); + } + + /// Removes handlers for a trap signal. + /// + /// # Arguments + /// + /// * `signal_type` - The type of signal to remove handlers for. + pub fn remove_handlers(&mut self, signal_type: TrapSignal) { + self.handlers.remove(&signal_type); + } +} diff --git a/local/recipes/shells/brush/source/brush-core/src/variables.rs b/local/recipes/shells/brush/source/brush-core/src/variables.rs new file mode 100644 index 0000000000..6f3d8938ea --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/variables.rs @@ -0,0 +1,1088 @@ +//! Implements variables for a shell environment. + +use std::borrow::Cow; +use std::collections::BTreeMap; +use std::ffi::OsString; +use std::fmt::{Display, Write}; + +use crate::shell::{Shell, ShellState}; +use crate::{error, escape, extensions}; + +/// A shell variable. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct ShellVariable { + /// The value currently associated with the variable. + value: ShellValue, + /// Whether or not the variable is marked as exported to child processes. + exported: bool, + /// Whether or not the variable is marked as read-only. + readonly: bool, + /// Whether or not the variable should be enumerated in the shell's environment. + enumerable: bool, + /// The transformation to apply to the variable's value when it is updated. + transform_on_update: ShellVariableUpdateTransform, + /// Whether or not the variable is marked as being traced. + trace: bool, + /// Whether or not the variable should be treated as an integer. + treat_as_integer: bool, + /// Whether or not the variable should be treated as a name reference. + treat_as_nameref: bool, +} + +/// Kind of transformation to apply to a variable's value when it is updated. +#[derive(Clone, Copy, Debug)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum ShellVariableUpdateTransform { + /// No transformation. + None, + /// Convert the value to lowercase. + Lowercase, + /// Convert the value to uppercase. + Uppercase, + /// Convert the value to lowercase, with the first character capitalized. + Capitalize, +} + +impl Default for ShellVariable { + fn default() -> Self { + Self { + value: ShellValue::String(String::new()), + exported: false, + readonly: false, + enumerable: true, + transform_on_update: ShellVariableUpdateTransform::None, + trace: false, + treat_as_integer: false, + treat_as_nameref: false, + } + } +} + +impl ShellVariable { + /// Returns a new shell variable, initialized with the given value. + /// + /// # Arguments + /// + /// * `value` - The value to associate with the variable. + pub fn new>(value: I) -> Self { + Self { + value: value.into(), + ..Self::default() + } + } + + /// Returns the value associated with the variable. + pub const fn value(&self) -> &ShellValue { + &self.value + } + + /// Returns whether or not the variable is exported to child processes. + pub const fn is_exported(&self) -> bool { + self.exported + } + + /// Marks the variable as exported to child processes. + pub const fn export(&mut self) -> &mut Self { + self.exported = true; + self + } + + /// Marks the variable as not exported to child processes. + pub const fn unexport(&mut self) -> &mut Self { + self.exported = false; + self + } + + /// Returns whether or not the variable is read-only. + pub const fn is_readonly(&self) -> bool { + self.readonly + } + + /// Marks the variable as read-only. + pub const fn set_readonly(&mut self) -> &mut Self { + self.readonly = true; + self + } + + /// Marks the variable as not read-only. + pub fn unset_readonly(&mut self) -> Result<&mut Self, error::Error> { + if self.readonly { + return Err(error::ErrorKind::ReadonlyVariable.into()); + } + + self.readonly = false; + Ok(self) + } + + /// Returns whether or not the variable is traced. + pub const fn is_trace_enabled(&self) -> bool { + self.trace + } + + /// Marks the variable as traced. + pub const fn enable_trace(&mut self) -> &mut Self { + self.trace = true; + self + } + + /// Marks the variable as not traced. + pub const fn disable_trace(&mut self) -> &mut Self { + self.trace = false; + self + } + + /// Returns whether or not the variable should be enumerated in the shell's environment. + pub const fn is_enumerable(&self) -> bool { + self.enumerable + } + + /// Marks the variable as not enumerable in the shell's environment. + pub const fn hide_from_enumeration(&mut self) -> &mut Self { + self.enumerable = false; + self + } + + /// Return the update transform associated with the variable. + pub const fn get_update_transform(&self) -> ShellVariableUpdateTransform { + self.transform_on_update + } + + /// Set the update transform associated with the variable. + pub const fn set_update_transform(&mut self, transform: ShellVariableUpdateTransform) { + self.transform_on_update = transform; + } + + /// Returns whether or not the variable should be treated as an integer. + pub const fn is_treated_as_integer(&self) -> bool { + self.treat_as_integer + } + + /// Marks the variable as being treated as an integer. + pub const fn treat_as_integer(&mut self) -> &mut Self { + self.treat_as_integer = true; + self + } + + /// Marks the variable as not being treated as an integer. + pub const fn unset_treat_as_integer(&mut self) -> &mut Self { + self.treat_as_integer = false; + self + } + + /// Returns whether or not the variable should be treated as a name reference. + pub const fn is_treated_as_nameref(&self) -> bool { + self.treat_as_nameref + } + + /// Marks the variable as being treated as a name reference. + pub const fn treat_as_nameref(&mut self) -> &mut Self { + self.treat_as_nameref = true; + self + } + + /// Marks the variable as not being treated as a name reference. + pub const fn unset_treat_as_nameref(&mut self) -> &mut Self { + self.treat_as_nameref = false; + self + } + + /// Converts the variable to an indexed array. + pub fn convert_to_indexed_array(&mut self) -> Result<(), error::Error> { + match self.value() { + ShellValue::IndexedArray(_) => Ok(()), + ShellValue::AssociativeArray(_) => { + Err(error::ErrorKind::ConvertingAssociativeArrayToIndexedArray.into()) + } + _ => { + let mut new_values = BTreeMap::new(); + new_values.insert( + 0, + self.value.to_cow_str_without_dynamic_support().to_string(), + ); + self.value = ShellValue::IndexedArray(new_values); + Ok(()) + } + } + } + + /// Converts the variable to an associative array. + pub fn convert_to_associative_array(&mut self) -> Result<(), error::Error> { + match self.value() { + ShellValue::AssociativeArray(_) => Ok(()), + ShellValue::IndexedArray(_) => { + Err(error::ErrorKind::ConvertingIndexedArrayToAssociativeArray.into()) + } + _ => { + let mut new_values: BTreeMap = BTreeMap::new(); + new_values.insert( + String::from("0"), + self.value.to_cow_str_without_dynamic_support().to_string(), + ); + self.value = ShellValue::AssociativeArray(new_values); + Ok(()) + } + } + } + + /// Assign the given value to the variable, conditionally appending to the preexisting value. + /// + /// # Arguments + /// + /// * `value` - The value to assign to the variable. + /// * `append` - Whether or not to append the value to the preexisting value. + #[expect(clippy::too_many_lines)] + pub fn assign(&mut self, value: ShellValueLiteral, append: bool) -> Result<(), error::Error> { + if self.is_readonly() { + return Err(error::ErrorKind::ReadonlyVariable.into()); + } + + let value = self.convert_value_literal_for_assignment(value); + + if append { + match (&self.value, &value) { + // If we're appending an array to a declared-but-unset variable (or appending + // anything to a declared-but-unset array), then fill it out first. + (ShellValue::Unset(_), ShellValueLiteral::Array(_)) + | ( + ShellValue::Unset( + ShellValueUnsetType::IndexedArray | ShellValueUnsetType::AssociativeArray, + ), + _, + ) => { + self.assign(ShellValueLiteral::Array(ArrayLiteral(vec![])), false)?; + } + // If we're appending a scalar to a declared-but-unset variable, then + // start with the empty string. This will result in the right thing happening, + // even in treat-as-integer cases. + (ShellValue::Unset(_), ShellValueLiteral::Scalar(_)) => { + self.assign(ShellValueLiteral::Scalar(String::new()), false)?; + } + // If we're trying to append an array to a string, we first promote the string to be + // an array with the string being present at index 0. + (ShellValue::String(_), ShellValueLiteral::Array(_)) => { + self.convert_to_indexed_array()?; + } + _ => (), + } + + let treat_as_int = self.is_treated_as_integer(); + let update_transform = self.get_update_transform(); + + match &mut self.value { + ShellValue::String(base) => match value { + ShellValueLiteral::Scalar(suffix) => { + if treat_as_int { + let int_value = base.parse::().unwrap_or(0) + + suffix.parse::().unwrap_or(0); + base.clear(); + base.push_str(int_value.to_string().as_str()); + } else { + base.push_str(suffix.as_str()); + Self::apply_value_transforms(base, treat_as_int, update_transform); + } + Ok(()) + } + ShellValueLiteral::Array(_) => { + // This case was already handled (see above). + Ok(()) + } + }, + ShellValue::IndexedArray(existing_values) => match value { + ShellValueLiteral::Scalar(new_value) => { + self.assign_at_index(String::from("0"), new_value, append) + } + ShellValueLiteral::Array(new_values) => { + ShellValue::update_indexed_array_from_literals(existing_values, new_values); + Ok(()) + } + }, + ShellValue::AssociativeArray(existing_values) => match value { + ShellValueLiteral::Scalar(new_value) => { + self.assign_at_index(String::from("0"), new_value, append) + } + ShellValueLiteral::Array(new_values) => { + ShellValue::update_associative_array_from_literals( + existing_values, + new_values, + ) + } + }, + ShellValue::Unset(_) => unreachable!("covered in conversion above"), + // TODO(dynamic): implement appending to dynamic vars + ShellValue::Dynamic { .. } => Ok(()), + } + } else { + match (&self.value, value) { + // If we're updating an array value with a string, then treat it as an update to + // just the "0"-indexed element of the array. + ( + ShellValue::IndexedArray(_) + | ShellValue::AssociativeArray(_) + | ShellValue::Unset( + ShellValueUnsetType::AssociativeArray | ShellValueUnsetType::IndexedArray, + ), + ShellValueLiteral::Scalar(s), + ) => self.assign_at_index(String::from("0"), s, false), + + // If we're updating an indexed array value with an array, then preserve the array + // type. We also default to using an indexed array if we are + // assigning an array to a previously string-holding variable. + ( + ShellValue::IndexedArray(_) + | ShellValue::Unset( + ShellValueUnsetType::IndexedArray | ShellValueUnsetType::Untyped, + ) + | ShellValue::String(_) + | ShellValue::Dynamic { .. }, + ShellValueLiteral::Array(literal_values), + ) => { + self.value = ShellValue::indexed_array_from_literals(literal_values); + Ok(()) + } + + // If we're updating an associative array value with an array, then preserve the + // array type. + ( + ShellValue::AssociativeArray(_) + | ShellValue::Unset(ShellValueUnsetType::AssociativeArray), + ShellValueLiteral::Array(literal_values), + ) => { + self.value = ShellValue::associative_array_from_literals(literal_values)?; + Ok(()) + } + + // Handle updates to dynamic values; for now we just drop them. + // TODO(dynamic): Allow updates to dynamic values + (ShellValue::Dynamic { .. }, _) => Ok(()), + + // Assign a scalar value to a scalar or unset (and untyped) variable. + (ShellValue::String(_) | ShellValue::Unset(_), ShellValueLiteral::Scalar(s)) => { + self.value = ShellValue::String(s); + Ok(()) + } + } + } + } + + /// Assign the given value to the variable at the given index, conditionally appending to the + /// preexisting value present at that element within the value. + /// + /// # Arguments + /// + /// * `array_index` - The index at which to assign the value. + /// * `value` - The value to assign to the variable at the given index. + /// * `append` - Whether or not to append the value to the preexisting value stored at the given + /// index. + pub fn assign_at_index( + &mut self, + array_index: String, + value: String, + append: bool, + ) -> Result<(), error::Error> { + match &self.value { + ShellValue::Unset(_) => { + self.assign(ShellValueLiteral::Array(ArrayLiteral(vec![])), false)?; + } + ShellValue::String(_) => { + self.convert_to_indexed_array()?; + } + _ => (), + } + + let treat_as_int = self.is_treated_as_integer(); + let value = self.convert_value_str_for_assignment(value); + + match &mut self.value { + ShellValue::IndexedArray(arr) => { + let key = get_key_for_indexed_array(arr, array_index.as_str())?; + + if append { + let existing_value = arr.get(&key).map_or("", |v| v.as_str()); + + let mut new_value; + if treat_as_int { + new_value = (existing_value.parse::().unwrap_or(0) + + value.parse::().unwrap_or(0)) + .to_string(); + } else { + new_value = existing_value.to_owned(); + new_value.push_str(value.as_str()); + } + + arr.insert(key, new_value); + } else { + arr.insert(key, value); + } + + Ok(()) + } + ShellValue::AssociativeArray(arr) => { + if append { + let existing_value = arr.get(array_index.as_str()).map_or("", |v| v.as_str()); + + let mut new_value; + if treat_as_int { + new_value = (existing_value.parse::().unwrap_or(0) + + value.parse::().unwrap_or(0)) + .to_string(); + } else { + new_value = existing_value.to_owned(); + new_value.push_str(value.as_str()); + } + + arr.insert(array_index, new_value.clone()); + } else { + arr.insert(array_index, value); + } + Ok(()) + } + _ => { + tracing::error!("assigning to index {array_index} of {:?}", self.value); + error::unimp("assigning to index of non-array variable") + } + } + } + + fn convert_value_literal_for_assignment(&self, value: ShellValueLiteral) -> ShellValueLiteral { + match value { + ShellValueLiteral::Scalar(s) => { + ShellValueLiteral::Scalar(self.convert_value_str_for_assignment(s)) + } + ShellValueLiteral::Array(literals) => ShellValueLiteral::Array(ArrayLiteral( + literals + .0 + .into_iter() + .map(|(k, v)| (k, self.convert_value_str_for_assignment(v))) + .collect(), + )), + } + } + + fn convert_value_str_for_assignment(&self, mut s: String) -> String { + Self::apply_value_transforms( + &mut s, + self.is_treated_as_integer(), + self.get_update_transform(), + ); + + s + } + + fn apply_value_transforms( + s: &mut String, + treat_as_int: bool, + update_transform: ShellVariableUpdateTransform, + ) { + if treat_as_int { + *s = (*s).parse::().unwrap_or(0).to_string(); + } else { + match update_transform { + ShellVariableUpdateTransform::None => (), + ShellVariableUpdateTransform::Lowercase => *s = (*s).to_lowercase(), + ShellVariableUpdateTransform::Uppercase => *s = (*s).to_uppercase(), + ShellVariableUpdateTransform::Capitalize => { + // This isn't really title-case; only the first character is capitalized. + *s = s.to_lowercase(); + if let Some(c) = s.chars().next() { + s.replace_range(0..1, &c.to_uppercase().to_string()); + } + } + } + } + } + + /// Tries to unset the value stored at the given index in the variable. Returns + /// whether or not a value was unset. + /// + /// # Arguments + /// + /// * `index` - The index at which to unset the value. + pub fn unset_index(&mut self, index: &str) -> Result { + match &mut self.value { + ShellValue::Unset(ty) => match ty { + ShellValueUnsetType::Untyped => Err(error::ErrorKind::NotArray.into()), + ShellValueUnsetType::AssociativeArray | ShellValueUnsetType::IndexedArray => { + Ok(false) + } + }, + ShellValue::String(_) => Err(error::ErrorKind::NotArray.into()), + ShellValue::AssociativeArray(values) => Ok(values.remove(index).is_some()), + ShellValue::IndexedArray(values) => { + let key = get_key_for_indexed_array(values, index)?; + Ok(values.remove(&key).is_some()) + } + ShellValue::Dynamic { .. } => Ok(false), + } + } + + /// Returns the variable's value; for dynamic values, this will resolve the value. + /// + /// # Arguments + /// + /// * `shell` - The shell in which the variable is being resolved. + pub fn resolve_value(&self, shell: &Shell) -> ShellValue { + // N.B. We do *not* specially handle a dynamic value that resolves to a dynamic value. + match &self.value { + ShellValue::Dynamic { getter, .. } => getter(shell), + _ => self.value.clone(), + } + } + + /// Returns the canonical attribute flag string for this variable. + pub fn attribute_flags(&self, shell: &Shell) -> String { + let value = self.resolve_value(shell); + + let mut result = String::new(); + + if matches!( + value, + ShellValue::IndexedArray(_) | ShellValue::Unset(ShellValueUnsetType::IndexedArray) + ) { + result.push('a'); + } + if matches!( + value, + ShellValue::AssociativeArray(_) + | ShellValue::Unset(ShellValueUnsetType::AssociativeArray) + ) { + result.push('A'); + } + if matches!( + self.get_update_transform(), + ShellVariableUpdateTransform::Capitalize + ) { + result.push('c'); + } + if self.is_treated_as_integer() { + result.push('i'); + } + if self.is_treated_as_nameref() { + result.push('n'); + } + if self.is_readonly() { + result.push('r'); + } + if matches!( + self.get_update_transform(), + ShellVariableUpdateTransform::Lowercase + ) { + result.push('l'); + } + if self.is_trace_enabled() { + result.push('t'); + } + if matches!( + self.get_update_transform(), + ShellVariableUpdateTransform::Uppercase + ) { + result.push('u'); + } + if self.is_exported() { + result.push('x'); + } + + result + } +} + +type DynamicValueGetter = fn(&dyn ShellState) -> ShellValue; +type DynamicValueSetter = fn(&dyn ShellState) -> (); + +/// A shell value. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum ShellValue { + /// A value that has been typed but not yet set. + Unset(ShellValueUnsetType), + /// A string. + String(String), + /// An associative array. + AssociativeArray(BTreeMap), + /// An indexed array. + IndexedArray(BTreeMap), + /// A value that is dynamically computed. + Dynamic { + /// Function that can query the value. + /// TODO(serde): figure out how to serialize/deserialize dynamic values. + #[cfg_attr( + feature = "serde", + serde(skip, default = "default_dynamic_value_getter") + )] + getter: DynamicValueGetter, + /// Function that receives value update requests. + /// TODO(serde): figure out how to serialize/deserialize dynamic values. + #[cfg_attr( + feature = "serde", + serde(skip, default = "default_dynamic_value_setter") + )] + setter: DynamicValueSetter, + }, +} + +#[cfg(feature = "serde")] +fn default_dynamic_value_getter() -> DynamicValueGetter { + |_shell: &dyn ShellState| ShellValue::String(String::new()) +} + +#[cfg(feature = "serde")] +fn default_dynamic_value_setter() -> DynamicValueSetter { + |_shell: &dyn ShellState| {} +} + +/// The type of an unset shell value. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum ShellValueUnsetType { + /// The value is untyped. + Untyped, + /// The value is an associative array. + AssociativeArray, + /// The value is an indexed array. + IndexedArray, +} + +/// A shell value literal; used for assignment. +#[derive(Clone, Debug)] +pub enum ShellValueLiteral { + /// A scalar value. + Scalar(String), + /// An array value. + Array(ArrayLiteral), +} + +impl ShellValueLiteral { + pub(crate) fn fmt_for_tracing(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Scalar(s) => Self::fmt_scalar_for_tracing(s.as_str(), f), + Self::Array(elements) => { + write!(f, "(")?; + for (i, (key, value)) in elements.0.iter().enumerate() { + if i > 0 { + write!(f, " ")?; + } + if let Some(key) = key { + write!(f, "[")?; + Self::fmt_scalar_for_tracing(key.as_str(), f)?; + write!(f, "]=")?; + } + Self::fmt_scalar_for_tracing(value.as_str(), f)?; + } + write!(f, ")") + } + } + } + + fn fmt_scalar_for_tracing(s: &str, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let processed = escape::quote_if_needed(s, escape::QuoteMode::SingleQuote); + write!(f, "{processed}") + } +} + +impl Display for ShellValueLiteral { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.fmt_for_tracing(f) + } +} + +impl From<&str> for ShellValueLiteral { + fn from(value: &str) -> Self { + Self::Scalar(value.to_owned()) + } +} + +impl From for ShellValueLiteral { + fn from(value: String) -> Self { + Self::Scalar(value) + } +} + +impl From> for ShellValueLiteral { + fn from(value: Vec<&str>) -> Self { + Self::Array(ArrayLiteral( + value.into_iter().map(|s| (None, s.to_owned())).collect(), + )) + } +} + +/// An array literal. +#[derive(Clone, Debug)] +pub struct ArrayLiteral(pub Vec<(Option, String)>); + +/// Style for formatting a shell variable's value. +#[derive(Copy, Clone, Debug)] +pub enum FormatStyle { + /// Basic formatting. + Basic, + /// Formatting as appropriate in the `declare` built-in command. + DeclarePrint, +} + +impl ShellValue { + /// Returns whether or not the value is an array. + pub const fn is_array(&self) -> bool { + matches!( + self, + Self::IndexedArray(_) + | Self::AssociativeArray(_) + | Self::Unset( + ShellValueUnsetType::IndexedArray | ShellValueUnsetType::AssociativeArray + ) + ) + } + + /// Returns whether or not the value is set. + pub const fn is_set(&self) -> bool { + !matches!(self, Self::Unset(_)) + } + + /// Returns a new indexed array value constructed from the given slice of owned strings. + /// + /// # Arguments + /// + /// * `values` - The slice of strings to construct the indexed array from. + pub fn indexed_array_from_strings(values: S) -> Self + where + S: IntoIterator, + { + let mut owned_values = BTreeMap::new(); + for (i, value) in values.into_iter().enumerate() { + owned_values.insert(i as u64, value); + } + + Self::IndexedArray(owned_values) + } + + /// Returns a new indexed array value constructed from the given slice of unowned strings. + /// + /// # Arguments + /// + /// * `values` - The slice of strings to construct the indexed array from. + pub fn indexed_array_from_strs(values: &[&str]) -> Self { + let mut owned_values = BTreeMap::new(); + for (i, value) in values.iter().enumerate() { + owned_values.insert(i as u64, (*value).to_string()); + } + + Self::IndexedArray(owned_values) + } + + /// Returns a new indexed array value constructed from the given literals. + /// + /// # Arguments + /// + /// * `literals` - The literals to construct the indexed array from. + pub fn indexed_array_from_literals(literals: ArrayLiteral) -> Self { + let mut values = BTreeMap::new(); + Self::update_indexed_array_from_literals(&mut values, literals); + + Self::IndexedArray(values) + } + + fn update_indexed_array_from_literals( + existing_values: &mut BTreeMap, + literal_values: ArrayLiteral, + ) { + let mut new_key = if let Some((largest_index, _)) = existing_values.last_key_value() { + largest_index + 1 + } else { + 0 + }; + + for (key, value) in literal_values.0 { + if let Some(key) = key { + new_key = key.parse().unwrap_or(0); + } + + existing_values.insert(new_key, value); + new_key += 1; + } + } + + /// Returns a new associative array value constructed from the given literals. + /// + /// # Arguments + /// + /// * `literals` - The literals to construct the associative array from. + pub fn associative_array_from_literals(literals: ArrayLiteral) -> Result { + let mut values = BTreeMap::new(); + Self::update_associative_array_from_literals(&mut values, literals)?; + + Ok(Self::AssociativeArray(values)) + } + + fn update_associative_array_from_literals( + existing_values: &mut BTreeMap, + literal_values: ArrayLiteral, + ) -> Result<(), error::Error> { + let mut current_key = None; + for (key, value) in literal_values.0 { + if let Some(current_key) = current_key.take() { + if key.is_some() { + return error::unimp("misaligned keys/values in associative array literal"); + } else { + existing_values.insert(current_key, value); + } + } else if let Some(key) = key { + existing_values.insert(key, value); + } else { + current_key = Some(value); + } + } + + if let Some(current_key) = current_key { + existing_values.insert(current_key, String::new()); + } + + Ok(()) + } + + /// Formats the value using the given style. + /// + /// # Arguments + /// + /// * `style` - The style to use for formatting the value. + pub fn format( + &self, + style: FormatStyle, + shell: &Shell, + ) -> Result, error::Error> { + match self { + Self::Unset(_) => Ok("".into()), + Self::String(s) => match style { + FormatStyle::Basic => Ok(escape::quote_if_needed( + s.as_str(), + escape::QuoteMode::SingleQuote, + )), + FormatStyle::DeclarePrint => { + Ok(escape::force_quote(s.as_str(), escape::QuoteMode::DoubleQuote).into()) + } + }, + Self::AssociativeArray(values) => { + let mut result = String::new(); + result.push('('); + + for (key, value) in values { + let formatted_key = + escape::quote_if_needed(key.as_str(), escape::QuoteMode::DoubleQuote); + let formatted_value = + escape::force_quote(value.as_str(), escape::QuoteMode::DoubleQuote); + + // N.B. We include an unconditional trailing space character (even after the + // last entry in the associative array) to match standard + // output behavior. + write!(result, "[{formatted_key}]={formatted_value} ")?; + } + + result.push(')'); + Ok(result.into()) + } + Self::IndexedArray(values) => { + let mut result = String::new(); + result.push('('); + + for (i, (key, value)) in values.iter().enumerate() { + if i > 0 { + result.push(' '); + } + + let formatted_value = + escape::force_quote(value.as_str(), escape::QuoteMode::DoubleQuote); + write!(result, "[{key}]={formatted_value}")?; + } + + result.push(')'); + Ok(result.into()) + } + Self::Dynamic { getter, .. } => { + let dynamic_value = getter(shell); + let result = dynamic_value.format(style, shell)?.to_string(); + Ok(result.into()) + } + } + } + + /// Tries to retrieve the value stored at the given index in this variable. + /// + /// # Arguments + /// + /// * `index` - The index at which to retrieve the value. + pub fn get_at( + &self, + index: &str, + shell: &Shell, + ) -> Result>, error::Error> { + match self { + Self::Unset(_) => Ok(None), + Self::String(s) => { + if index.parse::().unwrap_or(0) == 0 { + Ok(Some(Cow::Borrowed(s))) + } else { + Ok(None) + } + } + Self::AssociativeArray(values) => { + Ok(values.get(index).map(|s| Cow::Borrowed(s.as_str()))) + } + Self::IndexedArray(values) => { + let key = get_key_for_indexed_array(values, index)?; + Ok(values.get(&key).map(|s| Cow::Borrowed(s.as_str()))) + } + Self::Dynamic { getter, .. } => { + let dynamic_value = getter(shell); + let result = dynamic_value.get_at(index, shell)?; + Ok(result.map(|s| s.to_string().into())) + } + } + } + + /// Returns the keys of the elements in this variable. + pub fn element_keys(&self, shell: &Shell) -> Vec { + match self { + Self::Unset(_) => vec![], + Self::String(_) => vec!["0".to_owned()], + Self::AssociativeArray(array) => array.keys().map(|k| k.to_owned()).collect(), + Self::IndexedArray(array) => array.keys().map(|k| k.to_string()).collect(), + Self::Dynamic { getter, .. } => getter(shell).element_keys(shell), + } + } + + /// Returns the values of the elements in this variable. + pub fn element_values(&self, shell: &Shell) -> Vec { + match self { + Self::Unset(_) => vec![], + Self::String(s) => vec![s.to_owned()], + Self::AssociativeArray(array) => array.values().map(|v| v.to_owned()).collect(), + Self::IndexedArray(array) => array.values().map(|v| v.to_owned()).collect(), + Self::Dynamic { getter, .. } => getter(shell).element_values(shell), + } + } + + /// Converts this value to a string. + pub fn to_cow_str(&self, shell: &Shell) -> Cow<'_, str> { + self.try_get_cow_str(shell).unwrap_or(Cow::Borrowed("")) + } + + fn to_cow_str_without_dynamic_support(&self) -> Cow<'_, str> { + self.try_get_cow_str_without_dynamic_support() + .unwrap_or(Cow::Borrowed("")) + } + + /// Tries to convert this value to a string; returns `None` if the value is unset + /// or otherwise doesn't exist. + pub fn try_get_cow_str( + &self, + shell: &Shell, + ) -> Option> { + match self { + Self::Dynamic { getter, .. } => { + let dynamic_value = getter(shell); + dynamic_value + .try_get_cow_str(shell) + .map(|s| s.to_string().into()) + } + _ => self.try_get_cow_str_without_dynamic_support(), + } + } + + fn try_get_cow_str_without_dynamic_support(&self) -> Option> { + match self { + Self::Unset(_) => None, + Self::String(s) => Some(Cow::Borrowed(s.as_str())), + Self::AssociativeArray(values) => values.get("0").map(|s| Cow::Borrowed(s.as_str())), + Self::IndexedArray(values) => values.get(&0).map(|s| Cow::Borrowed(s.as_str())), + Self::Dynamic { .. } => None, + } + } + + /// Formats this value as a program string usable in an assignment. + /// + /// # Arguments + /// + /// * `index` - The index at which to retrieve the value, if indexing is to be performed. + pub fn to_assignable_str( + &self, + index: Option<&str>, + shell: &Shell, + ) -> Result { + match self { + Self::Unset(_) => Ok(String::new()), + Self::String(s) => Ok(escape::force_quote( + s.as_str(), + escape::QuoteMode::SingleQuote, + )), + Self::AssociativeArray(_) | Self::IndexedArray(_) => { + if let Some(index) = index { + if let Ok(Some(value)) = self.get_at(index, shell) { + Ok(escape::force_quote( + value.as_ref(), + escape::QuoteMode::SingleQuote, + )) + } else { + Ok(String::new()) + } + } else { + Ok(self.format(FormatStyle::DeclarePrint, shell)?.into_owned()) + } + } + Self::Dynamic { getter, .. } => getter(shell).to_assignable_str(index, shell), + } + } +} + +fn get_key_for_indexed_array( + values: &BTreeMap, + index_str: &str, +) -> Result { + let mut index_value = index_str.parse::().unwrap_or(0); + + // Handle negative indices, but check for out-of-range values. + #[expect(clippy::cast_possible_wrap)] + if index_value < 0 { + index_value += values.len() as i64; + if index_value < 0 { + return Err(error::ErrorKind::ArrayIndexOutOfRange(index_str.to_owned()).into()); + } + } + + // Now that we've confirmed that the index is non-negative, we can safely convert it + // to a u64 without any fuss. + #[expect(clippy::cast_sign_loss)] + Ok(index_value as u64) +} + +impl From<&str> for ShellValue { + fn from(value: &str) -> Self { + Self::String(value.to_owned()) + } +} + +impl From<&String> for ShellValue { + fn from(value: &String) -> Self { + Self::String(value.clone()) + } +} + +impl From for ShellValue { + fn from(value: String) -> Self { + Self::String(value) + } +} + +impl From for ShellValue { + fn from(value: OsString) -> Self { + Self::String(value.to_string_lossy().into_owned()) + } +} + +impl From> for ShellValue { + fn from(values: Vec) -> Self { + Self::indexed_array_from_strings(values) + } +} + +impl From> for ShellValue { + fn from(values: Vec<&str>) -> Self { + Self::indexed_array_from_strs(values.as_slice()) + } +} diff --git a/local/recipes/shells/brush/source/brush-core/src/wellknownvars.rs b/local/recipes/shells/brush/source/brush-core/src/wellknownvars.rs new file mode 100644 index 0000000000..46ccf1729f --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/src/wellknownvars.rs @@ -0,0 +1,751 @@ +use std::collections::BTreeMap; +use std::path::PathBuf; + +use rand::RngExt as _; + +use crate::shell::ShellState; +use crate::{Shell, ShellValue, ShellVariable, error, extensions, sys, variables}; + +const BASH_MAJOR: u32 = 5; +const BASH_MINOR: u32 = 2; +const BASH_PATCH: u32 = 37; +const BASH_BUILD: u32 = 1; +const BASH_RELEASE: &str = "release"; +const BASH_MACHINE: &str = "unknown"; + +const DEFAULT_LINENO: usize = 1; + +/// Inherit environment variables from the host process into the shell's environment. +/// +/// # Arguments +/// +/// * `shell` - The shell instance to inherit environment variables into. +pub(crate) fn inherit_env_vars( + shell: &mut Shell, +) -> Result<(), error::Error> { + for (k, v) in sys::env::get_host_env_vars() { + // See if it's a function exported by an ancestor process. + if let Some(func_name) = k.strip_prefix("BASH_FUNC_") + && let Some(func_name) = func_name.strip_suffix("%%") + { + // Intentionally best-effort; don't fail out of the shell if we can't + // parse an incoming function. + if shell.define_func_from_str(func_name, v.as_str()).is_ok() + && let Some(func) = shell.func_mut(func_name) + { + func.export(); + } + + continue; + } + + // Special case OLDPWD for bash compatibility. + if k == "OLDPWD" { + continue; + } + + let mut var = ShellVariable::new(ShellValue::String(v)); + var.export(); + shell.env_mut().set_global(k, var)?; + } + + Ok(()) +} + +#[expect(clippy::too_many_lines)] +pub(crate) fn init_well_known_vars( + shell: &mut Shell, +) -> Result<(), error::Error> { + let shell_version = shell.version().map(ToString::to_string); + shell.env_mut().set_global( + "BRUSH_VERSION", + ShellVariable::new(shell_version.unwrap_or_default()), + )?; + + // BASH + if let Some(shell_name) = shell.current_shell_name().map(|s| s.to_string()) { + shell + .env_mut() + .set_global("BASH", ShellVariable::new(shell_name.clone()))?; + // Initialize $_ to the shell name ($0). + shell.update_last_arg_variable(Some(shell_name)); + } + + // BASHOPTS + let mut bashopts_var = ShellVariable::new(ShellValue::Dynamic { + getter: |shell| shell.options().shopt_optstr().into(), + setter: |_| (), + }); + bashopts_var.set_readonly(); + shell.env_mut().set_global("BASHOPTS", bashopts_var)?; + + // BASHPID + #[cfg(not(target_family = "wasm"))] + { + let mut bashpid_var = + ShellVariable::new(ShellValue::String(std::process::id().to_string())); + bashpid_var.treat_as_integer(); + shell.env_mut().set_global("BASHPID", bashpid_var)?; + } + + // BASH_ALIASES + shell.env_mut().set_global( + "BASH_ALIASES", + ShellVariable::new(ShellValue::Dynamic { + getter: |shell| { + let values = variables::ArrayLiteral( + shell + .aliases() + .iter() + .map(|(k, v)| (Some(k.to_owned()), v.to_owned())) + .collect::>(), + ); + + ShellValue::associative_array_from_literals(values) + .unwrap_or_else(|_error| ShellValue::AssociativeArray(BTreeMap::new())) + }, + setter: |_| (), + }), + )?; + + // BASH_ARGC + shell.env_mut().set_global( + "BASH_ARGC", + ShellVariable::new(ShellValue::Dynamic { + getter: |shell| get_bash_argc_value(shell), + setter: |_| (), + }), + )?; + + // BASH_ARGV + shell.env_mut().set_global( + "BASH_ARGV", + ShellVariable::new(ShellValue::Dynamic { + getter: |shell| get_bash_argv_value(shell), + setter: |_| (), + }), + )?; + + // BASH_ARGV0 + shell.env_mut().set_global( + "BASH_ARGV0", + ShellVariable::new(ShellValue::Dynamic { + getter: |shell| { + let argv0 = shell.current_shell_name().unwrap_or_default(); + argv0.to_string().into() + }, + // TODO(vars): implement updating BASH_ARGV0 + setter: |_| (), + }), + )?; + + // TODO(vars): implement mutation of BASH_CMDS + shell.env_mut().set_global( + "BASH_CMDS", + ShellVariable::new(ShellValue::Dynamic { + getter: |shell| { + shell + .program_location_cache() + .to_value() + .unwrap_or_else(|_error| ShellValue::AssociativeArray(BTreeMap::new())) + }, + setter: |_| (), + }), + )?; + + // TODO(vars): implement BASH_COMMAND + // TODO(vars): implement BASH_EXECUTION_STRING + + // BASH_LINENO + shell.env_mut().set_global( + "BASH_LINENO", + ShellVariable::new(ShellValue::Dynamic { + getter: |shell| get_bash_lineno_value(shell), + setter: |_| (), + }), + )?; + + // BASH_SOURCE + shell.env_mut().set_global( + "BASH_SOURCE", + ShellVariable::new(ShellValue::Dynamic { + getter: |shell| get_bash_source_value(shell), + setter: |_| (), + }), + )?; + + // BASH_SUBSHELL + shell.env_mut().set_global( + "BASH_SUBSHELL", + ShellVariable::new(ShellValue::Dynamic { + getter: |shell| shell.depth().to_string().into(), + setter: |_| (), + }), + )?; + + // BASH_VERSINFO + let mut bash_versinfo_var = ShellVariable::new(ShellValue::indexed_array_from_strs( + [ + BASH_MAJOR.to_string().as_str(), + BASH_MINOR.to_string().as_str(), + BASH_PATCH.to_string().as_str(), + BASH_BUILD.to_string().as_str(), + BASH_RELEASE, + BASH_MACHINE, + ] + .as_slice(), + )); + bash_versinfo_var.set_readonly(); + shell + .env_mut() + .set_global("BASH_VERSINFO", bash_versinfo_var)?; + + // BASH_VERSION + // This is the Bash interface version. See BRUSH_VERSION for its implementation version. + shell.env_mut().set_global( + "BASH_VERSION", + ShellVariable::new(std::format!( + "{BASH_MAJOR}.{BASH_MINOR}.{BASH_PATCH}({BASH_BUILD})-{BASH_RELEASE}" + )), + )?; + + // COMP_WORDBREAKS + let mut default_comp_wordbreaks = String::from(" \t\n\"\'><=;|&(:"); + if shell.options().enable_hostname_completion { + default_comp_wordbreaks.push('@'); + } + + shell.env_mut().set_global( + "COMP_WORDBREAKS", + ShellVariable::new(default_comp_wordbreaks), + )?; + + // DIRSTACK + shell.env_mut().set_global( + "DIRSTACK", + ShellVariable::new(ShellValue::Dynamic { + getter: |shell| { + shell + .directory_stack() + .iter() + .map(|p| p.to_string_lossy().to_string()) + .collect::>() + .into() + }, + setter: |_| (), + }), + )?; + + // EPOCHREALTIME + shell.env_mut().set_global( + "EPOCHREALTIME", + ShellVariable::new(ShellValue::Dynamic { + getter: |_shell| { + let now = std::time::SystemTime::now(); + let since_epoch = now + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(); + since_epoch.as_secs_f64().to_string().into() + }, + setter: |_| (), + }), + )?; + + // EPOCHSECONDS + shell.env_mut().set_global( + "EPOCHSECONDS", + ShellVariable::new(ShellValue::Dynamic { + getter: |_shell| { + let now = std::time::SystemTime::now(); + let since_epoch = now + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(); + since_epoch.as_secs().to_string().into() + }, + setter: |_| (), + }), + )?; + + // EUID + if let Ok(euid) = sys::users::get_effective_uid() { + let mut euid_var = ShellVariable::new(ShellValue::String(format!("{euid}"))); + euid_var.treat_as_integer().set_readonly(); + shell.env_mut().set_global("EUID", euid_var)?; + } + + // FUNCNAME + shell.env_mut().set_global( + "FUNCNAME", + ShellVariable::new(ShellValue::Dynamic { + getter: |shell| get_funcname_value(shell), + setter: |_| (), + }), + )?; + + // GROUPS + // N.B. We could compute this up front, but we choose to make it dynamic so that we + // don't have to make costly system calls if the user never accesses it. + shell.env_mut().set_global( + "GROUPS", + ShellVariable::new(ShellValue::Dynamic { + getter: |_shell| { + let groups = get_current_user_gids(); + ShellValue::indexed_array_from_strings( + groups.into_iter().map(|gid| gid.to_string()), + ) + }, + setter: |_| (), + }), + )?; + + // HISTCMD + let mut histcmd_var = ShellVariable::new(ShellValue::Dynamic { + getter: |shell| { + shell + .history() + .map_or_else(|| "0".into(), |h| h.count().to_string().into()) + }, + setter: |_| (), + }); + histcmd_var.treat_as_integer(); + shell.env_mut().set_global("HISTCMD", histcmd_var)?; + + // HISTFILE (if not already set) + if !shell.env().is_set("HISTFILE") + && let Some(home_dir) = shell.home_dir() + { + let histfile = home_dir.join(".brush_history"); + shell.env_mut().set_global( + "HISTFILE", + ShellVariable::new(ShellValue::String(histfile.to_string_lossy().to_string())), + )?; + } + + // HOSTNAME + shell.env_mut().set_global( + "HOSTNAME", + ShellVariable::new( + sys::network::get_hostname() + .unwrap_or_default() + .to_string_lossy() + .to_string(), + ), + )?; + + // HOSTTYPE + shell.env_mut().set_global( + "HOSTTYPE", + ShellVariable::new(std::env::consts::ARCH.to_string()), + )?; + + // IFS + shell + .env_mut() + .set_global("IFS", ShellVariable::new(" \t\n"))?; + + // LINENO + shell.env_mut().set_global( + "LINENO", + ShellVariable::new(ShellValue::Dynamic { + getter: |shell| get_lineno(shell).to_string().into(), + setter: |_| (), + }), + )?; + + // MACHTYPE + shell + .env_mut() + .set_global("MACHTYPE", ShellVariable::new(BASH_MACHINE))?; + + // OLDPWD (initialization) + if !shell.env().is_set("OLDPWD") { + let mut oldpwd_var = + ShellVariable::new(ShellValue::Unset(variables::ShellValueUnsetType::Untyped)); + oldpwd_var.export(); + shell.env_mut().set_global("OLDPWD", oldpwd_var)?; + } + + // OPTERR + shell + .env_mut() + .set_global("OPTERR", ShellVariable::new("1"))?; + + // OPTIND + let mut optind_var = ShellVariable::new("1"); + optind_var.treat_as_integer(); + shell.env_mut().set_global("OPTIND", optind_var)?; + + // OSTYPE + // Match bash's conventional OSTYPE on each platform so that shell scripts + // branching on `[[ $OSTYPE == darwin* ]]` / `linux-gnu*` etc. (Homebrew + // shellenv, nvm, asdf, ...) take the expected path. Real bash includes a + // kernel-version suffix on macOS/BSDs (e.g. `darwin24`); we omit the + // suffix for now since the common patterns all use prefix matching. + let os_type = match std::env::consts::OS { + "linux" => "linux-gnu", + "android" => "linux-android", + "macos" | "ios" | "tvos" | "watchos" | "visionos" => "darwin", + "freebsd" => "freebsd", + "netbsd" => "netbsd", + "openbsd" => "openbsd", + "dragonfly" => "dragonfly", + "solaris" | "illumos" => "solaris", + "windows" => "windows", + _ => "unknown", + }; + shell + .env_mut() + .set_global("OSTYPE", ShellVariable::new(os_type))?; + + // PATH (if not already set) + if !shell.env().is_set("PATH") { + let default_path_str = std::env::join_paths(sys::fs::get_default_executable_search_paths()) + .unwrap_or_else(|_| PathBuf::from("").into()); + shell + .env_mut() + .set_global("PATH", ShellVariable::new(default_path_str))?; + } + + // PIPESTATUS + // TODO(well-known-vars): Investigate what happens if this gets unset. + // TODO(well-known-vars): Investigate if this needs to be saved/preserved across prompt display. + shell.env_mut().set_global( + "PIPESTATUS", + ShellVariable::new(ShellValue::Dynamic { + getter: |shell| { + ShellValue::indexed_array_from_strings( + shell.last_pipeline_statuses().iter().map(|s| s.to_string()), + ) + }, + setter: |_| (), + }), + )?; + + // PPID + if let Some(ppid) = sys::terminal::get_parent_process_id() { + let mut ppid_var = ShellVariable::new(ppid.to_string()); + ppid_var.treat_as_integer().set_readonly(); + shell.env_mut().set_global("PPID", ppid_var)?; + } + + // RANDOM + let mut random_var = ShellVariable::new(ShellValue::Dynamic { + getter: get_random_value, + setter: |_| (), + }); + random_var.treat_as_integer(); + shell.env_mut().set_global("RANDOM", random_var)?; + + // SECONDS + shell.env_mut().set_global( + "SECONDS", + ShellVariable::new(ShellValue::Dynamic { + getter: |shell| { + let now = std::time::SystemTime::now(); + let since_last = now + .duration_since(shell.last_stopwatch_time()) + .unwrap_or_default(); + let total_seconds = since_last.as_secs() + u64::from(shell.last_stopwatch_offset()); + total_seconds.to_string().into() + }, + // TODO(vars): implement updating SECONDS + setter: |_| (), + }), + )?; + + // SHELL (if not already set) + if !shell.env().is_set("SHELL") { + // Per docs, this should be the user's default login shell -- not the current shell. + if let Some(default_shell) = sys::users::get_current_user_default_shell() { + shell.env_mut().set_global( + "SHELL", + ShellVariable::new(default_shell.to_string_lossy().to_string()), + )?; + } + } + + // SHELLOPTS + let mut shellopts_var = ShellVariable::new(ShellValue::Dynamic { + getter: |shell| shell.options().seto_optstr().into(), + setter: |_| (), + }); + shellopts_var.set_readonly(); + shell.env_mut().set_global("SHELLOPTS", shellopts_var)?; + + // SHLVL + let input_shlvl = shell.env_str("SHLVL").unwrap_or_else(|| "0".into()); + let updated_shlvl = input_shlvl.as_ref().parse::().unwrap_or(0) + 1; + let mut shlvl_var = ShellVariable::new(updated_shlvl.to_string()); + shlvl_var.export(); + shell.env_mut().set_global("SHLVL", shlvl_var)?; + + // SRANDOM + let mut random_var = ShellVariable::new(ShellValue::Dynamic { + getter: get_srandom_value, + setter: |_| (), + }); + random_var.treat_as_integer(); + shell.env_mut().set_global("SRANDOM", random_var)?; + + // PS1 / PS2 + if shell.options().interactive { + if !shell.env().is_set("PS1") { + shell + .env_mut() + .set_global("PS1", ShellVariable::new(r"\s-\v\$ "))?; + } + + if !shell.env().is_set("PS2") { + shell + .env_mut() + .set_global("PS2", ShellVariable::new("> "))?; + } + } + + // PS4 + if !shell.env().is_set("PS4") { + shell + .env_mut() + .set_global("PS4", ShellVariable::new("+ "))?; + } + + // + // PWD + // + // Reflect our actual working directory. There's a chance + // we inherited an out-of-sync version of the variable. Future updates + // will be handled by set_working_dir(). + // + let pwd = shell.working_dir().to_string_lossy().to_string(); + let mut pwd_var = ShellVariable::new(pwd); + pwd_var.export(); + shell.env_mut().set_global("PWD", pwd_var)?; + + // UID + if let Ok(uid) = sys::users::get_current_uid() { + let mut uid_var = ShellVariable::new(ShellValue::String(format!("{uid}"))); + uid_var.treat_as_integer().set_readonly(); + shell.env_mut().set_global("UID", uid_var)?; + } + + Ok(()) +} + +/// Returns a list of the current user's group IDs, with the effective GID at the front. +fn get_current_user_gids() -> Vec { + let mut groups = sys::users::get_user_group_ids().unwrap_or_default(); + + // If the effective GID is present but not in the first position in the list, then move + // it there. + if let Ok(gid) = sys::users::get_effective_gid() { + if let Some(index) = groups.iter().position(|&g| g == gid) { + if index > 0 { + // Move it to the front. + groups.remove(index); + groups.insert(0, gid); + } + } + } + + groups +} + +fn get_random_value(_shell: &dyn ShellState) -> ShellValue { + let mut rng = rand::rng(); + let num = rng.random_range(0..32768); + let str = num.to_string(); + str.into() +} + +fn get_srandom_value(_shell: &dyn ShellState) -> ShellValue { + let mut rng = rand::rng(); + let num: u32 = rng.random(); + let str = num.to_string(); + str.into() +} + +fn get_funcname_value(shell: &dyn ShellState) -> variables::ShellValue { + let stack = shell.call_stack(); + + if stack.iter_function_calls().next().is_none() { + ShellValue::Unset(variables::ShellValueUnsetType::IndexedArray) + } else { + // When in a function, include both functions and sourced scripts in the stack + stack + .iter() + .filter_map(|frame| match &frame.frame_type { + crate::callstack::FrameType::Function(func) => Some(func.function_name.as_str()), + crate::callstack::FrameType::Script(script) => { + // Only include sourced scripts, not run scripts + if matches!(script.call_type, crate::callstack::ScriptCallType::Source) { + Some("source") + } else { + None + } + } + crate::callstack::FrameType::TrapHandler(_) + | crate::callstack::FrameType::Eval + | crate::callstack::FrameType::CommandString + | crate::callstack::FrameType::InteractiveSession => None, + }) + .collect::>() + .into() + } +} + +fn get_bash_lineno_value(shell: &dyn ShellState) -> variables::ShellValue { + let stack = shell.call_stack(); + + // BASH_LINENO[$i] contains the line number where FUNCNAME[$i] was called + // This is extracted from the call_site of each frame + if stack.iter_function_calls().next().is_none() { + ShellValue::Unset(variables::ShellValueUnsetType::IndexedArray) + } else { + stack + .iter() + .enumerate() + .filter_map(|(frame_idx, frame)| match &frame.frame_type { + crate::callstack::FrameType::Function(..) + | crate::callstack::FrameType::Script(..) => { + let caller_idx = frame_idx + 1; + if caller_idx < stack.depth() { + let caller_frame = &stack[caller_idx]; + Some( + caller_frame + .current_line() + .unwrap_or(DEFAULT_LINENO) + .to_string(), + ) + } else { + None + } + } + crate::callstack::FrameType::TrapHandler(_) + | crate::callstack::FrameType::Eval + | crate::callstack::FrameType::CommandString + | crate::callstack::FrameType::InteractiveSession => None, + }) + .collect::>() + .into() + } +} + +fn get_bash_source_value(shell: &dyn ShellState) -> variables::ShellValue { + let stack = shell.call_stack(); + + if stack.iter_function_calls().next().is_none() { + let top_frame = stack.iter_script_calls().next(); + top_frame + .map_or_else(Vec::new, |frame| vec![frame.source_info.source.clone()]) + .into() + } else { + // When in a function, include both functions and sourced scripts in the stack + // This mirrors the FUNCNAME array structure + stack + .iter() + .filter_map(|frame| match &frame.frame_type { + crate::callstack::FrameType::Function(func) => { + Some(func.function.source().source.clone()) + } + crate::callstack::FrameType::Script(script) => { + // Only include sourced scripts (matching the "source" in FUNCNAME) + if matches!(script.call_type, crate::callstack::ScriptCallType::Source) { + Some(script.source_info.source.clone()) + } else { + None + } + } + crate::callstack::FrameType::TrapHandler(_) | crate::callstack::FrameType::Eval => { + None + } + crate::callstack::FrameType::CommandString + | crate::callstack::FrameType::InteractiveSession => None, + }) + .collect::>() + .into() + } +} + +/// Returns the positional arguments that the given call-stack frame contributes +/// to `BASH_ARGC`/`BASH_ARGV`, or `None` if the frame contributes nothing. +/// +/// Even without extended debugging mode, bash tracks script-level frames (the +/// top-level invocation plus `source`/`.` calls); only *function* frames are +/// gated behind extended debugging mode. A sourced script invoked without +/// explicit positional arguments contributes its own name (the path passed to +/// `.`/`source`) as a single argument, matching bash. +fn frame_bash_args( + frame: &crate::callstack::Frame, + extdebug: bool, +) -> Option> { + use std::borrow::Cow; + match &frame.frame_type { + crate::callstack::FrameType::Script(call) => { + if matches!(call.call_type, crate::callstack::ScriptCallType::Source) + && frame.args.is_empty() + { + Some(Cow::Owned(vec![call.name().into_owned()])) + } else { + Some(Cow::Borrowed(frame.args.as_slice())) + } + } + crate::callstack::FrameType::CommandString + | crate::callstack::FrameType::InteractiveSession => { + Some(Cow::Borrowed(frame.args.as_slice())) + } + crate::callstack::FrameType::Function(..) => { + extdebug.then_some(Cow::Borrowed(frame.args.as_slice())) + } + crate::callstack::FrameType::TrapHandler(_) | crate::callstack::FrameType::Eval => None, + } +} + +/// Returns `true` if `BASH_ARGC`/`BASH_ARGV` should report an empty stack. +/// +/// Without extended debugging mode, bash leaves these arrays empty whenever a +/// function is active on the call stack. +fn bash_args_suppressed(shell: &dyn ShellState) -> bool { + !shell.options().enable_debugger && shell.call_stack().in_function() +} + +fn get_bash_argc_value(shell: &dyn ShellState) -> variables::ShellValue { + if bash_args_suppressed(shell) { + return ShellValue::indexed_array_from_strs(&[]); + } + + let extdebug = shell.options().enable_debugger; + shell + .call_stack() + .iter() + .filter_map(|frame| frame_bash_args(frame, extdebug).map(|args| args.len().to_string())) + .collect::>() + .into() +} + +fn get_bash_argv_value(shell: &dyn ShellState) -> variables::ShellValue { + if bash_args_suppressed(shell) { + return ShellValue::indexed_array_from_strs(&[]); + } + + let extdebug = shell.options().enable_debugger; + let mut argv = Vec::new(); + + for frame in shell.call_stack().iter() { + if let Some(args) = frame_bash_args(frame, extdebug) { + // Push args in reverse order per frame (last arg at lowest index = top of stack) + for arg in args.iter().rev() { + argv.push(arg.clone()); + } + } + } + + argv.into() +} + +fn get_lineno(shell: &dyn ShellState) -> usize { + shell + .call_stack() + .current_frame() + .and_then(|frame| frame.current_line()) + .unwrap_or(DEFAULT_LINENO) +} diff --git a/local/recipes/shells/brush/source/brush-core/tests/kill_on_drop_tests.rs b/local/recipes/shells/brush/source/brush-core/tests/kill_on_drop_tests.rs new file mode 100644 index 0000000000..a82994066b --- /dev/null +++ b/local/recipes/shells/brush/source/brush-core/tests/kill_on_drop_tests.rs @@ -0,0 +1,161 @@ +//! Integration tests for the opt-in `kill_external_commands_on_drop` option: +//! that a child outlives its shell by default, and is reaped when the option is set. +//! +//! Note: these tear down the whole runtime, not just the `Shell`. A backgrounded +//! command runs in a detached `tokio` task owning a *clone* of the shell; the +//! shell's job table holds only the task's `JoinHandle`, and dropping that +//! detaches rather than aborts. Dropping the tasks is what drops the `Child`, +//! where `kill_on_drop` takes effect. + +#![cfg(unix)] +#![cfg(test)] +#![allow(clippy::panic_in_result_fn, clippy::expect_used)] + +use std::time::{Duration, Instant}; + +use anyhow::Result; + +/// How long to wait for a polled condition to come true. +const TIMEOUT: Duration = Duration::from_secs(10); + +/// Returns whether a process is still running. +/// +/// A killed-but-unreaped process lingers as a zombie, and `kill -0` reports +/// success for one, so this inspects the process *state* and treats `Z` as not +/// running. +fn is_running(pid: u32) -> bool { + let output = std::process::Command::new("ps") + .args(["-o", "state=", "-p", pid.to_string().as_str()]) + .output() + .expect("failed to run `ps`"); + + let state = String::from_utf8_lossy(&output.stdout); + let state = state.trim(); + !state.is_empty() && !state.starts_with('Z') +} + +/// Polls `condition` until it holds, returning whether it did within [`TIMEOUT`]. +fn poll_until(mut condition: impl FnMut() -> bool) -> bool { + let deadline = Instant::now() + TIMEOUT; + loop { + if condition() { + return true; + } + if Instant::now() >= deadline { + return false; + } + std::thread::sleep(Duration::from_millis(25)); + } +} + +/// Builds a shell with the option set as requested, backgrounds a long-running +/// external child, tears the whole runtime down, and returns the child's pid so +/// the caller can check whether it survived. +fn spawn_child_then_tear_down(kill_on_drop: bool) -> Result { + let dir = tempfile::tempdir()?; + let pid_file = dir.path().join("pid"); + + // A multi-threaded runtime is required: the backgrounded command runs on a + // separate task, and the pid poll below blocks a worker. + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_io() + .build()?; + + // `/bin/sh` is the shell's direct child and `exec`s into `sleep`, so the pid + // it reports is the process the shell holds a handle to. No brush builtin is + // involved: the child is named by absolute path, and the inner `PATH` + // assignment is interpreted by `sh` itself. + let script = std::format!( + "/bin/sh -c 'PATH=/bin:/usr/bin; echo $$ > {}; exec sleep 300' &", + pid_file.display() + ); + + let pid = runtime.block_on(async { + let mut shell = brush_core::Shell::builder() + .do_not_inherit_env(true) + .skip_well_known_vars(true) + .kill_external_commands_on_drop(kill_on_drop) + .build() + .await?; + + let params = shell.default_exec_params(); + shell + .run_string(script.as_str(), &brush_core::SourceInfo::default(), ¶ms) + .await?; + + // Wait for the child to report its own pid. + let mut pid = None; + poll_until(|| { + pid = std::fs::read_to_string(pid_file.as_path()) + .ok() + .and_then(|contents| contents.trim().parse::().ok()); + pid.is_some() + }); + pid.ok_or_else(|| anyhow::anyhow!("child never reported its pid")) + })?; + + assert!( + is_running(pid), + "child {pid} should be running before teardown" + ); + + // Tear everything down: this drops the task that owns the child, and with it + // the `Child` handle that carries the kill-on-drop request. + drop(runtime); + + Ok(pid) +} + +/// With the option ON, tearing the shell down reaps the child it spawned. +#[test] +fn child_is_killed_on_teardown_with_option_on() -> Result<()> { + let pid = spawn_child_then_tear_down(true)?; + + assert!( + poll_until(|| !is_running(pid)), + "with the option enabled, child {pid} should have been killed on teardown" + ); + + Ok(()) +} + +/// With the option OFF (the default), the child outlives teardown, exactly as it +/// does today. This is the behavior real shells depend on. +#[test] +fn child_outlives_teardown_by_default() -> Result<()> { + let pid = spawn_child_then_tear_down(false)?; + + // Give the child every chance to die, so a regression to + // kill-on-drop-by-default is caught rather than raced past. + std::thread::sleep(Duration::from_millis(500)); + let survived = is_running(pid); + + // This test intentionally leaves a live process behind, so clean it up + // regardless of the assertion's outcome. + let _ = std::process::Command::new("kill") + .args(["-9", pid.to_string().as_str()]) + .status(); + + assert!(survived, "by default child {pid} must outlive its shell"); + + Ok(()) +} + +/// The option must default to off, so merely adding it changes nothing for +/// existing consumers. +#[tokio::test] +async fn option_defaults_to_disabled() -> Result<()> { + let shell = brush_core::Shell::builder() + .do_not_inherit_env(true) + .skip_well_known_vars(true) + .build() + .await?; + + assert!( + !shell.options().kill_external_commands_on_drop, + "kill_external_commands_on_drop must default to disabled" + ); + + Ok(()) +} diff --git a/local/recipes/shells/brush/source/brush-coreutils-builtins/Cargo.toml b/local/recipes/shells/brush/source/brush-coreutils-builtins/Cargo.toml new file mode 100644 index 0000000000..1839efe74b --- /dev/null +++ b/local/recipes/shells/brush/source/brush-coreutils-builtins/Cargo.toml @@ -0,0 +1,277 @@ +[package] +name = "brush-coreutils-builtins" +description = "Optional coreutils builtins for brush-shell (powered by uutils/coreutils)" +version = "0.1.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] +# No utilities are enabled by default, so plain `cargo build --workspace` +# pays only for the scaffolding. Consumers opt into the full set by enabling +# `coreutils.all` (or individual `coreutils.` features). +default = [] + +'coreutils.all' = [ + 'coreutils.arch', + 'coreutils.base32', + 'coreutils.base64', + 'coreutils.basename', + 'coreutils.basenc', + 'coreutils.cat', + 'coreutils.cksum', + 'coreutils.b2sum', + 'coreutils.md5sum', + 'coreutils.sha1sum', + 'coreutils.sha224sum', + 'coreutils.sha256sum', + 'coreutils.sha384sum', + 'coreutils.sha512sum', + 'coreutils.comm', + 'coreutils.cp', + 'coreutils.csplit', + 'coreutils.cut', + 'coreutils.date', + 'coreutils.dd', + 'coreutils.df', + 'coreutils.dir', + 'coreutils.dircolors', + 'coreutils.dirname', + 'coreutils.du', + 'coreutils.echo', + 'coreutils.env', + 'coreutils.expand', + 'coreutils.expr', + 'coreutils.factor', + 'coreutils.false', + 'coreutils.fmt', + 'coreutils.fold', + 'coreutils.head', + 'coreutils.hostname', + 'coreutils.join', + 'coreutils.link', + 'coreutils.ln', + 'coreutils.ls', + 'coreutils.mkdir', + 'coreutils.mktemp', + 'coreutils.more', + 'coreutils.mv', + 'coreutils.nl', + 'coreutils.nproc', + 'coreutils.numfmt', + 'coreutils.od', + 'coreutils.paste', + 'coreutils.pr', + 'coreutils.printenv', + 'coreutils.printf', + 'coreutils.ptx', + 'coreutils.pwd', + 'coreutils.readlink', + 'coreutils.realpath', + 'coreutils.rm', + 'coreutils.rmdir', + 'coreutils.seq', + 'coreutils.shred', + 'coreutils.shuf', + 'coreutils.sleep', + 'coreutils.sort', + 'coreutils.split', + 'coreutils.sum', + 'coreutils.sync', + 'coreutils.tac', + 'coreutils.tail', + 'coreutils.tee', + 'coreutils.test', + 'coreutils.touch', + 'coreutils.tr', + 'coreutils.true', + 'coreutils.truncate', + 'coreutils.tsort', + 'coreutils.uname', + 'coreutils.unexpand', + 'coreutils.uniq', + 'coreutils.unlink', + 'coreutils.vdir', + 'coreutils.wc', + 'coreutils.whoami', + 'coreutils.yes', +] + +'coreutils.arch' = ["dep:uu_arch"] +'coreutils.base32' = ["dep:uu_base32"] +'coreutils.base64' = ["dep:uu_base64"] +'coreutils.basename' = ["dep:uu_basename"] +'coreutils.basenc' = ["dep:uu_basenc"] +'coreutils.cat' = ["dep:uu_cat"] +'coreutils.cksum' = ["dep:uu_cksum"] +'coreutils.b2sum' = ["dep:uu_b2sum"] +'coreutils.md5sum' = ["dep:uu_md5sum"] +'coreutils.sha1sum' = ["dep:uu_sha1sum"] +'coreutils.sha224sum' = ["dep:uu_sha224sum"] +'coreutils.sha256sum' = ["dep:uu_sha256sum"] +'coreutils.sha384sum' = ["dep:uu_sha384sum"] +'coreutils.sha512sum' = ["dep:uu_sha512sum"] +'coreutils.comm' = ["dep:uu_comm"] +'coreutils.cp' = ["dep:uu_cp"] +'coreutils.csplit' = ["dep:uu_csplit"] +'coreutils.cut' = ["dep:uu_cut"] +'coreutils.date' = ["dep:uu_date"] +'coreutils.dd' = ["dep:uu_dd"] +'coreutils.df' = ["dep:uu_df"] +'coreutils.dir' = ["dep:uu_dir"] +'coreutils.dircolors' = ["dep:uu_dircolors"] +'coreutils.dirname' = ["dep:uu_dirname"] +'coreutils.du' = ["dep:uu_du"] +'coreutils.echo' = ["dep:uu_echo"] +'coreutils.env' = ["dep:uu_env"] +'coreutils.expand' = ["dep:uu_expand"] +'coreutils.expr' = ["dep:uu_expr"] +'coreutils.factor' = ["dep:uu_factor"] +'coreutils.false' = ["dep:uu_false"] +'coreutils.fmt' = ["dep:uu_fmt"] +'coreutils.fold' = ["dep:uu_fold"] +'coreutils.head' = ["dep:uu_head"] +'coreutils.hostname' = ["dep:uu_hostname"] +'coreutils.join' = ["dep:uu_join"] +'coreutils.link' = ["dep:uu_link"] +'coreutils.ln' = ["dep:uu_ln"] +'coreutils.ls' = ["dep:uu_ls"] +'coreutils.mkdir' = ["dep:uu_mkdir"] +'coreutils.mktemp' = ["dep:uu_mktemp"] +'coreutils.more' = ["dep:uu_more"] +'coreutils.mv' = ["dep:uu_mv"] +'coreutils.nl' = ["dep:uu_nl"] +'coreutils.nproc' = ["dep:uu_nproc"] +'coreutils.numfmt' = ["dep:uu_numfmt"] +'coreutils.od' = ["dep:uu_od"] +'coreutils.paste' = ["dep:uu_paste"] +'coreutils.pr' = ["dep:uu_pr"] +'coreutils.printenv' = ["dep:uu_printenv"] +'coreutils.printf' = ["dep:uu_printf"] +'coreutils.ptx' = ["dep:uu_ptx"] +'coreutils.pwd' = ["dep:uu_pwd"] +'coreutils.readlink' = ["dep:uu_readlink"] +'coreutils.realpath' = ["dep:uu_realpath"] +'coreutils.rm' = ["dep:uu_rm"] +'coreutils.rmdir' = ["dep:uu_rmdir"] +'coreutils.seq' = ["dep:uu_seq"] +'coreutils.shred' = ["dep:uu_shred"] +'coreutils.shuf' = ["dep:uu_shuf"] +'coreutils.sleep' = ["dep:uu_sleep"] +'coreutils.sort' = ["dep:uu_sort"] +'coreutils.split' = ["dep:uu_split"] +'coreutils.sum' = ["dep:uu_sum"] +'coreutils.sync' = ["dep:uu_sync"] +'coreutils.tac' = ["dep:uu_tac"] +'coreutils.tail' = ["dep:uu_tail"] +'coreutils.tee' = ["dep:uu_tee"] +'coreutils.test' = ["dep:uu_test"] +'coreutils.touch' = ["dep:uu_touch"] +'coreutils.tr' = ["dep:uu_tr"] +'coreutils.true' = ["dep:uu_true"] +'coreutils.truncate' = ["dep:uu_truncate"] +'coreutils.tsort' = ["dep:uu_tsort"] +'coreutils.uname' = ["dep:uu_uname"] +'coreutils.unexpand' = ["dep:uu_unexpand"] +'coreutils.uniq' = ["dep:uu_uniq"] +'coreutils.unlink' = ["dep:uu_unlink"] +'coreutils.vdir' = ["dep:uu_vdir"] +'coreutils.wc' = ["dep:uu_wc"] +'coreutils.whoami' = ["dep:uu_whoami"] +'coreutils.yes' = ["dep:uu_yes"] + +[lints] +workspace = true + +[dependencies] +uucore = { version = "0.8.0", default-features = false } + +uu_arch = { version = "0.8.0", optional = true } +uu_base32 = { version = "0.8.0", optional = true } +uu_base64 = { version = "0.8.0", optional = true } +uu_basename = { version = "0.8.0", optional = true } +uu_basenc = { version = "0.8.0", optional = true } +uu_cat = { version = "0.8.0", optional = true } +uu_cksum = { version = "0.8.0", optional = true } +uu_b2sum = { version = "0.8.0", optional = true } +uu_md5sum = { version = "0.8.0", optional = true } +uu_sha1sum = { version = "0.8.0", optional = true } +uu_sha224sum = { version = "0.8.0", optional = true } +uu_sha256sum = { version = "0.8.0", optional = true } +uu_sha384sum = { version = "0.8.0", optional = true } +uu_sha512sum = { version = "0.8.0", optional = true } +uu_comm = { version = "0.8.0", optional = true } +uu_cp = { version = "0.8.0", optional = true } +uu_csplit = { version = "0.8.0", optional = true } +uu_cut = { version = "0.8.0", optional = true } +uu_date = { version = "0.8.0", optional = true } +uu_dd = { version = "0.8.0", optional = true } +uu_df = { version = "0.8.0", optional = true } +uu_dir = { version = "0.8.0", optional = true } +uu_dircolors = { version = "0.8.0", optional = true } +uu_dirname = { version = "0.8.0", optional = true } +uu_du = { version = "0.8.0", optional = true } +uu_echo = { version = "0.8.0", optional = true } +uu_env = { version = "0.8.0", optional = true } +uu_expand = { version = "0.8.0", optional = true } +uu_expr = { version = "0.8.0", optional = true } +uu_factor = { version = "0.8.0", optional = true } +uu_false = { version = "0.8.0", optional = true } +uu_fmt = { version = "0.8.0", optional = true } +uu_fold = { version = "0.8.0", optional = true } +uu_head = { version = "0.8.0", optional = true } +uu_hostname = { version = "0.8.0", optional = true } +uu_join = { version = "0.8.0", optional = true } +uu_link = { version = "0.8.0", optional = true } +uu_ln = { version = "0.8.0", optional = true } +uu_ls = { version = "0.8.0", optional = true } +uu_mkdir = { version = "0.8.0", optional = true } +uu_mktemp = { version = "0.8.0", optional = true } +uu_more = { version = "0.8.0", optional = true } +uu_mv = { version = "0.8.0", optional = true } +uu_nl = { version = "0.8.0", optional = true } +uu_nproc = { version = "0.8.0", optional = true } +uu_numfmt = { version = "0.8.0", optional = true } +uu_od = { version = "0.8.0", optional = true } +uu_paste = { version = "0.8.0", optional = true } +uu_pr = { version = "0.8.0", optional = true } +uu_printenv = { version = "0.8.0", optional = true } +uu_printf = { version = "0.8.0", optional = true } +uu_ptx = { version = "0.8.0", optional = true } +uu_pwd = { version = "0.8.0", optional = true } +uu_readlink = { version = "0.8.0", optional = true } +uu_realpath = { version = "0.8.0", optional = true } +uu_rm = { version = "0.8.0", optional = true } +uu_rmdir = { version = "0.8.0", optional = true } +uu_seq = { version = "0.8.0", optional = true } +uu_shred = { version = "0.8.0", optional = true } +uu_shuf = { version = "0.8.0", optional = true } +uu_sleep = { version = "0.8.0", optional = true } +uu_sort = { version = "0.8.0", optional = true } +uu_split = { version = "0.8.0", optional = true } +uu_sum = { version = "0.8.0", optional = true } +uu_sync = { version = "0.8.0", optional = true } +uu_tac = { version = "0.8.0", optional = true } +uu_tail = { version = "0.8.0", optional = true } +uu_tee = { version = "0.8.0", optional = true } +uu_test = { version = "0.8.0", optional = true } +uu_touch = { version = "0.8.0", optional = true } +uu_tr = { version = "0.8.0", optional = true } +uu_true = { version = "0.8.0", optional = true } +uu_truncate = { version = "0.8.0", optional = true } +uu_tsort = { version = "0.8.0", optional = true } +uu_uname = { version = "0.8.0", optional = true } +uu_unexpand = { version = "0.8.0", optional = true } +uu_uniq = { version = "0.8.0", optional = true } +uu_unlink = { version = "0.8.0", optional = true } +uu_vdir = { version = "0.8.0", optional = true } +uu_wc = { version = "0.8.0", optional = true } +uu_whoami = { version = "0.8.0", optional = true } +uu_yes = { version = "0.8.0", optional = true } diff --git a/local/recipes/shells/brush/source/brush-coreutils-builtins/LICENSE b/local/recipes/shells/brush/source/brush-coreutils-builtins/LICENSE new file mode 120000 index 0000000000..ea5b60640b --- /dev/null +++ b/local/recipes/shells/brush/source/brush-coreutils-builtins/LICENSE @@ -0,0 +1 @@ +../LICENSE \ No newline at end of file diff --git a/local/recipes/shells/brush/source/brush-coreutils-builtins/src/lib.rs b/local/recipes/shells/brush/source/brush-coreutils-builtins/src/lib.rs new file mode 100644 index 0000000000..dfec486d36 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-coreutils-builtins/src/lib.rs @@ -0,0 +1,207 @@ +//! Optional bundled coreutils for brush, powered by uutils/coreutils. +//! +//! Each utility is feature-gated (e.g., `coreutils.cat`, `coreutils.ls`) and +//! can be individually enabled or disabled. The `coreutils.all` feature enables +//! all cross-platform utilities. +//! +//! The crate exposes [`bundled_commands`], a registry of utility name to +//! `uumain`-style function pointer. The binary consumer (typically +//! brush-shell) installs this registry at startup; when its bundled-dispatch +//! protocol fires, the registered function is called directly. The consumer +//! is also responsible for registering any per-name builtin shims that +//! re-enter the binary as an external process, so shell redirections, pipes, +//! and process-group state are honored by uutils transparently. +//! +//! ## Relationship to `uucore::bin!` +//! +//! Each adapter generated by [`register!`] is a hand-rolled port of the body +//! of upstream's `uucore::bin!($util)` macro (SIGPIPE setup, Fluent +//! localization, `uumain` call, stdout flush). We can't invoke the macro +//! directly because it expands to a `pub fn main()` that reads argv via +//! `uucore::args_os()` and terminates the process with `std::process::exit`. +//! Our call shape is different: adapters receive argv as a parameter (the +//! `--invoke-bundled` dispatcher owns argv slicing) and must *return* an exit +//! code so the dispatcher can run a single `exit` site. +//! +//! Intentional divergences from `bin!`, all of which are benign in our usage: +//! +//! * **No `exit`:** adapters return `i32` instead of calling +//! `std::process::exit`. +//! * **Caller-supplied argv:** `args` is passed in rather than pulled from +//! `uucore::args_os()`. +//! * **Simpler `LocalizationError` formatting:** we render with `Display` +//! rather than matching on `ParseResource` for snippet-aware output. + +use std::collections::HashMap; +use std::ffi::OsString; + +/// First half of the `uucore::bin!` body: install SIGPIPE handling and +/// initialize the thread-local Fluent localizer. Without this, `translate!` +/// calls inside the utility fall back to emitting raw Fluent keys (e.g., +/// `ls-error-cannot-access-no-such-file`) instead of rendered messages. +/// +/// `util_crate_name` is the upstream crate name (e.g., `"uu_dir"`), matching +/// what `bin!` passes via `stringify!($util)`. We route it through +/// [`uucore::get_canonical_util_name`] to apply upstream's alias map (`dir` → +/// `ls`, `vdir` → `ls`, `[` → `test`); utilities implemented as thin wrappers +/// over another crate load their translation bundle under the wrapped name, +/// so skipping this step silently breaks localization for the aliases. +/// +/// Matches `uucore::bin!` behavior on localization-init failure: exit 99. +/// (This is uutils' contract, not brush's — the fact that brush-core uses 99 +/// for `ExecutionExitCode::Unimplemented` is coincidental.) +/// `setup_localization` is idempotent per thread, so repeated invocations +/// (e.g., successive bundled-dispatch calls in a long-lived host, if that +/// ever happens) are cheap. +#[allow( + dead_code, + reason = "only called by adapters generated by the `register!` macro; with no coreutils.* features enabled, no adapter exists" +)] +fn prepare_uutil_runtime(util_crate_name: &str) { + uucore::panic::preserve_inherited_sigpipe(); + uucore::panic::mute_sigpipe_panic(); + let localization_name = uucore::get_canonical_util_name(util_crate_name); + if let Err(err) = uucore::locale::setup_localization(localization_name) { + eprintln!("brush: could not initialize localization for '{localization_name}': {err}"); + std::process::exit(99); + } +} + +/// Second half of the `uucore::bin!` body: flush stdout after `uumain` +/// returns. `bin!` does this before calling `std::process::exit` to work +/// around . We do it here so +/// callers that don't immediately `exit` (e.g., a test harness) still observe +/// complete output. +#[allow( + dead_code, + reason = "only called by adapters generated by the `register!` macro; with no coreutils.* features enabled, no adapter exists" +)] +fn finalize_uutil_runtime() { + use std::io::Write; + if let Err(e) = std::io::stdout().flush() { + eprintln!("brush: error flushing stdout: {e}"); + } +} + +/// Adapts a uutils `uumain` (`impl Iterator -> i32`) to a +/// plain `fn(Vec) -> i32` and inserts it into the registry under +/// the given name when its feature is enabled. The generated adapter mirrors +/// the body of `uucore::bin!`; see the module-level docs for why we port it +/// by hand rather than invoke the macro. +macro_rules! register { + ($map:expr, $feature:literal, $name:literal, $util_crate:ident) => { + #[cfg(feature = $feature)] + { + fn adapter(args: I) -> i32 + where + I: IntoIterator, + { + $crate::prepare_uutil_runtime(stringify!($util_crate)); + let code = $util_crate::uumain(args.into_iter()); + $crate::finalize_uutil_runtime(); + code + } + $map.insert($name.to_string(), adapter as fn(Vec) -> i32); + } + }; +} + +/// Returns the set of bundled coreutils commands enabled by feature flags. +/// +/// Keyed by utility name (e.g., `"cat"`); values are `uumain`-shaped entry +/// points that consume a full argv (with `argv[0]` as the command name) and +/// return an exit code. +#[allow(clippy::too_many_lines)] +#[must_use] +pub fn bundled_commands() -> HashMap) -> i32> { + #[allow( + unused_mut, + reason = "every register! is cfg'd on a coreutils.* feature; with none enabled, nothing mutates the map" + )] + let mut m = HashMap::) -> i32>::new(); + + register!(m, "coreutils.arch", "arch", uu_arch); + register!(m, "coreutils.base32", "base32", uu_base32); + register!(m, "coreutils.base64", "base64", uu_base64); + register!(m, "coreutils.basename", "basename", uu_basename); + register!(m, "coreutils.basenc", "basenc", uu_basenc); + register!(m, "coreutils.cat", "cat", uu_cat); + register!(m, "coreutils.cksum", "cksum", uu_cksum); + register!(m, "coreutils.b2sum", "b2sum", uu_b2sum); + register!(m, "coreutils.md5sum", "md5sum", uu_md5sum); + register!(m, "coreutils.sha1sum", "sha1sum", uu_sha1sum); + register!(m, "coreutils.sha224sum", "sha224sum", uu_sha224sum); + register!(m, "coreutils.sha256sum", "sha256sum", uu_sha256sum); + register!(m, "coreutils.sha384sum", "sha384sum", uu_sha384sum); + register!(m, "coreutils.sha512sum", "sha512sum", uu_sha512sum); + register!(m, "coreutils.comm", "comm", uu_comm); + register!(m, "coreutils.cp", "cp", uu_cp); + register!(m, "coreutils.csplit", "csplit", uu_csplit); + register!(m, "coreutils.cut", "cut", uu_cut); + register!(m, "coreutils.date", "date", uu_date); + register!(m, "coreutils.dd", "dd", uu_dd); + register!(m, "coreutils.df", "df", uu_df); + register!(m, "coreutils.dir", "dir", uu_dir); + register!(m, "coreutils.dircolors", "dircolors", uu_dircolors); + register!(m, "coreutils.dirname", "dirname", uu_dirname); + register!(m, "coreutils.du", "du", uu_du); + register!(m, "coreutils.echo", "echo", uu_echo); + register!(m, "coreutils.env", "env", uu_env); + register!(m, "coreutils.expand", "expand", uu_expand); + register!(m, "coreutils.expr", "expr", uu_expr); + register!(m, "coreutils.factor", "factor", uu_factor); + register!(m, "coreutils.false", "false", uu_false); + register!(m, "coreutils.fmt", "fmt", uu_fmt); + register!(m, "coreutils.fold", "fold", uu_fold); + register!(m, "coreutils.head", "head", uu_head); + register!(m, "coreutils.hostname", "hostname", uu_hostname); + register!(m, "coreutils.join", "join", uu_join); + register!(m, "coreutils.link", "link", uu_link); + register!(m, "coreutils.ln", "ln", uu_ln); + register!(m, "coreutils.ls", "ls", uu_ls); + register!(m, "coreutils.mkdir", "mkdir", uu_mkdir); + register!(m, "coreutils.mktemp", "mktemp", uu_mktemp); + register!(m, "coreutils.more", "more", uu_more); + register!(m, "coreutils.mv", "mv", uu_mv); + register!(m, "coreutils.nl", "nl", uu_nl); + register!(m, "coreutils.nproc", "nproc", uu_nproc); + register!(m, "coreutils.numfmt", "numfmt", uu_numfmt); + register!(m, "coreutils.od", "od", uu_od); + register!(m, "coreutils.paste", "paste", uu_paste); + register!(m, "coreutils.pr", "pr", uu_pr); + register!(m, "coreutils.printenv", "printenv", uu_printenv); + register!(m, "coreutils.printf", "printf", uu_printf); + register!(m, "coreutils.ptx", "ptx", uu_ptx); + register!(m, "coreutils.pwd", "pwd", uu_pwd); + register!(m, "coreutils.readlink", "readlink", uu_readlink); + register!(m, "coreutils.realpath", "realpath", uu_realpath); + register!(m, "coreutils.rm", "rm", uu_rm); + register!(m, "coreutils.rmdir", "rmdir", uu_rmdir); + register!(m, "coreutils.seq", "seq", uu_seq); + register!(m, "coreutils.shred", "shred", uu_shred); + register!(m, "coreutils.shuf", "shuf", uu_shuf); + register!(m, "coreutils.sleep", "sleep", uu_sleep); + register!(m, "coreutils.sort", "sort", uu_sort); + register!(m, "coreutils.split", "split", uu_split); + register!(m, "coreutils.sum", "sum", uu_sum); + register!(m, "coreutils.sync", "sync", uu_sync); + register!(m, "coreutils.tac", "tac", uu_tac); + register!(m, "coreutils.tail", "tail", uu_tail); + register!(m, "coreutils.tee", "tee", uu_tee); + register!(m, "coreutils.test", "test", uu_test); + register!(m, "coreutils.touch", "touch", uu_touch); + register!(m, "coreutils.tr", "tr", uu_tr); + register!(m, "coreutils.true", "true", uu_true); + register!(m, "coreutils.truncate", "truncate", uu_truncate); + register!(m, "coreutils.tsort", "tsort", uu_tsort); + register!(m, "coreutils.uname", "uname", uu_uname); + register!(m, "coreutils.unexpand", "unexpand", uu_unexpand); + register!(m, "coreutils.uniq", "uniq", uu_uniq); + register!(m, "coreutils.unlink", "unlink", uu_unlink); + register!(m, "coreutils.vdir", "vdir", uu_vdir); + register!(m, "coreutils.wc", "wc", uu_wc); + register!(m, "coreutils.whoami", "whoami", uu_whoami); + register!(m, "coreutils.yes", "yes", uu_yes); + + m +} diff --git a/local/recipes/shells/brush/source/brush-experimental-builtins/Cargo.toml b/local/recipes/shells/brush/source/brush-experimental-builtins/Cargo.toml new file mode 100644 index 0000000000..da567f1ad0 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-experimental-builtins/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "brush-experimental-builtins" +description = "*EXPERIMENTAL builtins for a POSIX/bash shell (used by brush-shell)" +version = "0.1.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.save'] +'builtin.save' = ["brush-core/serde", "dep:serde_json"] + +[lints] +workspace = true + +[dependencies] +brush-core = { version = "^0.5.0", path = "../brush-core" } +clap = { version = "4.6.0", features = ["derive", "wrap_help"] } +serde_json = { version = "1.0.149", optional = true } diff --git a/local/recipes/shells/brush/source/brush-experimental-builtins/LICENSE b/local/recipes/shells/brush/source/brush-experimental-builtins/LICENSE new file mode 120000 index 0000000000..ea5b60640b --- /dev/null +++ b/local/recipes/shells/brush/source/brush-experimental-builtins/LICENSE @@ -0,0 +1 @@ +../LICENSE \ No newline at end of file diff --git a/local/recipes/shells/brush/source/brush-experimental-builtins/src/lib.rs b/local/recipes/shells/brush/source/brush-experimental-builtins/src/lib.rs new file mode 100644 index 0000000000..12f8be1063 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-experimental-builtins/src/lib.rs @@ -0,0 +1,33 @@ +//! Experimental builtins. + +#[cfg(feature = "builtin.save")] +mod save; + +#[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}; + +/// Returns the set of experimental built-in commands. +pub fn experimental_builtins() +-> std::collections::HashMap> { + let mut m = std::collections::HashMap::>::new(); + + #[cfg(feature = "builtin.save")] + m.insert("save".into(), builtin::()); + + m +} + +/// Extension trait that simplifies adding experimental builtins to a shell builder. +pub trait ShellBuilderExt { + /// Add experimental builtins to the shell being built. + #[must_use] + fn experimental_builtins(self) -> Self; +} + +impl ShellBuilderExt + for brush_core::ShellBuilder +{ + fn experimental_builtins(self) -> Self { + self.builtins(crate::experimental_builtins()) + } +} diff --git a/local/recipes/shells/brush/source/brush-experimental-builtins/src/save.rs b/local/recipes/shells/brush/source/brush-experimental-builtins/src/save.rs new file mode 100644 index 0000000000..004eecca84 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-experimental-builtins/src/save.rs @@ -0,0 +1,26 @@ +use brush_core::{ExecutionResult, builtins}; +use clap::Parser; +use std::io::Write; + +/// (*EXPERIMENTAL*) Serializes the current shell state to JSON and writes it to stdout. +/// Beware that the serialized state may include sensitive information, such as any +/// secrets stored in shell variables or referenced in command history. +#[derive(Parser)] +pub(crate) struct SaveCommand {} + +impl builtins::Command for SaveCommand { + type Error = brush_core::Error; + + async fn execute( + &self, + context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + let serialized_str = serde_json::to_string(&context.shell).map_err(|e| { + brush_core::Error::from(brush_core::ErrorKind::InternalError(e.to_string())) + })?; + + writeln!(context.stdout(), "{serialized_str}")?; + + Ok(ExecutionResult::success()) + } +} diff --git a/local/recipes/shells/brush/source/brush-interactive/Cargo.toml b/local/recipes/shells/brush/source/brush-interactive/Cargo.toml new file mode 100644 index 0000000000..3e9e81559a --- /dev/null +++ b/local/recipes/shells/brush/source/brush-interactive/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "brush-interactive" +description = "Interactive layer of brush-shell" +version = "0.4.0" +authors.workspace = true +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 = [] +basic = ["dep:crossterm", "completion"] +completion = [] +minimal = [] +reedline = ["dep:reedline", "dep:nu-ansi-term", "completion", "highlighting"] +highlighting = [] + +[lints] +workspace = true + +[dependencies] +bon = "3.9.1" +brush-parser = { version = "^0.4.0", path = "../brush-parser" } +brush-core = { version = "^0.5.0", path = "../brush-core" } +crossterm = { version = "0.29.0", features = ["serde"], optional = true } +indexmap = "2.13.0" +nu-ansi-term = { version = "0.50.3", optional = true } +radix_trie = "0.3.0" +reedline = { version = "0.49.0", optional = true } +thiserror = "2.0.18" +tokio = { version = "1.52.3", features = ["rt", "sync"] } +tracing = "0.1.44" + +[dev-dependencies] +pretty_assertions = { version = "1.4.1", features = ["unstable"] } diff --git a/local/recipes/shells/brush/source/brush-interactive/LICENSE b/local/recipes/shells/brush/source/brush-interactive/LICENSE new file mode 120000 index 0000000000..ea5b60640b --- /dev/null +++ b/local/recipes/shells/brush/source/brush-interactive/LICENSE @@ -0,0 +1 @@ +../LICENSE \ No newline at end of file diff --git a/local/recipes/shells/brush/source/brush-interactive/src/basic/input_backend.rs b/local/recipes/shells/brush/source/brush-interactive/src/basic/input_backend.rs new file mode 100644 index 0000000000..d8efa545cd --- /dev/null +++ b/local/recipes/shells/brush/source/brush-interactive/src/basic/input_backend.rs @@ -0,0 +1,117 @@ +use std::io::IsTerminal; + +use brush_core::Shell; + +use crate::{ + InputBackend, ShellError, completion, + input_backend::{InteractivePrompt, ReadResult}, +}; + +use super::{non_term_line_reader, term_line_reader}; + +/// Represents a basic shell input backend capable of interactive usage, with primitive support +/// for completion and test-focused automation via pexpect and similar technologies. +#[derive(Default)] +pub struct BasicInputBackend; + +impl InputBackend for BasicInputBackend { + fn read_line( + &mut self, + shell: &crate::ShellRef, + prompt: InteractivePrompt, + ) -> Result { + if std::io::stdin().is_terminal() { + self.read_line_via(shell, &term_line_reader::TermLineReader::new()?, &prompt) + } else { + self.read_line_via(shell, &non_term_line_reader::NonTermLineReader, &prompt) + } + } +} + +impl BasicInputBackend { + fn read_line_via( + &self, + shell_ref: &crate::ShellRef, + reader: &R, + prompt: &InteractivePrompt, + ) -> Result { + let mut prompt_to_use = self.should_display_prompt().then_some(&prompt); + let mut result = String::new(); + + loop { + match reader.read_line(prompt_to_use.map(|p| p.prompt.as_str()), |line, cursor| { + let mut shell = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(shell_ref.lock()) + }); + + Self::generate_completions(&mut shell, line, cursor) + })? { + ReadResult::Input(s) => { + result.push_str(s.as_str()); + + let shell = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(shell_ref.lock()) + }); + + if Self::is_valid_input(&shell, result.as_str()) { + break; + } + + prompt_to_use = None; + } + ReadResult::BoundCommand(s) => { + result.push_str(s.as_str()); + break; + } + ReadResult::Eof => { + if result.is_empty() { + return Ok(ReadResult::Eof); + } + break; + } + ReadResult::Interrupted => return Ok(ReadResult::Interrupted), + } + } + + Ok(ReadResult::Input(result)) + } + + #[expect(clippy::unused_self)] + fn should_display_prompt(&self) -> bool { + std::io::stdin().is_terminal() + } + + fn is_valid_input(shell: &Shell, input: &str) -> bool { + match shell.parse_string(input) { + // Incomplete tokenizing (unclosed quotes, etc.) - need more input + Err(brush_parser::ParseError::Tokenizing { inner, position: _ }) + if inner.is_incomplete() => + { + false + } + // Parse error at end of input - could be incomplete + Err(brush_parser::ParseError::ParsingAtEndOfInput) => false, + // Parse error at a specific position OR successful parse - complete + _ => true, + } + } + + fn generate_completions( + shell: &mut Shell, + line: &str, + cursor: usize, + ) -> Result { + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current() + .block_on(Self::generate_completions_async(shell, line, cursor)) + }) + } + + async fn generate_completions_async( + shell: &mut Shell, + line: &str, + cursor: usize, + ) -> Result { + Ok(completion::complete_async(shell, line, cursor).await) + } +} diff --git a/local/recipes/shells/brush/source/brush-interactive/src/basic/mod.rs b/local/recipes/shells/brush/source/brush-interactive/src/basic/mod.rs new file mode 100644 index 0000000000..cad0319653 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-interactive/src/basic/mod.rs @@ -0,0 +1,19 @@ +mod input_backend; +mod non_term_line_reader; +mod term_line_reader; + +pub use input_backend::BasicInputBackend; + +use crate::{ReadResult, ShellError}; + +pub(crate) trait LineReader { + fn read_line( + &self, + prompt: Option<&str>, + completion_handler: impl FnMut( + &str, + usize, + ) + -> Result, + ) -> Result; +} diff --git a/local/recipes/shells/brush/source/brush-interactive/src/basic/non_term_line_reader.rs b/local/recipes/shells/brush/source/brush-interactive/src/basic/non_term_line_reader.rs new file mode 100644 index 0000000000..33264379bb --- /dev/null +++ b/local/recipes/shells/brush/source/brush-interactive/src/basic/non_term_line_reader.rs @@ -0,0 +1,28 @@ +use crate::{ReadResult, ShellError}; + +pub(crate) struct NonTermLineReader; + +impl super::LineReader for NonTermLineReader { + fn read_line( + &self, + _prompt: Option<&str>, + _completion_handler: impl FnMut( + &str, + usize, + ) -> Result< + brush_core::completion::Completions, + crate::ShellError, + >, + ) -> Result { + let mut input = String::new(); + let bytes_read = std::io::stdin() + .read_line(&mut input) + .map_err(ShellError::InputError)?; + + if bytes_read == 0 { + Ok(ReadResult::Eof) + } else { + Ok(ReadResult::Input(input)) + } + } +} diff --git a/local/recipes/shells/brush/source/brush-interactive/src/basic/term_line_reader.rs b/local/recipes/shells/brush/source/brush-interactive/src/basic/term_line_reader.rs new file mode 100644 index 0000000000..3ffb78bc4f --- /dev/null +++ b/local/recipes/shells/brush/source/brush-interactive/src/basic/term_line_reader.rs @@ -0,0 +1,315 @@ +// +// This module is intentionally limited, and does not have all the bells and whistles. We wan +// enough here that we can use it in the basic shell for (p)expect/pty-style testing of +// completion, and without using VT100-style escape sequences for cursor movement and display. +// + +use crossterm::ExecutableCommand; +use std::io::Write; + +use crate::{ReadResult, ShellError}; + +const BACKSPACE: char = 8u8 as char; + +pub(crate) struct TermLineReader { + term_mode: brush_core::terminal::AutoModeGuard, +} + +impl TermLineReader { + pub fn new() -> Result { + let reader = Self { + term_mode: brush_core::terminal::AutoModeGuard::new(std::io::stdin().into())?, + }; + + let settings = brush_core::terminal::Settings::builder() + .echo_input(false) + .line_input(false) + .interrupt_signals(false) + .output_nl_as_nlcr(true) + .build(); + + reader.term_mode.apply_settings(&settings)?; + + Ok(reader) + } +} + +impl super::LineReader for TermLineReader { + fn read_line( + &self, + prompt: Option<&str>, + mut completion_handler: impl FnMut( + &str, + usize, + ) + -> Result, + ) -> Result { + let mut state = ReadLineState::new(prompt); + state.display_prompt()?; + + loop { + if let crossterm::event::Event::Key(event) = crossterm::event::read()? + && let Some(result) = state.on_key(event, &mut completion_handler)? + { + return Ok(result); + } + } + } +} + +struct ReadLineState<'a> { + // Current line of input + line: String, + // Current position of cursor, expressed as a byte offset from the + // start of `line`. We maintain the invariant that it will always + // be at a clean character boundary. + cursor: usize, + // Current prompt to use. + prompt: Option<&'a str>, +} + +impl<'a> ReadLineState<'a> { + const fn new(prompt: Option<&'a str>) -> Self { + Self { + line: String::new(), + cursor: 0, + prompt, + } + } + + pub fn display_prompt(&self) -> Result<(), ShellError> { + if let Some(prompt) = self.prompt { + eprint!("{prompt}"); + std::io::stderr().flush()?; + } + + Ok(()) + } + + fn on_key( + &mut self, + event: crossterm::event::KeyEvent, + mut completion_handler: impl FnMut( + &str, + usize, + ) + -> Result, + ) -> Result, ShellError> { + match (event.modifiers, event.code) { + (_, crossterm::event::KeyCode::Enter) + | (crossterm::event::KeyModifiers::CONTROL, crossterm::event::KeyCode::Char('j')) => { + Self::display_newline()?; + self.line.push('\n'); + let line = std::mem::take(&mut self.line); + return Ok(Some(ReadResult::Input(line))); + } + ( + crossterm::event::KeyModifiers::SHIFT | crossterm::event::KeyModifiers::NONE, + crossterm::event::KeyCode::Char(c), + ) => { + self.on_char(c)?; + } + (crossterm::event::KeyModifiers::CONTROL, crossterm::event::KeyCode::Char('c')) => { + eprintln!("^C"); + return Ok(Some(ReadResult::Interrupted)); + } + (crossterm::event::KeyModifiers::CONTROL, crossterm::event::KeyCode::Char('d')) + if self.line.is_empty() => + { + Self::display_newline()?; + return Ok(Some(ReadResult::Eof)); + } + (crossterm::event::KeyModifiers::CONTROL, crossterm::event::KeyCode::Char('l')) => { + self.clear_screen()?; + } + (_, crossterm::event::KeyCode::Backspace) => { + self.backspace()?; + } + (_, crossterm::event::KeyCode::Left) => { + self.move_cursor_left()?; + } + (_, crossterm::event::KeyCode::Tab) => { + let completions = completion_handler(self.line.as_str(), self.cursor)?; + self.handle_completions(&completions)?; + } + _ => (), + } + + Ok(None) + } + + fn on_char(&mut self, c: char) -> Result<(), ShellError> { + self.line.insert(self.cursor, c); + self.cursor += c.len_utf8(); + eprint!("{c}"); + std::io::stderr().flush()?; + + Ok(()) + } + + fn display_newline() -> Result<(), ShellError> { + eprintln!(); + std::io::stderr().flush()?; + + Ok(()) + } + + fn clear_screen(&self) -> Result<(), ShellError> { + std::io::stderr() + .execute(crossterm::terminal::Clear( + crossterm::terminal::ClearType::All, + ))? + .execute(crossterm::cursor::MoveTo(0, 0))?; + + self.display_prompt()?; + eprint!("{}", self.line.as_str()); + std::io::stderr().flush()?; + Ok(()) + } + + #[allow(clippy::string_slice, reason = "it's calculated based on char indices")] + fn backspace(&mut self) -> Result<(), ShellError> { + let char_indices = self.line.char_indices(); + + let Some((last_char_index, _)) = char_indices.last() else { + return Ok(()); + }; + + self.cursor = last_char_index; + self.line.truncate(last_char_index); + + eprint!("{BACKSPACE}"); + eprint!("{} ", &self.line[self.cursor..]); + eprint!( + "{}", + repeated_char_str(BACKSPACE, self.line.len() + 1 - self.cursor) + ); + + std::io::stderr().flush()?; + Ok(()) + } + + fn move_cursor_left(&mut self) -> Result<(), ShellError> { + eprint!("{BACKSPACE}"); + std::io::stderr().flush()?; + + self.cursor = self.cursor.saturating_sub(1); + + while self.cursor > 0 && !self.line.is_char_boundary(self.cursor) { + self.cursor -= 1; + } + + Ok(()) + } + + fn handle_completions( + &mut self, + completions: &brush_core::completion::Completions, + ) -> Result<(), ShellError> { + if completions.candidates.is_empty() { + // Do nothing + Ok(()) + } else if completions.candidates.len() == 1 { + self.handle_single_completion(completions) + } else { + self.handle_multiple_completions(completions) + } + } + + #[expect( + clippy::string_slice, + reason = "all offsets are expected to be at char boundaries" + )] + fn handle_single_completion( + &mut self, + completions: &brush_core::completion::Completions, + ) -> Result<(), ShellError> { + let Some(candidate) = completions.candidates.first() else { + return Ok(()); + }; + + if completions.insertion_index + completions.delete_count != self.cursor { + return Ok(()); + } + + let mut delete_count = completions.delete_count; + let mut redisplay_offset = completions.insertion_index; + + // Don't bother erasing and re-writing the portion of the + // completion's prefix that + // is identical to what we already had in the token-being-completed. + if delete_count > 0 + && candidate.starts_with(&self.line[redisplay_offset..redisplay_offset + delete_count]) + { + redisplay_offset += delete_count; + delete_count = 0; + } + + let mut updated_line = self.line.clone(); + updated_line.truncate(completions.insertion_index); + updated_line.push_str(candidate); + updated_line.push_str(&self.line[self.cursor..]); + self.line = updated_line; + + self.cursor = completions.insertion_index + candidate.len(); + + let move_left = repeated_char_str(BACKSPACE, delete_count); + eprint!("{move_left}{}", &self.line[redisplay_offset..]); + + // TODO(completion): Remove trailing chars if completion is shorter? + eprint!( + "{}", + repeated_char_str(BACKSPACE, self.line.len() - self.cursor) + ); + + std::io::stderr().flush()?; + + Ok(()) + } + + fn handle_multiple_completions( + &self, + completions: &brush_core::completion::Completions, + ) -> Result<(), ShellError> { + // Display replacements. + Self::display_newline()?; + for candidate in &completions.candidates { + let formatted = format_completion_candidate(candidate.as_str(), &completions.options); + eprintln!("{formatted}"); + } + std::io::stderr().flush()?; + + // Re-display prompt. + self.display_prompt()?; + + // Re-display line so far. + eprint!( + "{}{}", + self.line, + repeated_char_str(BACKSPACE, self.line.len() - self.cursor) + ); + + std::io::stderr().flush()?; + + Ok(()) + } +} + +#[allow(clippy::string_slice)] +fn format_completion_candidate( + mut candidate: &str, + options: &brush_core::completion::ProcessingOptions, +) -> String { + if options.treat_as_filenames { + let trimmed = brush_core::sys::fs::strip_path_separator_suffix(candidate); + if let Some(index) = brush_core::sys::fs::rfind_path_separator(trimmed) { + candidate = &candidate[index + 1..]; + } + } + + candidate.to_string() +} + +fn repeated_char_str(c: char, count: usize) -> String { + (0..count).map(|_| c).collect() +} diff --git a/local/recipes/shells/brush/source/brush-interactive/src/completion.rs b/local/recipes/shells/brush/source/brush-interactive/src/completion.rs new file mode 100644 index 0000000000..2e6d1d8c42 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-interactive/src/completion.rs @@ -0,0 +1,123 @@ +use std::path::{Path, PathBuf}; + +use brush_core::escape; + +#[allow(dead_code)] +pub(crate) async fn complete_async( + shell: &mut brush_core::Shell, + line: &str, + pos: usize, +) -> brush_core::completion::Completions { + let working_dir = shell.working_dir().to_path_buf(); + + // Intentionally ignore any errors that arise. + let completion_future = shell.complete(line, pos); + tokio::pin!(completion_future); + + // Wait for the completions to come back or interruption, whichever happens first. + let result = tokio::select! { + result = &mut completion_future => { + result + } + _ = tokio::signal::ctrl_c() => { + Err(brush_core::ErrorKind::Interrupted.into()) + }, + }; + + let mut completions = result.unwrap_or_else(|_| brush_core::completion::Completions { + insertion_index: pos, + delete_count: 0, + candidates: Vec::new(), + options: brush_core::completion::ProcessingOptions::default(), + }); + + // Look at the line up to 'pos' to check if we're in an unterminated + // single or double quote string. + let mut quote_char: Option = None; + let mut escaped = false; + for (i, c) in line.char_indices() { + if i >= pos { + break; + } + + if escaped { + escaped = false; + continue; + } + + if let Some(q) = quote_char { + if c == q { + quote_char = None; + } + } else if c == '\\' { + escaped = true; + } else if c == '\'' || c == '\"' { + quote_char = Some(c); + } + } + + let completing_end_of_line = pos == line.len(); + + // Deduplicate the candidates (retaining order), then postprocess them. + completions.candidates = completions + .candidates + .into_iter() + .collect::>() + .into_iter() + .map(|candidate| { + postprocess_completion_candidate( + candidate, + &completions.options, + working_dir.as_ref(), + completing_end_of_line, + quote_char, + ) + }) + .collect(); + + completions +} + +#[allow(dead_code)] +fn postprocess_completion_candidate( + mut candidate: String, + options: &brush_core::completion::ProcessingOptions, + working_dir: &Path, + completing_end_of_line: bool, + quote_char: Option, +) -> String { + if options.treat_as_filenames { + // Check if it's a directory. + if !brush_core::sys::fs::ends_with_path_separator(&candidate) { + let candidate_path = Path::new(&candidate); + let abs_candidate_path = if candidate_path.is_absolute() { + PathBuf::from(candidate_path) + } else { + working_dir.join(candidate_path) + }; + + if abs_candidate_path.is_dir() { + // Use forward slash: backslash is the shell escape character. + candidate.push('/'); + } + } + + if !options.no_autoquote_filenames { + let quote_mode = match quote_char { + Some('\'') => escape::QuoteMode::SingleQuote, + Some('\"') => escape::QuoteMode::DoubleQuote, + _ => escape::QuoteMode::BackslashEscape, + }; + + candidate = escape::quote_if_needed(&candidate, quote_mode).to_string(); + } + } + if completing_end_of_line && !options.no_trailing_space_at_end_of_line { + if !options.treat_as_filenames || !brush_core::sys::fs::ends_with_path_separator(&candidate) + { + candidate.push(' '); + } + } + + candidate +} diff --git a/local/recipes/shells/brush/source/brush-interactive/src/error.rs b/local/recipes/shells/brush/source/brush-interactive/src/error.rs new file mode 100644 index 0000000000..137f0a1f2b --- /dev/null +++ b/local/recipes/shells/brush/source/brush-interactive/src/error.rs @@ -0,0 +1,29 @@ +use std::path::PathBuf; + +/// Represents an error encountered while running or otherwise managing an interactive shell. +#[derive(thiserror::Error, Debug)] +pub enum ShellError { + /// An error occurred with the embedded shell. + #[error("{0}")] + ShellError(#[from] brush_core::Error), + + /// A generic I/O error occurred. + #[error("I/O error: {0}")] + IoError(#[from] std::io::Error), + + /// Failed to create xtrace file. + #[error("failed to create xtrace file '{0}': {1}")] + FailedToCreateXtraceFile(PathBuf, std::io::Error), + + /// An error occurred while reading input. + #[error("input error occurred: {0}")] + InputError(std::io::Error), + + /// The requested input backend type is not supported. + #[error("requested input backend type not supported")] + InputBackendNotSupported, + + /// An unexpected error occurred while reading input. + #[error("unexpected error occurred reading input")] + UnexpectedInputFailure, +} diff --git a/local/recipes/shells/brush/source/brush-interactive/src/highlighting.rs b/local/recipes/shells/brush/source/brush-interactive/src/highlighting.rs new file mode 100644 index 0000000000..cb23a45c3f --- /dev/null +++ b/local/recipes/shells/brush/source/brush-interactive/src/highlighting.rs @@ -0,0 +1,597 @@ +//! Generic syntax highlighting for shell commands. +//! +//! This module provides semantic tagging of shell command strings without +//! imposing any specific styling. Consumers can map the semantic categories +//! to their own color schemes or styles. + +/// Semantic category for a highlighted span. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HighlightKind { + /// Default text + Default, + /// Comment text + Comment, + /// Arithmetic expression + Arithmetic, + /// Parameter expansion (variables, etc.) + Parameter, + /// Command substitution + CommandSubstitution, + /// Quoted text + Quoted, + /// Operator (|, &&, etc.) + Operator, + /// Variable assignment + Assignment, + /// Hyphen-prefixed option + HyphenOption, + /// Function definition + Function, + /// Shell keyword + Keyword, + /// Builtin command + Builtin, + /// Alias + Alias, + /// External command (found in PATH) + ExternalCommand, + /// Command not found + NotFoundCommand, + /// Unknown command (cursor still in token) + UnknownCommand, +} + +/// A highlighted span of text with semantic meaning. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HighlightSpan { + /// Byte range of this span within the input string. + pub range: std::ops::Range, + /// Semantic category of this span. + pub kind: HighlightKind, +} + +impl HighlightSpan { + /// Creates a new highlight span over the given byte range. + #[must_use] + pub const fn new(range: std::ops::Range, kind: HighlightKind) -> Self { + Self { range, kind } + } +} + +/// The result of highlighting a line: the spans plus the line they index into. +/// +/// Pairing the two means span text is resolved against the original input +/// rather than a separately-supplied (and possibly mismatched) string. +pub struct Highlighted<'a> { + line: &'a str, + spans: Vec, +} + +impl<'a> Highlighted<'a> { + /// The line that was highlighted. + #[must_use] + pub const fn line(&self) -> &'a str { + self.line + } + + /// The spans, in order, covering the entire line. + #[must_use] + pub fn spans(&self) -> &[HighlightSpan] { + &self.spans + } + + /// Returns the text of `span` within the highlighted line. + /// + /// `span` is expected to be one of [`Self::spans`]; a range that is out of + /// bounds or off a UTF-8 char boundary (debug-asserted) yields `""` rather + /// than panicking. + #[must_use] + pub fn text(&self, span: &HighlightSpan) -> &'a str { + debug_assert!( + self.line.is_char_boundary(span.range.start), + "highlight span start {} is not a UTF-8 char boundary in {:?}", + span.range.start, + self.line, + ); + debug_assert!( + self.line.is_char_boundary(span.range.end), + "highlight span end {} is not a UTF-8 char boundary in {:?}", + span.range.end, + self.line, + ); + self.line.get(span.range.clone()).unwrap_or("") + } + + /// Iterates `(kind, text)` for each span, with text borrowed from the line. + pub fn iter(&self) -> impl Iterator + '_ { + self.spans.iter().map(|span| (span.kind, self.text(span))) + } +} + +/// Highlights a shell command string. +/// +/// # Arguments +/// * `shell` - Reference to the shell for context (aliases, functions, builtins, etc.) +/// * `line` - The command string to highlight +/// * `cursor` - Current cursor position (byte offset) +/// +/// # Returns +/// The highlighted line: spans covering the entire input, paired with the input. +#[must_use] +pub fn highlight_command<'a>( + shell: &brush_core::Shell, + line: &'a str, + cursor: usize, +) -> Highlighted<'a> { + let mut highlighter = Highlighter::new(shell, line, cursor); + highlighter.highlight_program(line, 0); + Highlighted { + line, + spans: highlighter.spans, + } +} + +enum CommandType { + Function, + Keyword, + Builtin, + Alias, + External, + NotFound, + Unknown, +} + +struct Highlighter<'a, SE: brush_core::ShellExtensions> { + shell: &'a brush_core::Shell, + /// The topmost input line; span offsets stored in `spans` index into this string. + input_line: &'a str, + cursor: usize, + spans: Vec, + current_byte_index: usize, + next_missing_kind: Option, +} + +impl<'a, SE: brush_core::ShellExtensions> Highlighter<'a, SE> { + const fn new(shell: &'a brush_core::Shell, input_line: &'a str, cursor: usize) -> Self { + Self { + shell, + input_line, + cursor, + spans: Vec::new(), + current_byte_index: 0, + next_missing_kind: None, + } + } + + fn highlight_program(&mut self, line: &str, global_offset: usize) { + if let Ok(tokens) = brush_parser::tokenize_str_with_options( + line, + &(self.shell.parser_options().tokenizer_options()), + ) { + let mut saw_command_token = false; + + // Tokenizer offsets are *character* indices into `line`; slicing needs bytes. + // `char_indices()` gives the byte offset of each char, plus a sentinel for the + // end, so a lookup is O(1) and the whole line costs one pass (not O(n²)). + let char_byte_offsets: Vec = line + .char_indices() + .map(|(byte_idx, _)| byte_idx) + .chain(std::iter::once(line.len())) + .collect(); + let byte_offset = |char_offset: usize| { + char_byte_offsets + .get(char_offset) + .copied() + .unwrap_or(line.len()) + }; + + for token in tokens { + match token { + brush_parser::Token::Operator(_op, token_location) => { + let start = global_offset + byte_offset(token_location.start.index); + let end = global_offset + byte_offset(token_location.end.index); + self.append_span(HighlightKind::Operator, start..end); + } + brush_parser::Token::Word(w, token_location) => { + let start_byte = byte_offset(token_location.start.index); + let end_byte = byte_offset(token_location.end.index); + + // Parse the raw slice from `line`, not `w.as_str()`: the tokenizer may + // drop chars from `w` (e.g. `\` continuations), so offsets into + // `w` no longer map onto `line`; offsets into the raw slice do. + let raw_word_text = line.get(start_byte..end_byte).unwrap_or(""); + if let Ok(word_pieces) = + brush_parser::word::parse(raw_word_text, &self.shell.parser_options()) + { + let token_range = + (global_offset + start_byte)..(global_offset + end_byte); + + // Classify against the tokenized form `w` (the logical word) so + // command lookups ignore mid-word line continuations. + let default_text_kind = self.get_kind_for_word( + w.as_str(), + &token_range, + &mut saw_command_token, + ); + + for word_piece in word_pieces { + self.highlight_word_piece( + word_piece, + default_text_kind, + token_range.start, + ); + } + } + } + } + } + + self.skip_ahead(global_offset + line.len()); + } else { + self.append_span( + HighlightKind::Default, + global_offset..global_offset + line.len(), + ); + } + } + + fn highlight_word_piece( + &mut self, + word_piece: brush_parser::word::WordPieceWithSource, + default_text_kind: HighlightKind, + global_offset: usize, + ) { + let piece = + (global_offset + word_piece.start_index)..(global_offset + word_piece.end_index); + self.skip_ahead(piece.start); + + match word_piece.piece { + brush_parser::word::WordPiece::SingleQuotedText(_) + | brush_parser::word::WordPiece::AnsiCQuotedText(_) + | brush_parser::word::WordPiece::EscapeSequence(_) => { + self.append_span(HighlightKind::Quoted, piece.clone()); + } + brush_parser::word::WordPiece::DoubleQuotedSequence(subpieces) + | brush_parser::word::WordPiece::GettextDoubleQuotedSequence(subpieces) => { + self.set_next_missing_kind(HighlightKind::Quoted); + for subpiece in subpieces { + self.highlight_word_piece(subpiece, HighlightKind::Quoted, global_offset); + } + self.set_next_missing_kind(HighlightKind::Quoted); + } + brush_parser::word::WordPiece::ParameterExpansion(_) + | brush_parser::word::WordPiece::TildeExpansion(_) => { + self.append_span(HighlightKind::Parameter, piece.clone()); + } + brush_parser::word::WordPiece::BackquotedCommandSubstitution(command) => { + self.set_next_missing_kind(HighlightKind::CommandSubstitution); + self.highlight_program( + command.as_str(), + piece.start + 1, /* opening backtick */ + ); + self.set_next_missing_kind(HighlightKind::CommandSubstitution); + } + brush_parser::word::WordPiece::CommandSubstitution(command) => { + self.set_next_missing_kind(HighlightKind::CommandSubstitution); + self.highlight_program(command.as_str(), piece.start + 2 /* opening $( */); + self.set_next_missing_kind(HighlightKind::CommandSubstitution); + } + brush_parser::word::WordPiece::ArithmeticExpression(_) => { + // TODO(highlighting): Consider individually highlighting pieces of the expression + // itself. + self.append_span(HighlightKind::Arithmetic, piece.clone()); + } + brush_parser::word::WordPiece::Text(_text) => { + self.append_span(default_text_kind, piece.clone()); + } + } + + self.skip_ahead(piece.end); + } + + fn append_span(&mut self, kind: HighlightKind, range: std::ops::Range) { + debug_assert!( + self.input_line.is_char_boundary(range.start), + "span start {} is not a UTF-8 char boundary in {:?}", + range.start, + self.input_line, + ); + debug_assert!( + self.input_line.is_char_boundary(range.end), + "span end {} is not a UTF-8 char boundary in {:?}", + range.end, + self.input_line, + ); + + // See if we need to cover a gap between this substring and the one that preceded it. + if range.start > self.current_byte_index { + let missing_kind = self.next_missing_kind.unwrap_or(HighlightKind::Comment); + self.spans.push(HighlightSpan::new( + self.current_byte_index..range.start, + missing_kind, + )); + self.current_byte_index = range.start; + } + + let end = range.end; + if !range.is_empty() { + self.spans.push(HighlightSpan::new(range, kind)); + } + + self.current_byte_index = end; + } + + fn skip_ahead(&mut self, dest: usize) { + // Append a no-op span to make sure we cover any trailing gaps in the input line not + // otherwise styled. + self.append_span(HighlightKind::Default, dest..dest); + } + + const fn set_next_missing_kind(&mut self, kind: HighlightKind) { + self.next_missing_kind = Some(kind); + } + + fn get_kind_for_word( + &self, + w: &str, + token_range: &std::ops::Range, + saw_command_token: &mut bool, + ) -> HighlightKind { + if !*saw_command_token { + if w.contains('=') { + HighlightKind::Assignment + } else { + *saw_command_token = true; + match self.classify_possible_command(w, token_range) { + CommandType::Function => HighlightKind::Function, + CommandType::Keyword => HighlightKind::Keyword, + CommandType::Builtin => HighlightKind::Builtin, + CommandType::Alias => HighlightKind::Alias, + CommandType::External => HighlightKind::ExternalCommand, + CommandType::NotFound => HighlightKind::NotFoundCommand, + CommandType::Unknown => HighlightKind::UnknownCommand, + } + } + } else { + if self.shell.is_keyword(w) { + HighlightKind::Keyword + } else if w.starts_with('-') { + HighlightKind::HyphenOption + } else { + HighlightKind::Default + } + } + } + + fn classify_possible_command( + &self, + name: &str, + token_range: &std::ops::Range, + ) -> CommandType { + if self.shell.is_keyword(name) { + return CommandType::Keyword; + } else if self.shell.aliases().contains_key(name) { + return CommandType::Alias; + } else if self.shell.funcs().get(name).is_some() { + return CommandType::Function; + } else if self.shell.builtins().contains_key(name) { + return CommandType::Builtin; + } + + // Short-circuit if the cursor is still in this token (inclusive of its end, so a + // command still being typed isn't prematurely flagged as not-found). + if self.cursor >= token_range.start && self.cursor <= token_range.end { + return CommandType::Unknown; + } + + if brush_core::sys::fs::contains_path_separator(name) { + // TODO(highlighting): Should check for executable-ness. + let candidate_path = self.shell.absolute_path(std::path::Path::new(name)); + if candidate_path.exists() { + CommandType::External + } else { + CommandType::NotFound + } + } else { + if self.shell.find_first_executable_in_path(name).is_some() { + CommandType::External + } else { + CommandType::NotFound + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_highlight_simple_command() { + let shell = brush_core::Shell::builder().build().await.unwrap(); + let line = "somecommand hello"; + // Use cursor position at the end so we get final highlighting + let highlighted = highlight_command(&shell, line, line.len()); + + // Should have at least 2 spans + assert!(!highlighted.spans().is_empty()); + + // Verify highlighting produces spans that cover the input + let total_covered: usize = highlighted.spans().iter().map(|s| s.range.len()).sum(); + assert_eq!(total_covered, line.len(), "Spans should cover entire input"); + + // The command should be classified as something (NotFound, External, etc.) + let cmd_span = highlighted + .spans() + .iter() + .find(|s| highlighted.text(s) == "somecommand"); + assert!(cmd_span.is_some(), "Should have a span for the command"); + } + + #[tokio::test] + async fn test_highlight_quoted_string() { + let shell = brush_core::Shell::builder().build().await.unwrap(); + let line = r#"echo "hello world""#; + let highlighted = highlight_command(&shell, line, 0); + + // Should have spans for: echo, space, "hello world" + assert!(!highlighted.spans().is_empty()); + + // Check that quoted parts are marked as Quoted + assert!( + highlighted + .spans() + .iter() + .any(|s| s.kind == HighlightKind::Quoted) + ); + } + + #[tokio::test] + async fn test_highlight_parameter_expansion() { + let shell = brush_core::Shell::builder().build().await.unwrap(); + let line = "echo $HOME"; + let highlighted = highlight_command(&shell, line, 0); + + // Should have spans including a parameter expansion + assert!( + highlighted + .spans() + .iter() + .any(|s| s.kind == HighlightKind::Parameter) + ); + } + + #[tokio::test] + async fn test_highlight_covers_entire_input() { + let shell = brush_core::Shell::builder().build().await.unwrap(); + let line = "echo hello world"; + let highlighted = highlight_command(&shell, line, 0); + + // Verify that spans cover the entire input (no gaps) + let mut covered = vec![false; line.len()]; + for span in highlighted.spans() { + for item in covered + .iter_mut() + .take(span.range.end) + .skip(span.range.start) + { + *item = true; + } + } + + assert!(covered.iter().all(|&c| c), "Not all characters are covered"); + } + + /// Asserts the invariants every highlighter output must satisfy (mirrors + /// `fuzz/fuzz_targets/fuzz_highlight.rs`). + fn assert_spans_are_valid(highlighted: &Highlighted<'_>) { + let line = highlighted.line(); + + // 1. Each span is in-range and lands on UTF-8 char boundaries. + for span in highlighted.spans() { + assert!( + span.range.start <= span.range.end, + "span has start > end: {span:?} (line={line:?})", + ); + assert!( + span.range.end <= line.len(), + "span end exceeds line length: {span:?} (line.len()={})", + line.len(), + ); + assert!( + line.is_char_boundary(span.range.start), + "span start not on char boundary: {span:?} (line={line:?}, bytes={:?})", + line.as_bytes(), + ); + assert!( + line.is_char_boundary(span.range.end), + "span end not on char boundary: {span:?} (line={line:?}, bytes={:?})", + line.as_bytes(), + ); + } + + // 2. Spans are ordered and contiguous, covering the entire input. + let mut next_expected_start = 0usize; + for span in highlighted.spans() { + assert_eq!( + span.range.start, next_expected_start, + "spans are not contiguous: {span:?} (expected start={next_expected_start}, line={line:?})", + ); + next_expected_start = span.range.end; + } + assert_eq!( + next_expected_start, + line.len(), + "spans do not cover entire input (covered {next_expected_start} of {}, line={line:?})", + line.len(), + ); + + // 3. Resolving each span's text must not panic. + for (_, _) in highlighted.iter() {} + } + + #[tokio::test] + async fn test_highlight_multibyte_chars_in_word_does_not_panic() { + // Regression: a multibyte word followed by another token used to panic from + // mixing char indices (tokenizer) with byte indices (word parser). + let shell = brush_core::Shell::builder().build().await.unwrap(); + let line = ": 爸爸 /"; + let highlighted = highlight_command(&shell, line, line.len()); + + assert_spans_are_valid(&highlighted); + } + + #[tokio::test] + async fn test_highlight_multibyte_chars_partial_input_does_not_panic() { + // Simulate intermediate keystrokes while a user is typing the line. + let shell = brush_core::Shell::builder().build().await.unwrap(); + let full = ": 爸爸 /"; + // Every char-boundary prefix must highlight without panicking. + for (boundary, _) in full + .char_indices() + .chain(std::iter::once((full.len(), ' '))) + { + // `boundary` is sourced from char_indices(), so slicing is on a char boundary. + #[allow(clippy::string_slice)] + let line = &full[..boundary]; + let highlighted = highlight_command(&shell, line, line.len()); + assert_spans_are_valid(&highlighted); + } + } + + #[tokio::test] + async fn test_highlight_multibyte_in_various_positions() { + let shell = brush_core::Shell::builder().build().await.unwrap(); + let cases = [ + "爸", + "爸 x", + "x 爸", + "echo 爸爸", + "爸爸=value", + "\"爸爸\" /", + "$爸", + "$(爸爸) /", + "`爸爸` /", + "# 爸爸 comment", + ]; + for line in cases { + let highlighted = highlight_command(&shell, line, line.len()); + assert_spans_are_valid(&highlighted); + } + } + + #[tokio::test] + async fn test_highlight_issue_1128_multibyte_then_paren() { + // Regression for #1128: a 2-byte char immediately followed by `(` panicked + // because the operator's char index was sliced as a byte offset, landing + // mid-character. Exercise the reported chars, including the keystroke-by- + // keystroke sequence (the char alone, then the char + `(`). + let shell = brush_core::Shell::builder().build().await.unwrap(); + for prefix in ["£", "€", "é", "½", "§", "²", "ï", "¤", "…"] { + for line in [prefix.to_string(), format!("{prefix}(")] { + let highlighted = highlight_command(&shell, &line, line.len()); + assert_spans_are_valid(&highlighted); + } + } + } +} diff --git a/local/recipes/shells/brush/source/brush-interactive/src/input_backend.rs b/local/recipes/shells/brush/source/brush-interactive/src/input_backend.rs new file mode 100644 index 0000000000..efe2c625f6 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-interactive/src/input_backend.rs @@ -0,0 +1,51 @@ +use crate::ShellError; + +/// Represents an input backend for reading lines of input. +pub trait InputBackend: Send { + /// Reads a line of input, using the given prompt. + /// + /// # Arguments + /// + /// * `shell` - The shell instance for which input is being read. + /// * `prompt` - The prompt to display to the user. + fn read_line( + &mut self, + shell: &crate::ShellRef, + prompt: InteractivePrompt, + ) -> Result; + + /// Returns the current contents of the read buffer and the current cursor + /// position within the buffer; None is returned if the read buffer is + /// empty or cannot be read by this implementation. + fn get_read_buffer(&self) -> Option<(String, usize)> { + None + } + + /// Updates the read buffer with the given string and cursor. Considered a + /// no-op if the implementation does not support updating read buffers. + fn set_read_buffer(&mut self, _buffer: String, _cursor: usize) { + // No-op by default. + } +} + +/// Result of a read operation. +pub enum ReadResult { + /// The user entered a line of input. + Input(String), + /// A bound key sequence yielded a registered command. + BoundCommand(String), + /// End of input was reached. + Eof, + /// The user interrupted the input operation. + Interrupted, +} + +/// Represents an interactive prompt. +pub struct InteractivePrompt { + /// Prompt to display. + pub prompt: String, + /// Alternate-side prompt (typically right) to display. + pub alt_side_prompt: String, + /// Prompt to display on a continuation line of input. + pub continuation_prompt: String, +} diff --git a/local/recipes/shells/brush/source/brush-interactive/src/interactive_shell.rs b/local/recipes/shells/brush/source/brush-interactive/src/interactive_shell.rs new file mode 100644 index 0000000000..a38bf5b102 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-interactive/src/interactive_shell.rs @@ -0,0 +1,463 @@ +use std::io::IsTerminal as _; +use std::io::Write as _; + +use crate::InputBackend; +use crate::InteractivePrompt; +use crate::ReadResult; +use crate::ShellError; + +/// Result of an interactive execution. +pub enum InteractiveExecutionResult { + /// The command was executed and returned the given result. + Executed(brush_core::ExecutionResult), + /// The command failed to execute. + Failed(brush_core::Error), + /// End of input was reached. + Eof, +} + +impl From<&InteractiveExecutionResult> for i32 { + /// Converts an `InteractiveExecutionResult` into a signed, 32-bit exit code. + fn from(value: &InteractiveExecutionResult) -> Self { + match value { + InteractiveExecutionResult::Executed(result) => u8::from(result.exit_code).into(), + InteractiveExecutionResult::Failed(_) => 1, + InteractiveExecutionResult::Eof => 0, + } + } +} + +/// Options for interactive shells. +#[derive(Clone)] +pub struct InteractiveOptions { + /// Whether terminal shell integration is enabled. + pub terminal_shell_integration: bool, + /// Whether or not to run `PROMPT_COMMAND` before each prompt. + pub run_prompt_command: bool, + /// Whether or not to run zsh-style exec/cmd functions (e.g., `preexec_functions`, + /// `precmd_functions`). + pub run_cmd_exec_funcs: bool, +} + +impl Default for InteractiveOptions { + fn default() -> Self { + Self { + terminal_shell_integration: false, + run_prompt_command: true, + run_cmd_exec_funcs: false, + } + } +} + +/// Represents an interactive shell that displays prompts, interactively reads user input, etc. +pub struct InteractiveShell<'a, IB: InputBackend, SE: brush_core::ShellExtensions> { + /// The underlying shell instance. + shell: crate::ShellRef, + /// The input backend to use. + input: &'a mut IB, + /// Terminal integration utility, if any. + terminal_integration: Option, + /// Terminal-control guard, held for the lifetime of the interactive shell. + _terminal_control: Option, + /// Options. + options: InteractiveOptions, +} + +impl<'a, IB: InputBackend, SE: brush_core::ShellExtensions> InteractiveShell<'a, IB, SE> { + /// Creates a new `InteractiveShell` wrapping the given shell instance. + /// + /// # Arguments + /// + /// * `shell` - The shell instance to wrap. + /// * `input` - The input backend to use. + /// * `options` - The user interface options to use. + pub fn new( + shell: &crate::ShellRef, + input: &'a mut IB, + options: &InteractiveOptions, + ) -> Result { + let stdin_is_terminal = std::io::stdin().is_terminal(); + + // Acquire terminal control if stdin is a terminal. + let terminal_control = if stdin_is_terminal { + Some(brush_core::terminal::TerminalControl::acquire()?) + } else { + None + }; + + // Set up terminal integration if enabled *and* if stdin is a terminal. + let terminal_integration = if options.terminal_shell_integration && stdin_is_terminal { + let terminfo = crate::term_detection::get_terminal_info(&HostEnvironment); + let terminal_integration = crate::term_integration::TerminalIntegration::new(terminfo); + + print!("{}", terminal_integration.initialize().as_ref()); + std::io::stdout().flush()?; + + Some(terminal_integration) + } else { + None + }; + + Ok(Self { + shell: shell.clone(), + input, + terminal_integration, + _terminal_control: terminal_control, + options: options.clone(), + }) + } + + /// Runs the interactive shell loop, reading commands from standard input and writing + /// results to standard output and standard error. Continues until the shell + /// normally exits or until a fatal error occurs. + pub async fn run_interactively(&mut self) -> Result<(), ShellError> { + let mut shell = self.shell.lock().await; + + let mut announce_exit = shell.options().interactive; + + shell.start_interactive_session()?; + + drop(shell); + + loop { + let result = self.run_interactively_once().await?; + match result { + InteractiveExecutionResult::Executed(brush_core::ExecutionResult { + next_control_flow: brush_core::results::ExecutionControlFlow::ExitShell, + .. + }) => { + break; + } + InteractiveExecutionResult::Executed(brush_core::ExecutionResult { + next_control_flow: + brush_core::results::ExecutionControlFlow::ReturnFromFunctionOrScript, + .. + }) => { + tracing::error!("return from non-function/script"); + } + InteractiveExecutionResult::Executed(_) => {} + InteractiveExecutionResult::Failed(err) => { + // Report the error, but continue to execute. + let shell = self.shell.lock().await; + let mut stderr = shell.stderr(); + let _ = shell.display_error(&mut stderr, &err); + + drop(shell); + } + InteractiveExecutionResult::Eof => { + break; + } + } + + if self.shell.lock().await.options().exit_after_one_command { + announce_exit = false; + break; + } + } + + let mut shell = self.shell.lock().await; + + shell.end_interactive_session()?; + + if announce_exit { + writeln!(shell.stderr(), "exit")?; + } + + if let Err(e) = shell.save_history() { + // N.B. This seems like the sort of thing that's worth being noisy about, + // but bash doesn't do that -- and probably for a reason. + tracing::debug!("couldn't save history: {e}"); + } + + // Give the shell an opportunity to perform any on-exit operations. + shell.on_exit().await?; + + drop(shell); + + Ok(()) + } + + /// Runs the interactive shell loop once, reading a single command from standard input. + async fn run_interactively_once(&mut self) -> Result { + let mut shell = self.shell.lock().await; + + // Run any pre-prompt actions. + Self::run_pre_prompt_actions(&mut shell, &self.options).await?; + + // Compose the prompt. + let prompt = Self::compose_prompt(&mut shell, self.terminal_integration.as_ref()).await?; + + drop(shell); + + // Read input. + match self.input.read_line(&self.shell, prompt)? { + ReadResult::Input(read_result) => { + // We got a line of input -- execute it. + self.execute_line(read_result, true /* user input */).await + } + ReadResult::BoundCommand(read_result) => { + // We got a line that was bound to keybindings; execute it. + self.execute_line(read_result, false /* user input */).await + } + ReadResult::Eof => { + // We're done! + Ok(InteractiveExecutionResult::Eof) + } + ReadResult::Interrupted => { + // We were interrupted; report that appropriately. + let result: brush_core::ExecutionResult = + brush_core::ExecutionExitCode::Interrupted.into(); + self.shell + .lock() + .await + .set_last_exit_status(result.exit_code.into()); + Ok(InteractiveExecutionResult::Executed(result)) + } + } + } + + async fn compose_prompt( + shell: &mut brush_core::Shell, + terminal_integration: Option<&crate::term_integration::TerminalIntegration>, + ) -> Result { + // Now that we've done that, compose the prompt. + let mut prompt = InteractivePrompt { + prompt: shell.compose_prompt().await?, + alt_side_prompt: shell.compose_alt_side_prompt().await?, + continuation_prompt: shell.compose_continuation_prompt().await?, + }; + + if let Some(terminal_integration) = terminal_integration { + let pre_prompt = terminal_integration.pre_prompt(); + let working_dir = terminal_integration.report_cwd(shell.working_dir()); + let post_prompt = terminal_integration.post_prompt(); + + prompt.prompt = [ + pre_prompt.as_ref(), + working_dir.as_ref(), + prompt.prompt.as_str(), + post_prompt.as_ref(), + ] + .concat(); + } + + Ok(prompt) + } + + /// Executes the given line of input. + /// + /// # Arguments + /// + /// * `read_result` - The line of input to execute. + /// * `user_input` - Whether the line came from direct user input (as opposed to a key binding, + /// say). + async fn execute_line( + &mut self, + read_result: String, + user_input: bool, + ) -> Result { + let mut shell = self.shell.lock().await; + + // See if the the user interface has a non-empty read buffer. + let buffer_info = self.input.get_read_buffer(); + + // If the user interface did, in fact, have a non-empty read buffer, + // then reflect it to the shell in case any shell code wants to + // process and/or transform the buffer. + let nonempty_buffer = if let Some((buffer, cursor)) = buffer_info { + if !buffer.is_empty() { + shell.set_edit_buffer(buffer, cursor)?; + true + } else { + false + } + } else { + false + }; + + // If the line came from direct user input (as opposed to a key binding, say), then we + // need to do a few more things before executing it. + if user_input { + Self::run_pre_exec_actions( + &mut shell, + read_result.as_str(), + &self.options, + self.terminal_integration.as_ref(), + ) + .await?; + } + + // Count the command's lines. + let line_count = read_result.lines().count().max(1); + + // Execute the command. + let params = shell.default_exec_params(); + let source_info = brush_core::SourceInfo::from("main"); + let result = match shell.run_string(read_result, &source_info, ¶ms).await { + Ok(result) => Ok(InteractiveExecutionResult::Executed(result)), + Err(e) => Ok(InteractiveExecutionResult::Failed(e)), + }; + + // Update cumulative line counter based on actual lines in the command. + shell.increment_interactive_line_offset(line_count); + + // See if the shell has input buffer state that we need to reflect back to + // the user interface. It may be state that originally came from the user + // interface, or it may be state that was programmatically generated by + // the command we just executed. + let mut buffer_and_cursor = shell.pop_edit_buffer()?; + + drop(shell); + + if buffer_and_cursor.is_none() && nonempty_buffer { + buffer_and_cursor = Some((String::new(), 0)); + } + + if let Some((updated_buffer, updated_cursor)) = buffer_and_cursor { + self.input.set_read_buffer(updated_buffer, updated_cursor); + } + + // Invoke terminal integration. + if let Some(terminal_integration) = &self.terminal_integration { + let exit_code = result.as_ref().map_or(1, i32::from); + print!( + "{}", + terminal_integration.post_exec_command(exit_code).as_ref() + ); + std::io::stdout().flush()?; + } + + result + } + + async fn run_pre_prompt_actions( + shell: &mut brush_core::Shell, + options: &InteractiveOptions, + ) -> Result<(), ShellError> { + // Check for any completed jobs. + shell.check_for_completed_jobs()?; + + // If there's a variable called PROMPT_COMMAND, then run it first. + if options.run_prompt_command { + if let Some(prompt_cmd_var) = shell.env_var("PROMPT_COMMAND") { + match prompt_cmd_var.value() { + brush_core::ShellValue::String(cmd_str) => { + Self::run_pre_prompt_command(shell, cmd_str.to_owned()).await?; + } + brush_core::ShellValue::IndexedArray(values) => { + let owned_values: Vec<_> = values.values().cloned().collect(); + for cmd_str in owned_values { + Self::run_pre_prompt_command(shell, cmd_str).await?; + } + } + // Other types are ignored. + _ => (), + } + } + } + + // Next, run any zsh-style `precmd_functions`. + // TODO(precmd_functions): verify if we need to save/restore exit results. + if options.run_cmd_exec_funcs { + // If there's a variable called precmd_functions, then call them. + if let Some(brush_core::ShellValue::IndexedArray(precmd_funcs)) = shell + .env_var("precmd_functions") + .map(|var| var.value()) + .cloned() + { + for func_name in precmd_funcs.values() { + let _ = shell + .invoke_function( + func_name, + std::iter::empty::<&str>(), + shell.default_exec_params(), + ) + .await; + } + } + } + + Ok(()) + } + + async fn run_pre_exec_actions( + shell: &mut brush_core::Shell, + command_line: &str, + options: &InteractiveOptions, + terminal_integration: Option<&crate::term_integration::TerminalIntegration>, + ) -> Result<(), ShellError> { + // Display the pre-command prompt on stderr (if there is one). + let precmd_prompt = shell.compose_precmd_prompt().await?; + if !precmd_prompt.is_empty() { + eprint!("{precmd_prompt}"); + std::io::stderr().flush()?; + } + + // Update history (if applicable). + shell.add_to_history(command_line.trim_end_matches('\n'))?; + + // Next, run any zsh-style `preexec_functions`. + // TODO(preexec_functions): verify if we need to save/restore exit results. + if options.run_cmd_exec_funcs { + // If there's a variable called preexec_functions, then call them. + if let Some(brush_core::ShellValue::IndexedArray(preexec_funcs)) = shell + .env_var("preexec_functions") + .map(|var| var.value()) + .cloned() + { + for func_name in preexec_funcs.values() { + let _ = shell + .invoke_function(func_name, &[command_line], shell.default_exec_params()) + .await; + } + } + } + + // Invoke terminal integration. + if let Some(terminal_integration) = terminal_integration { + print!( + "{}", + terminal_integration.pre_exec_command(command_line).as_ref() + ); + std::io::stdout().flush()?; + } + + Ok(()) + } + + async fn run_pre_prompt_command( + shell: &mut brush_core::Shell, + prompt_cmd: String, + ) -> Result<(), ShellError> { + // Save (and later restore) the last exit status. + let prev_last_result = shell.last_exit_status(); + let prev_last_pipeline_statuses = shell.last_pipeline_statuses().to_vec(); + + // Run the command. + let params = shell.default_exec_params(); + let source_info = brush_core::SourceInfo::from("PROMPT_COMMAND"); + shell.run_string(prompt_cmd, &source_info, ¶ms).await?; + + // Restore the last exit status. + *shell.last_pipeline_statuses_mut() = prev_last_pipeline_statuses; + shell.set_last_exit_status(prev_last_result); + + Ok(()) + } +} + +/// Represents the host environment; used for terminal detection in conjunction +/// with the `TerminalEnvironment` trait. +struct HostEnvironment; + +impl crate::term_detection::TerminalEnvironment for HostEnvironment { + /// Gets the value of the given environment variable from the host process's + /// OS environment variables. Returns `None` if the variable is not set. + /// + /// # Arguments + /// + /// * `name` - The name of the environment variable to get. + fn get_env_var(&self, name: &str) -> Option { + std::env::var(name).ok() + } +} diff --git a/local/recipes/shells/brush/source/brush-interactive/src/lib.rs b/local/recipes/shells/brush/source/brush-interactive/src/lib.rs new file mode 100644 index 0000000000..58432809e5 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-interactive/src/lib.rs @@ -0,0 +1,44 @@ +//! Library implementing interactive command input and completion for the brush shell. + +mod error; +pub use error::ShellError; + +mod interactive_shell; +pub use interactive_shell::{InteractiveExecutionResult, InteractiveOptions, InteractiveShell}; + +mod input_backend; +pub use input_backend::{InputBackend, InteractivePrompt, ReadResult}; + +mod options; +pub use options::UIOptions; + +mod refs; +pub use refs::ShellRef; + +mod term_detection; +mod term_integration; +mod trace_categories; + +#[cfg(feature = "highlighting")] +pub mod highlighting; + +#[cfg(feature = "completion")] +mod completion; + +// Reedline-based shell +#[cfg(feature = "reedline")] +mod reedline; +#[cfg(feature = "reedline")] +pub use reedline::ReedlineInputBackend; + +// Basic shell +#[cfg(feature = "basic")] +mod basic; +#[cfg(feature = "basic")] +pub use basic::BasicInputBackend; + +// Minimal shell +#[cfg(feature = "minimal")] +mod minimal; +#[cfg(feature = "minimal")] +pub use minimal::MinimalInputBackend; diff --git a/local/recipes/shells/brush/source/brush-interactive/src/minimal/input_backend.rs b/local/recipes/shells/brush/source/brush-interactive/src/minimal/input_backend.rs new file mode 100644 index 0000000000..24d6ceda18 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-interactive/src/minimal/input_backend.rs @@ -0,0 +1,105 @@ +use std::io::{IsTerminal, Write}; + +use crate::{ + InputBackend, ShellError, + input_backend::{InteractivePrompt, ReadResult}, +}; + +/// Represents a minimal shell input backend, capable of taking commands from standard input. +#[derive(Default)] +pub struct MinimalInputBackend; + +impl InputBackend for MinimalInputBackend { + fn read_line( + &mut self, + _shell_ref: &crate::ShellRef, + prompt: InteractivePrompt, + ) -> Result { + self.display_prompt(&prompt)?; + + let result = match Self::read_input_line()? { + ReadResult::Input(s) => s, + ReadResult::BoundCommand(s) => s, + ReadResult::Eof => return Ok(ReadResult::Eof), + ReadResult::Interrupted => return Ok(ReadResult::Interrupted), + }; + + if result.is_empty() { + Ok(ReadResult::Eof) + } else { + Ok(ReadResult::Input(result)) + } + } +} + +impl MinimalInputBackend { + #[expect(clippy::unused_self)] + fn should_display_prompt(&self) -> bool { + std::io::stdin().is_terminal() + } + + fn display_prompt(&self, prompt: &InteractivePrompt) -> Result<(), ShellError> { + if self.should_display_prompt() { + eprint!("{}", prompt.prompt); + std::io::stderr().flush()?; + } + + Ok(()) + } + + fn read_input_line() -> Result { + // On Redox the interactive console pty often presents as non-blocking, + // so a single `read_line` returns WouldBlock (or Ok(0)) after only a + // partial line — the characters the user has typed so far. The bytes + // read before that error stay in the `read_line` buffer, so reading + // into a *fresh* String each retry silently dropped every keystroke and + // only a fully-queued line (e.g. a leftover newline) ever came through: + // the shell echoed input but never executed the command. Accumulate + // partial reads into a buffer that persists across retries, and only + // yield a line once a newline has actually arrived. + // + // The buffer is per-thread; the minimal backend reads on one thread. + use std::cell::RefCell; + use std::io::ErrorKind; + thread_local! { + static PENDING: RefCell = const { RefCell::new(String::new()) }; + } + loop { + let mut chunk = String::new(); + let res = std::io::stdin().read_line(&mut chunk); + // Preserve whatever was read before any error/would-block. + let complete = PENDING.with(|p| { + let mut p = p.borrow_mut(); + p.push_str(&chunk); + p.ends_with('\n') + }); + match res { + // Real EOF only on a non-interactive input. On an interactive + // Redox pty a non-blocking read with no data queued can also + // surface as Ok(0); retry rather than exiting (which would drop + // back to a getty login loop). Flush any partial line first. + Ok(0) if !std::io::stdin().is_terminal() => { + let line = PENDING.with(|p| std::mem::take(&mut *p.borrow_mut())); + return Ok(if line.is_empty() { + ReadResult::Eof + } else { + ReadResult::Input(line) + }); + } + // A non-blocking read with no data currently available (or an + // interrupted syscall) is not fatal on an interactive terminal; + // the partial bytes are already saved in PENDING, so keep polling. + Err(e) + if std::io::stdin().is_terminal() + && matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::Interrupted) => {} + Err(e) => return Err(ShellError::InputError(e)), + _ => {} + } + if complete { + let line = PENDING.with(|p| std::mem::take(&mut *p.borrow_mut())); + return Ok(ReadResult::Input(line)); + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + } +} diff --git a/local/recipes/shells/brush/source/brush-interactive/src/minimal/mod.rs b/local/recipes/shells/brush/source/brush-interactive/src/minimal/mod.rs new file mode 100644 index 0000000000..b8cfed28dc --- /dev/null +++ b/local/recipes/shells/brush/source/brush-interactive/src/minimal/mod.rs @@ -0,0 +1,3 @@ +mod input_backend; + +pub use input_backend::MinimalInputBackend; diff --git a/local/recipes/shells/brush/source/brush-interactive/src/options.rs b/local/recipes/shells/brush/source/brush-interactive/src/options.rs new file mode 100644 index 0000000000..e60701ab83 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-interactive/src/options.rs @@ -0,0 +1,29 @@ +/// Options for a shell user interface. +#[derive(Default, bon::Builder)] +pub struct UIOptions { + /// Whether to disable bracketed paste mode. + #[builder(default)] + pub disable_bracketed_paste: bool, + /// Whether to disable color. + #[builder(default)] + pub disable_color: bool, + /// Whether to disable syntax highlighting. + #[builder(default)] + pub disable_highlighting: bool, + /// Whether to enable terminal integration. + #[builder(default)] + pub terminal_shell_integration: bool, + /// Whether to enable zsh-style hooks. + #[builder(default)] + pub zsh_style_hooks: bool, +} + +impl From<&UIOptions> for crate::InteractiveOptions { + fn from(options: &UIOptions) -> Self { + Self { + terminal_shell_integration: options.terminal_shell_integration, + run_cmd_exec_funcs: options.zsh_style_hooks, + ..Default::default() + } + } +} diff --git a/local/recipes/shells/brush/source/brush-interactive/src/reedline/completer.rs b/local/recipes/shells/brush/source/brush-interactive/src/reedline/completer.rs new file mode 100644 index 0000000000..ff959077e3 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-interactive/src/reedline/completer.rs @@ -0,0 +1,91 @@ +use nu_ansi_term::{Color, Style}; +use std::borrow::BorrowMut; + +use crate::{completion, refs}; + +pub(crate) struct ReedlineCompleter { + pub shell: refs::ShellRef, +} + +impl reedline::Completer for ReedlineCompleter { + fn complete(&mut self, line: &str, pos: usize) -> Vec { + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(self.complete_async(line, pos)) + }) + } +} + +impl ReedlineCompleter { + async fn complete_async(&self, line: &str, pos: usize) -> Vec { + let mut shell_guard = self.shell.lock().await; + let shell = shell_guard.borrow_mut().as_mut(); + let completions = completion::complete_async(shell, line, pos).await; + + // We're done with the shell, so drop it eagerly. + drop(shell_guard); + + let insertion_index = completions.insertion_index; + let delete_count = completions.delete_count; + let options = completions.options; + + completions + .candidates + .into_iter() + .map(|candidate| { + Self::to_suggestion(line, candidate, insertion_index, delete_count, &options) + }) + .collect() + } + + #[allow( + clippy::string_slice, + reason = "all indices + counts are expected to be at char boundaries" + )] + fn to_suggestion( + line: &str, + mut candidate: String, + mut insertion_index: usize, + mut delete_count: usize, + options: &brush_core::completion::ProcessingOptions, + ) -> reedline::Suggestion { + let mut style = Style::new(); + + // Special handling for filename completions. + if options.treat_as_filenames { + if brush_core::sys::fs::ends_with_path_separator(&candidate) { + style = style.fg(Color::Green); + } + + if insertion_index + delete_count <= line.len() { + let removed = &line[insertion_index..insertion_index + delete_count]; + if let Some(last_sep_index) = brush_core::sys::fs::rfind_path_separator(removed) { + if candidate.starts_with(removed) { + candidate = candidate.split_off(last_sep_index + 1); + insertion_index += last_sep_index + 1; + delete_count -= last_sep_index + 1; + } + } + } + } + + // See if there's whitespace at the end. + let append_whitespace = candidate.ends_with(' '); + if append_whitespace { + candidate.pop(); + } + + reedline::Suggestion { + value: candidate, + description: None, + style: Some(style), + extra: None, + span: reedline::Span { + start: insertion_index, + end: insertion_index + delete_count, + }, + match_indices: None, + display_override: None, + append_whitespace, + } + } +} diff --git a/local/recipes/shells/brush/source/brush-interactive/src/reedline/edit_mode.rs b/local/recipes/shells/brush/source/brush-interactive/src/reedline/edit_mode.rs new file mode 100644 index 0000000000..28d4843264 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-interactive/src/reedline/edit_mode.rs @@ -0,0 +1,713 @@ +use brush_core::{ + interfaces::{self, InputFunction, Key, KeyAction, KeyBindings as _, KeySequence, KeyStroke}, + trace_categories, +}; +use radix_trie::Trie; +use std::{collections::HashMap, sync::Arc}; +use tokio::sync::Mutex; + +#[derive(thiserror::Error, Debug)] +pub enum KeyError { + /// Unsupported key sequence + #[error("unsupported key sequence: {0}")] + UnsupportedKeySequence(KeySequence), + + /// Unsupported key action + #[error("unsupported key action: {0}")] + UnsupportedKeyAction(KeyAction), +} + +pub(crate) struct MutableEditMode { + inner: Arc>, +} + +impl MutableEditMode { + pub fn new(bindings: reedline::Keybindings) -> Self { + Self { + inner: Arc::new(Mutex::new(UpdatableBindings::new(bindings))), + } + } + + pub fn bindings(&self) -> Arc> { + self.inner.clone() + } +} + +impl reedline::EditMode for MutableEditMode { + fn parse_event(&mut self, event: reedline::ReedlineRawEvent) -> reedline::ReedlineEvent { + let mut inner = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(self.inner.lock()) + }); + + inner.parse_event(event) + } + + fn edit_mode(&self) -> reedline::PromptEditMode { + let inner = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(self.inner.lock()) + }); + + inner.edit_mode() + } +} + +pub(crate) struct UpdatableBindings { + bindings: reedline::Keybindings, + edit_mode: Box, + /// Trie for raw byte sequences. Supports both exact lookups and prefix matching + /// during macro resolution. + raw_mappings: Trie, interfaces::KeyAction>, + /// Tracks defined macros. + macros: HashMap, +} + +impl UpdatableBindings { + pub fn new(bindings: reedline::Keybindings) -> Self { + // Clone the bindings so we can keep a copy for later updates. + let edit_mode = Self::rebuild_edit_mode(&bindings); + + Self { + bindings, + edit_mode, + raw_mappings: Trie::new(), + macros: HashMap::new(), + } + } + + pub fn update(&mut self, f: impl Fn(&mut reedline::Keybindings)) { + f(&mut self.bindings); + self.try_update_bindings_for_all_macros(); + self.edit_mode = Self::rebuild_edit_mode(&self.bindings); + } + + fn rebuild_edit_mode(bindings: &reedline::Keybindings) -> Box { + Box::new(reedline::Emacs::new(bindings.clone())) + } +} + +impl reedline::EditMode for UpdatableBindings { + fn parse_event(&mut self, event: reedline::ReedlineRawEvent) -> reedline::ReedlineEvent { + self.edit_mode.parse_event(event) + } + + fn edit_mode(&self) -> reedline::PromptEditMode { + self.edit_mode.edit_mode() + } +} + +impl interfaces::KeyBindings for UpdatableBindings { + fn get_current(&self) -> HashMap { + let mut results = HashMap::new(); + + for (key_combo, event) in self.bindings.get_keybindings() { + let action = translate_reedline_event_to_action(event); + if let Some(action) = action { + if let Some(key) = translate_reedline_keycode(key_combo.key_code) { + let mut stroke = KeyStroke::from(key); + + if key_combo.modifier.contains(reedline::KeyModifiers::CONTROL) { + stroke.control = true; + } + if key_combo.modifier.contains(reedline::KeyModifiers::ALT) { + stroke.alt = true; + } + if key_combo.modifier.contains(reedline::KeyModifiers::SHIFT) { + stroke.shift = true; + } + if key_combo.modifier.contains(reedline::KeyModifiers::HYPER) { + // TODO + } + if key_combo.modifier.contains(reedline::KeyModifiers::META) { + // TODO + } + if key_combo.modifier.contains(reedline::KeyModifiers::SUPER) { + // TODO + } + + let seq = KeySequence::from(stroke); + results.insert(seq, action); + } + } + } + + results + } + + fn get_untranslated(&self, bytes: &[u8]) -> Option<&KeyAction> { + self.raw_mappings.get(bytes) + } + + fn bind(&mut self, seq: KeySequence, action: KeyAction) -> Result<(), std::io::Error> { + self.do_bind(seq, action, true) + } + + fn try_unbind(&mut self, seq: KeySequence) -> bool { + self.try_unbind_impl(&seq, true) + } + + fn define_macro( + &mut self, + seq: KeySequence, + target: KeySequence, + ) -> Result<(), std::io::Error> { + self.macros.insert(seq, target); + self.update(|_| {}); + + Ok(()) + } + + fn get_macros(&self) -> HashMap { + self.macros.clone() + } +} + +impl UpdatableBindings { + /// Internal implementation that optionally removes from the macros map. + /// When updating bindings for macros, we don't want to remove the macro definition itself. + fn try_unbind_impl(&mut self, seq: &KeySequence, remove_from_macros: bool) -> bool { + // Optionally remove from macros. + let removed_macro = if remove_from_macros { + self.macros.remove(seq).is_some() + } else { + false + }; + + match seq { + interfaces::KeySequence::Strokes(_) => { + if let Some((modifiers, key_code)) = translate_key_sequence_to_reedline(seq) { + let found = self.bindings.find_binding(modifiers, key_code).is_some(); + + if found { + self.update(|bindings| { + let _ = bindings.remove_binding(modifiers, key_code); + }); + } + + found || removed_macro + } else { + removed_macro + } + } + interfaces::KeySequence::Bytes(bytes) => { + let flat_bytes: Vec = bytes.iter().flatten().copied().collect(); + let removed_raw = self.raw_mappings.remove(&flat_bytes).is_some(); + + removed_raw || removed_macro + } + } + } + + fn do_bind( + &mut self, + seq: KeySequence, + action: KeyAction, + rebuild_for_reedline: bool, + ) -> Result<(), std::io::Error> { + let Some(event) = translate_action_to_reedline_event(&action) else { + return Err(std::io::Error::other(KeyError::UnsupportedKeyAction( + action, + ))); + }; + + match seq { + interfaces::KeySequence::Strokes(_) => { + if let Some((modifiers, key_code)) = translate_key_sequence_to_reedline(&seq) { + if rebuild_for_reedline { + self.update(|bindings| { + bindings.add_binding(modifiers, key_code, event.clone()); + }); + } else { + self.bindings + .add_binding(modifiers, key_code, event.clone()); + } + + Ok(()) + } else { + Err(std::io::Error::other(KeyError::UnsupportedKeySequence(seq))) + } + } + interfaces::KeySequence::Bytes(ref bytes) => { + let flat_bytes: Vec = bytes.iter().flatten().copied().collect(); + self.raw_mappings.insert(flat_bytes, action); + + Ok(()) + } + } + } + + fn try_update_bindings_for_all_macros(&mut self) { + let macros = self.macros.clone(); + for (seq, target) in macros { + let _ = self.update_bindings_for_macro(seq, target); + } + } + + fn update_bindings_for_macro( + &mut self, + seq: KeySequence, + target: KeySequence, + ) -> Result<(), std::io::Error> { + match target { + // TODO(input): We acknowledge that this implementation eagerly resolves the macro + // and what it will do. Subsequent changes to other key binding might invalidate + // this. We also are *extremely* limited in what we support here. + interfaces::KeySequence::Strokes(key_strokes) => { + if key_strokes.is_empty() { + // Empty macro target - unbind any existing binding for this sequence. + self.try_unbind(seq); + } else { + return Err(std::io::Error::other( + "binding key sequence to readline macro with strokes", + )); + } + } + interfaces::KeySequence::Bytes(items) => { + // Flatten all byte sequences into one contiguous buffer for prefix matching. + let flat_bytes: Vec = items.iter().flatten().copied().collect(); + let actions = self.resolve_macro_body(&flat_bytes); + + if actions.is_empty() { + // No actions resolved - unbind any existing key binding for this sequence, + // but keep the macro definition so it shows up in `bind -s/-S`. + self.try_unbind_impl(&seq, false); + return Ok(()); + } + + // Create a single action: either the action itself (if just one), or a Sequence. + let action = if let [single] = actions.as_slice() { + single.clone() + } else { + KeyAction::Sequence(actions) + }; + + self.do_bind(seq, action, false)?; + } + } + + Ok(()) + } + + /// Resolve a macro body (byte sequence) into a sequence of actions using prefix matching. + /// Returns a flattened vector of actions (any nested Sequences are expanded). + fn resolve_macro_body(&self, bytes: &[u8]) -> Vec { + let mut actions = Vec::new(); + let mut remaining = bytes; + + while !remaining.is_empty() { + // Find the longest prefix match in the trie. + if let Some((matched_key, action)) = self.find_longest_prefix_match(remaining) { + // Flatten any nested Sequence actions. + Self::flatten_action_into(&mut actions, action.clone()); + remaining = &remaining[matched_key.len()..]; + } else { + // No match found - skip one byte and continue. + // This handles unbound byte sequences gracefully. + tracing::debug!( + target: trace_categories::INPUT, + "skipping unbound byte in macro resolution: 0x{:02x}", + remaining[0] + ); + remaining = &remaining[1..]; + } + } + + actions + } + + /// Find the longest prefix match in the trie for the given bytes. + /// Uses the trie's native `get_ancestor` method which efficiently finds + /// the longest matching prefix. + fn find_longest_prefix_match(&self, bytes: &[u8]) -> Option<(Vec, &KeyAction)> { + use radix_trie::TrieCommon; + + self.raw_mappings.get_ancestor(bytes).and_then(|subtrie| { + let key = subtrie.key()?.clone(); + let value = subtrie.value()?; + Some((key, value)) + }) + } + + /// Flatten an action into the actions vector, expanding any Sequence variants. + fn flatten_action_into(actions: &mut Vec, action: KeyAction) { + match action { + KeyAction::Sequence(inner_actions) => { + for inner in inner_actions { + Self::flatten_action_into(actions, inner); + } + } + other => actions.push(other), + } + } +} + +fn translate_key_sequence_to_reedline( + seq: &KeySequence, +) -> Option<(reedline::KeyModifiers, reedline::KeyCode)> { + let KeySequence::Strokes(strokes) = seq else { + // TODO(input): handle other kinds of key sequences + return None; + }; + + let [stroke] = &strokes.as_slice() else { + // TODO(input): handle multiple strokes + return None; + }; + + let mut modifiers = reedline::KeyModifiers::empty(); + modifiers.set(reedline::KeyModifiers::ALT, stroke.alt); + modifiers.set(reedline::KeyModifiers::CONTROL, stroke.control); + modifiers.set(reedline::KeyModifiers::SHIFT, stroke.shift); + + let key_code = match stroke.key { + Key::Character(c) => reedline::KeyCode::Char(c), + Key::Backspace => reedline::KeyCode::Backspace, + Key::Enter => reedline::KeyCode::Enter, + Key::Left => reedline::KeyCode::Left, + Key::Right => reedline::KeyCode::Right, + Key::Up => reedline::KeyCode::Up, + Key::Down => reedline::KeyCode::Down, + Key::Home => reedline::KeyCode::Home, + Key::End => reedline::KeyCode::End, + Key::PageUp => reedline::KeyCode::PageUp, + Key::PageDown => reedline::KeyCode::PageDown, + Key::Tab => reedline::KeyCode::Tab, + Key::BackTab => reedline::KeyCode::BackTab, + Key::Delete => reedline::KeyCode::Delete, + Key::Insert => reedline::KeyCode::Insert, + Key::F(n) => reedline::KeyCode::F(n), + Key::Escape => reedline::KeyCode::Esc, + }; + + Some((modifiers, key_code)) +} + +fn translate_action_to_reedline_event(action: &KeyAction) -> Option { + match action { + KeyAction::ShellCommand(cmd) => Some(reedline::ReedlineEvent::ExecuteHostCommand( + format_reedline_host_command(cmd.as_str()), + )), + KeyAction::DoInputFunction(func) => translate_input_function_to_reedline_event(func), + KeyAction::Sequence(actions) => { + // Convert each action in the sequence to a reedline event. + let events: Vec<_> = actions + .iter() + .filter_map(translate_action_to_reedline_event) + .collect(); + + if events.is_empty() { + None + } else if events.len() == 1 { + events.into_iter().next() + } else { + Some(reedline::ReedlineEvent::Multiple(events)) + } + } + } +} + +fn format_reedline_host_command(cmd: &str) -> String { + // NOTE: When this command gets returned from reedline's `read_line` function, + // we need a way to know that it didn't come from user input (e.g., so we don't + // add it to history, etc.). Since reedline doesn't provide any facilities for + // doing this, we apply a workaround of appending a special marker comment at + // the end of the command. + std::format!("{cmd} # bind-command") +} + +fn parse_reedline_host_command(cmd: &str) -> Option<&str> { + // See the implementation of `format_reedline_host_command`. We look for the marker. + cmd.strip_suffix(" # bind-command") +} + +fn translate_input_function_to_reedline_event( + func: &InputFunction, +) -> Option { + use reedline::{EditCommand, ReedlineEvent}; + + match func { + InputFunction::BackwardDeleteChar => { + Some(ReedlineEvent::Edit(vec![EditCommand::Backspace])) + } + InputFunction::BackwardKillWord => { + Some(ReedlineEvent::Edit(vec![EditCommand::CutWordLeft])) + } + InputFunction::KillLine => Some(ReedlineEvent::Edit(vec![EditCommand::KillLine])), + InputFunction::KillWholeLine => Some(ReedlineEvent::Edit(vec![EditCommand::CutFromStart])), + InputFunction::KillWord => Some(ReedlineEvent::Edit(vec![EditCommand::CutWordRight])), + InputFunction::DeleteChar => Some(ReedlineEvent::Edit(vec![EditCommand::Delete])), + InputFunction::DowncaseWord => Some(ReedlineEvent::Edit(vec![EditCommand::LowercaseWord])), + InputFunction::BackwardChar => Some(ReedlineEvent::Edit(vec![EditCommand::MoveLeft { + select: false, + }])), + InputFunction::ForwardChar => Some(ReedlineEvent::Edit(vec![EditCommand::MoveRight { + select: false, + }])), + InputFunction::EndOfLine => Some(ReedlineEvent::Edit(vec![EditCommand::MoveToLineEnd { + select: false, + }])), + InputFunction::BeginningOfLine => { + Some(ReedlineEvent::Edit(vec![EditCommand::MoveToLineStart { + select: false, + }])) + } + InputFunction::BackwardWord => Some(ReedlineEvent::Edit(vec![EditCommand::MoveWordLeft { + select: false, + }])), + InputFunction::ForwardWord => Some(ReedlineEvent::Edit(vec![EditCommand::MoveWordRight { + select: false, + }])), + InputFunction::Yank => Some(ReedlineEvent::Edit(vec![EditCommand::PasteCutBufferAfter])), + InputFunction::ViRedo => Some(ReedlineEvent::Edit(vec![EditCommand::Redo])), + InputFunction::TransposeChars => { + Some(ReedlineEvent::Edit(vec![EditCommand::SwapGraphemes])) + } + InputFunction::UpcaseWord => Some(ReedlineEvent::Edit(vec![EditCommand::UppercaseWord])), + InputFunction::Undo => Some(ReedlineEvent::Edit(vec![EditCommand::Undo])), + InputFunction::ClearScreen => Some(ReedlineEvent::ClearScreen), + InputFunction::AcceptLine => Some(ReedlineEvent::Enter), + InputFunction::HistorySearchBackward => Some(ReedlineEvent::SearchHistory), + InputFunction::RedrawCurrentLine => Some(ReedlineEvent::Repaint), + InputFunction::Complete => Some(ReedlineEvent::Edit(vec![EditCommand::Complete])), + InputFunction::BrushAcceptHint => Some(ReedlineEvent::HistoryHintComplete), + InputFunction::BrushAcceptHintWord => Some(ReedlineEvent::HistoryHintWordComplete), + _ => None, + } +} + +pub(crate) fn is_reedline_host_command(cmd: &str) -> bool { + // See the implementation of `format_reedline_host_command`. We look for the marker. + cmd.ends_with("# bind-command") +} + +const fn translate_reedline_keycode(keycode: reedline::KeyCode) -> Option { + match keycode { + reedline::KeyCode::Backspace => Some(Key::Backspace), + reedline::KeyCode::Enter => Some(Key::Enter), + reedline::KeyCode::Left => Some(Key::Left), + reedline::KeyCode::Right => Some(Key::Right), + reedline::KeyCode::Up => Some(Key::Up), + reedline::KeyCode::Down => Some(Key::Down), + reedline::KeyCode::Home => Some(Key::Home), + reedline::KeyCode::End => Some(Key::End), + reedline::KeyCode::PageUp => Some(Key::PageUp), + reedline::KeyCode::PageDown => Some(Key::PageDown), + reedline::KeyCode::Tab => Some(Key::Tab), + reedline::KeyCode::BackTab => Some(Key::BackTab), + reedline::KeyCode::Delete => Some(Key::Delete), + reedline::KeyCode::Insert => Some(Key::Insert), + reedline::KeyCode::F(n) => Some(Key::F(n)), + reedline::KeyCode::Char(c) => Some(Key::Character(c)), + reedline::KeyCode::Null => None, + reedline::KeyCode::Esc => Some(Key::Escape), + reedline::KeyCode::CapsLock => None, + reedline::KeyCode::ScrollLock => None, + reedline::KeyCode::NumLock => None, + reedline::KeyCode::PrintScreen => None, + reedline::KeyCode::Pause => None, + reedline::KeyCode::Menu => None, + reedline::KeyCode::KeypadBegin => None, + reedline::KeyCode::Media(_media_key_code) => None, + reedline::KeyCode::Modifier(_modifier_key_code) => None, + } +} + +#[expect(clippy::too_many_lines)] +fn translate_reedline_event_to_action(event: &reedline::ReedlineEvent) -> Option { + match event { + reedline::ReedlineEvent::Edit(cmds) => { + match cmds.as_slice() { + [reedline::EditCommand::Backspace] => Some(KeyAction::DoInputFunction( + InputFunction::BackwardDeleteChar, + )), + [reedline::EditCommand::BackspaceWord] => { + // Not quite accurate, because it doesn't save the deleted text. + Some(KeyAction::DoInputFunction(InputFunction::BackwardKillWord)) + } + [reedline::EditCommand::CapitalizeChar] => None, + [reedline::EditCommand::ClearToLineEnd] => { + // Not quite accurate, because it doesn't save the deleted text. + Some(KeyAction::DoInputFunction(InputFunction::KillLine)) + } + [reedline::EditCommand::Complete] => { + Some(KeyAction::DoInputFunction(InputFunction::Complete)) + } + [reedline::EditCommand::CutFromStart] => { + Some(KeyAction::DoInputFunction(InputFunction::KillWholeLine)) + } + [reedline::EditCommand::KillLine] => { + Some(KeyAction::DoInputFunction(InputFunction::KillLine)) + } + [reedline::EditCommand::CutWordLeft] => { + Some(KeyAction::DoInputFunction(InputFunction::BackwardKillWord)) + } + [reedline::EditCommand::CutWordRight] => { + Some(KeyAction::DoInputFunction(InputFunction::KillWord)) + } + [reedline::EditCommand::Delete] => { + Some(KeyAction::DoInputFunction(InputFunction::DeleteChar)) + } + [reedline::EditCommand::DeleteWord] => { + Some(KeyAction::DoInputFunction(InputFunction::KillWord)) + } + [reedline::EditCommand::InsertNewline] => None, + [reedline::EditCommand::LowercaseWord] => { + Some(KeyAction::DoInputFunction(InputFunction::DowncaseWord)) + } + [reedline::EditCommand::MoveLeft { select: false }] => { + Some(KeyAction::DoInputFunction(InputFunction::BackwardChar)) + } + [reedline::EditCommand::MoveLeft { select: true }] => None, + [reedline::EditCommand::MoveRight { select: false }] => { + Some(KeyAction::DoInputFunction(InputFunction::ForwardChar)) + } + [reedline::EditCommand::MoveRight { select: true }] => None, + [reedline::EditCommand::MoveToEnd { select: false }] => { + // TODO(input): Not quite accurate, because it doesn't just go to end of line. + Some(KeyAction::DoInputFunction(InputFunction::EndOfLine)) + } + [reedline::EditCommand::MoveToEnd { select: true }] => None, + [reedline::EditCommand::MoveToLineEnd { select: false }] => { + Some(KeyAction::DoInputFunction(InputFunction::EndOfLine)) + } + [reedline::EditCommand::MoveToLineEnd { select: true }] => None, + [reedline::EditCommand::MoveToLineStart { select: false }] => { + Some(KeyAction::DoInputFunction(InputFunction::BeginningOfLine)) + } + [reedline::EditCommand::MoveToLineStart { select: true }] => None, + [reedline::EditCommand::MoveToStart { select: false }] => { + // TODO(input): Not quite accurate, because it doesn't just go to beginning of + // line. + Some(KeyAction::DoInputFunction(InputFunction::BeginningOfLine)) + } + [reedline::EditCommand::MoveToStart { select: true }] => None, + [reedline::EditCommand::MoveWordLeft { select: false }] => { + Some(KeyAction::DoInputFunction(InputFunction::BackwardWord)) + } + [reedline::EditCommand::MoveWordLeft { select: true }] => None, + [reedline::EditCommand::MoveWordRight { select: false }] => { + Some(KeyAction::DoInputFunction(InputFunction::ForwardWord)) + } + [reedline::EditCommand::MoveWordRight { select: true }] => None, + [reedline::EditCommand::PasteCutBufferAfter] => { + Some(KeyAction::DoInputFunction(InputFunction::Yank)) + } + [reedline::EditCommand::PasteCutBufferBefore] => None, + [reedline::EditCommand::Redo] => { + Some(KeyAction::DoInputFunction(InputFunction::ViRedo)) + } + [reedline::EditCommand::SelectAll] => None, + [reedline::EditCommand::SwapGraphemes] => { + Some(KeyAction::DoInputFunction(InputFunction::TransposeChars)) + } + [reedline::EditCommand::UppercaseWord] => { + Some(KeyAction::DoInputFunction(InputFunction::UpcaseWord)) + } + [reedline::EditCommand::Undo] => { + Some(KeyAction::DoInputFunction(InputFunction::Undo)) + } + _ => { + // TODO(input): Handle more? + tracing::debug!(target: trace_categories::INPUT, "unhandled edit commands: {cmds:?}"); + None + } + } + } + reedline::ReedlineEvent::ClearScreen => { + Some(KeyAction::DoInputFunction(InputFunction::ClearScreen)) + } + reedline::ReedlineEvent::CtrlC => None, + reedline::ReedlineEvent::CtrlD => None, + reedline::ReedlineEvent::Enter => { + Some(KeyAction::DoInputFunction(InputFunction::AcceptLine)) + } + reedline::ReedlineEvent::Esc => None, + reedline::ReedlineEvent::MenuPrevious => None, + reedline::ReedlineEvent::OpenEditor => None, + reedline::ReedlineEvent::Left => { + Some(KeyAction::DoInputFunction(InputFunction::BackwardChar)) + } + reedline::ReedlineEvent::Right => { + Some(KeyAction::DoInputFunction(InputFunction::ForwardChar)) + } + reedline::ReedlineEvent::Up => Some(KeyAction::DoInputFunction( + InputFunction::PreviousScreenLine, + )), + reedline::ReedlineEvent::Down => { + Some(KeyAction::DoInputFunction(InputFunction::NextScreenLine)) + } + reedline::ReedlineEvent::SearchHistory => Some(KeyAction::DoInputFunction( + InputFunction::HistorySearchBackward, + )), + reedline::ReedlineEvent::Repaint => { + Some(KeyAction::DoInputFunction(InputFunction::RedrawCurrentLine)) + } + reedline::ReedlineEvent::HistoryHintComplete => { + Some(KeyAction::DoInputFunction(InputFunction::BrushAcceptHint)) + } + reedline::ReedlineEvent::HistoryHintWordComplete => Some(KeyAction::DoInputFunction( + InputFunction::BrushAcceptHintWord, + )), + reedline::ReedlineEvent::Multiple(evts) => { + if let &[ + reedline::ReedlineEvent::Edit(ref edit_cmds), + reedline::ReedlineEvent::Enter, + ] = evts.as_slice() + { + if let &[ + reedline::EditCommand::MoveToStart { select: false }, + reedline::EditCommand::InsertChar('#'), + ] = edit_cmds.as_slice() + { + return Some(KeyAction::DoInputFunction(InputFunction::InsertComment)); + } + } + + // TODO(input): Try to extract something from these? + tracing::debug!(target: trace_categories::INPUT, "unhandled composite event: {evts:?}"); + None + } + reedline::ReedlineEvent::UntilFound(uf_events) => { + let mut i = 0; + + if uf_events.is_empty() { + return None; + } + + while i < uf_events.len() { + match &uf_events[i] { + reedline::ReedlineEvent::HistoryHintComplete + | reedline::ReedlineEvent::HistoryHintWordComplete + | reedline::ReedlineEvent::Menu(_) + | reedline::ReedlineEvent::MenuDown + | reedline::ReedlineEvent::MenuUp + | reedline::ReedlineEvent::MenuLeft + | reedline::ReedlineEvent::MenuRight + | reedline::ReedlineEvent::MenuNext + | reedline::ReedlineEvent::MenuPrevious + | reedline::ReedlineEvent::MenuPageNext + | reedline::ReedlineEvent::MenuPagePrevious => { + i += 1; + } + _ => { + break; + } + } + } + + if i == uf_events.len() - 1 { + translate_reedline_event_to_action(&uf_events[i]) + } else { + // TODO(input): Try to extract something from these? + tracing::debug!(target: trace_categories::INPUT, "unhandled until-found event: {uf_events:?}"); + None + } + } + reedline::ReedlineEvent::ExecuteHostCommand(cmd) => parse_reedline_host_command(cmd) + .map(|cmd_str| KeyAction::ShellCommand(cmd_str.to_string())), + evt => { + // TODO(input): Handle more? + tracing::debug!(target: trace_categories::INPUT, "unhandled event: {evt:?}"); + None + } + } +} diff --git a/local/recipes/shells/brush/source/brush-interactive/src/reedline/highlighter.rs b/local/recipes/shells/brush/source/brush-interactive/src/reedline/highlighter.rs new file mode 100644 index 0000000000..64b122fb71 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-interactive/src/reedline/highlighter.rs @@ -0,0 +1,139 @@ +use crate::{highlighting, refs}; +use nu_ansi_term::{Color, Style}; + +mod styles { + use super::{Color, Style}; + + pub fn default() -> Style { + Style::new().fg(Color::Default) + } + + pub fn comment() -> Style { + Style::new().fg(Color::DarkGray) + } + + pub fn arithmetic() -> Style { + Style::new().fg(Color::LightBlue) + } + + pub fn parameter() -> Style { + Style::new().fg(Color::LightMagenta) + } + + pub fn command_substitution() -> Style { + Style::new().fg(Color::LightBlue) + } + + pub fn quoted() -> Style { + Style::new().fg(Color::Yellow) + } + + pub fn operator() -> Style { + Style::new().fg(Color::Default).italic() + } + + pub fn assignment() -> Style { + Style::new().fg(Color::LightGray).dimmed() + } + + pub fn hyphen_option() -> Style { + Style::new().fg(Color::Default).italic() + } + + pub fn function() -> Style { + Style::new().bold().fg(Color::Yellow) + } + + pub fn keyword() -> Style { + Style::new().bold().fg(Color::LightYellow).italic() + } + + pub fn builtin() -> Style { + Style::new().bold().fg(Color::Green) + } + + pub fn alias() -> Style { + Style::new().bold().fg(Color::Cyan) + } + + pub fn external_command() -> Style { + Style::new().bold().fg(Color::Green) + } + + pub fn not_found_command() -> Style { + Style::new().bold().fg(Color::Red) + } + + pub fn unknown_command() -> Style { + Style::new().bold().fg(Color::Default) + } +} + +pub(crate) struct ReedlineHighlighter { + pub shell: refs::ShellRef, +} + +pub(crate) struct PlainTextHighlighter; + +impl reedline::Highlighter for PlainTextHighlighter { + fn highlight(&self, line: &str, _cursor: usize) -> reedline::StyledText { + let mut styled = reedline::StyledText::new(); + styled.push((Style::new(), line.to_owned())); + styled + } +} + +impl reedline::Highlighter for ReedlineHighlighter { + #[expect(clippy::significant_drop_tightening)] + fn highlight(&self, line: &str, cursor: usize) -> reedline::StyledText { + let shell = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(self.shell.lock()) + }); + + let highlighted = highlighting::highlight_command(shell.as_ref(), line, cursor); + + let mut styled = reedline::StyledText::new(); + for (kind, text) in highlighted.iter() { + styled.push((kind_to_style(kind), text.to_owned())); + } + + styled + } +} + +fn kind_to_style(kind: highlighting::HighlightKind) -> Style { + match kind { + highlighting::HighlightKind::Default => styles::default(), + highlighting::HighlightKind::Comment => styles::comment(), + highlighting::HighlightKind::Arithmetic => styles::arithmetic(), + highlighting::HighlightKind::Parameter => styles::parameter(), + highlighting::HighlightKind::CommandSubstitution => styles::command_substitution(), + highlighting::HighlightKind::Quoted => styles::quoted(), + highlighting::HighlightKind::Operator => styles::operator(), + highlighting::HighlightKind::Assignment => styles::assignment(), + highlighting::HighlightKind::HyphenOption => styles::hyphen_option(), + highlighting::HighlightKind::Function => styles::function(), + highlighting::HighlightKind::Keyword => styles::keyword(), + highlighting::HighlightKind::Builtin => styles::builtin(), + highlighting::HighlightKind::Alias => styles::alias(), + highlighting::HighlightKind::ExternalCommand => styles::external_command(), + highlighting::HighlightKind::NotFoundCommand => styles::not_found_command(), + highlighting::HighlightKind::UnknownCommand => styles::unknown_command(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use reedline::Highlighter; + + #[test] + fn plain_text_highlighter_uses_terminal_default_colors() { + let styled = PlainTextHighlighter.highlight("echo hello", 0); + + assert_eq!(styled.buffer.len(), 1); + assert_eq!(styled.buffer[0].0.foreground, None); + assert_eq!(styled.buffer[0].0.background, None); + assert_eq!(styled.buffer[0].1, "echo hello"); + } +} diff --git a/local/recipes/shells/brush/source/brush-interactive/src/reedline/history.rs b/local/recipes/shells/brush/source/brush-interactive/src/reedline/history.rs new file mode 100644 index 0000000000..170f37d71f --- /dev/null +++ b/local/recipes/shells/brush/source/brush-interactive/src/reedline/history.rs @@ -0,0 +1,263 @@ +use crate::refs; + +pub(crate) struct ReedlineHistory { + pub shell: refs::ShellRef, +} + +impl ReedlineHistory { + fn lock_shell(&self) -> tokio::sync::MutexGuard<'_, brush_core::Shell> { + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(self.shell.lock()) + }) + } +} + +impl reedline::History for ReedlineHistory { + /// Updates or adds a new item to the saved history. + /// + /// # Arguments + /// + /// * `item` - The history item to save. + fn save(&mut self, item: reedline::HistoryItem) -> reedline::Result { + // + // TODO(history): Evaluate a way to rationalize between this and the shared + // history saving. For now, we need to do nothing here to avoid + // duplicate history items since we are auto-updating the history + // in a non-reedline-specific way. + // + + // let brush_item = reedline_history_item_to_brush(&item); + // let mut shell = self.lock_shell(); + // let history = get_shell_history_mut(&mut shell)?; + // + // if let Some(id) = &item.id { + // history + // .update_by_id(id.0, brush_item) + // .map_err(brush_error_to_reedline)?; + // } else { + // let id = history.add(brush_item).map_err(brush_error_to_reedline)?; + // item.id = Some(reedline::HistoryItemId(id)); + // } + + Ok(item) + } + + /// Loads a history item by its ID. + /// + /// # Arguments + /// + /// * `id` - The ID of the history item to load. + fn load(&self, id: reedline::HistoryItemId) -> reedline::Result { + let shell = self.lock_shell(); + + // Get the history, retrieve the item, and translate the item it into reedline's format. + get_shell_history(&shell)? + .get_by_id(id.0) + .map_err(brush_error_to_reedline)? + .ok_or({ + reedline::ReedlineError(reedline::ReedlineErrorVariants::OtherHistoryError( + "history item not found", + )) + }) + .map(brush_history_item_to_reedline) + } + + /// Counts all history items matching the given query. + /// + /// # Arguments + /// + /// * `query` - The search query to match against history items. + fn count(&self, query: reedline::SearchQuery) -> reedline::Result { + let query = reedline_history_query_into_brush(query)?; + + let shell = self.lock_shell(); + let count = get_shell_history(&shell)?.search(query).iter().count(); + drop(shell); + + #[expect(clippy::cast_possible_wrap)] + Ok(count as i64) + } + + /// Searches through history, returning all items matching the given query. + /// + /// # Arguments + /// + /// * `query` - The search query to match against history items. + #[expect(clippy::significant_drop_tightening)] + fn search(&self, query: reedline::SearchQuery) -> reedline::Result> { + let query = reedline_history_query_into_brush(query)?; + let shell = self.lock_shell(); + let items = get_shell_history(&shell)? + .search(query) + .map_err(brush_error_to_reedline)? + .map(|item| { + // Translate the item into reedline's format. + brush_history_item_to_reedline(item) + }) + .collect::>(); + + Ok(items) + } + + /// Update a history item. + /// + /// # Arguments + /// + /// * `id` - The ID of the history item to update. + /// * `updater` - A function that takes a history item and returns an updated history item. + fn update( + &mut self, + id: reedline::HistoryItemId, + updater: &dyn Fn(reedline::HistoryItem) -> reedline::HistoryItem, + ) -> reedline::Result<()> { + // TODO(history): Understand atomicity expectations of reedline. + let item = self.load(id)?; + let updated_item = updater(item); + self.save(updated_item)?; + + Ok(()) + } + + /// Delete all history items. + fn clear(&mut self) -> reedline::Result<()> { + let mut shell = self.lock_shell(); + + // Get the history, retrieve the item, and translate the item it into reedline's format. + get_shell_history_mut(&mut shell)? + .clear() + .map_err(brush_error_to_reedline) + } + + /// Delete the history item with the given ID. + /// + /// # Arguments + /// + /// * `id` - The ID of the history item to delete. + fn delete(&mut self, id: reedline::HistoryItemId) -> reedline::Result<()> { + let mut shell = self.lock_shell(); + + get_shell_history_mut(&mut shell)? + .delete_item_by_id(id.0) + .map_err(brush_error_to_reedline) + } + + /// Sync all history items to backing storage. + fn sync(&mut self) -> std::io::Result<()> { + let mut shell = self.lock_shell(); + shell.save_history().map_err(std::io::Error::other) + } + + /// Retrieves a unique ID for the current session. + fn session(&self) -> Option { + // Not implemented for now. + None + } +} + +fn brush_history_item_to_reedline(item: &brush_core::history::Item) -> reedline::HistoryItem { + let mut rl_item = reedline::HistoryItem::from_command_line(item.command_line.as_str()); + rl_item.id = Some(reedline::HistoryItemId(item.id)); + rl_item.start_timestamp = item.timestamp; + + rl_item +} + +#[expect(unused)] +fn reedline_history_item_to_brush(item: &reedline::HistoryItem) -> brush_core::history::Item { + // TODO(history): implement more fields when they are added to Item + brush_core::history::Item { + id: item.id.map_or(0, |id| id.0), + command_line: item.command_line.clone(), + timestamp: item.start_timestamp, + dirty: true, + } +} + +fn brush_error_to_reedline(error: brush_core::Error) -> reedline::ReedlineError { + reedline::ReedlineError::from(std::io::Error::other(error)) +} + +fn reedline_history_query_into_brush( + query: reedline::SearchQuery, +) -> reedline::Result { + let mut result = brush_core::history::Query { + direction: match query.direction { + reedline::SearchDirection::Forward => brush_core::history::Direction::Forward, + reedline::SearchDirection::Backward => brush_core::history::Direction::Backward, + }, + max_items: query.limit, + not_at_or_before_id: if matches!(query.direction, reedline::SearchDirection::Backward) { + query.end_id.map(|id| id.0) + } else { + query.start_id.map(|id| id.0) + }, + not_at_or_after_id: if matches!(query.direction, reedline::SearchDirection::Backward) { + query.start_id.map(|id| id.0) + } else { + query.end_id.map(|id| id.0) + }, + not_at_or_before_time: if matches!(query.direction, reedline::SearchDirection::Backward) { + query.end_time + } else { + query.start_time + }, + not_at_or_after_time: if matches!(query.direction, reedline::SearchDirection::Backward) { + query.start_time + } else { + query.end_time + }, + ..Default::default() + }; + + if let Some(cmdline_filter) = query.filter.command_line { + result.command_line_filter = match cmdline_filter { + reedline::CommandLineSearch::Exact(cmdline) => { + Some(brush_core::history::CommandLineFilter::Exact(cmdline)) + } + reedline::CommandLineSearch::Substring(cmdline) => { + Some(brush_core::history::CommandLineFilter::Contains(cmdline)) + } + reedline::CommandLineSearch::Prefix(cmdline) => { + Some(brush_core::history::CommandLineFilter::Prefix(cmdline)) + } + } + } + + if query.filter.cwd_exact.is_some() + || query.filter.cwd_prefix.is_some() + || query.filter.exit_successful.is_some() + || query.filter.hostname.is_some() + || query.filter.session.is_some() + { + return Err(reedline::ReedlineError( + reedline::ReedlineErrorVariants::HistoryFeatureUnsupported { + history: "(default)", + feature: "search filter", + }, + )); + } + + Ok(result) +} + +fn get_shell_history<'a, SE: brush_core::ShellExtensions>( + shell: &'a tokio::sync::MutexGuard<'_, brush_core::Shell>, +) -> Result<&'a brush_core::history::History, reedline::ReedlineError> { + shell.history().ok_or({ + reedline::ReedlineError(reedline::ReedlineErrorVariants::HistoryFeatureUnsupported { + history: "(default)", + feature: "load", + }) + }) +} + +fn get_shell_history_mut<'a, SE: brush_core::ShellExtensions>( + shell: &'a mut tokio::sync::MutexGuard<'_, brush_core::Shell>, +) -> Result<&'a mut brush_core::history::History, reedline::ReedlineError> { + shell.history_mut().ok_or({ + reedline::ReedlineError(reedline::ReedlineErrorVariants::HistoryFeatureUnsupported { + history: "(default)", + feature: "load", + }) + }) +} diff --git a/local/recipes/shells/brush/source/brush-interactive/src/reedline/input_backend.rs b/local/recipes/shells/brush/source/brush-interactive/src/reedline/input_backend.rs new file mode 100644 index 0000000000..733e33d1db --- /dev/null +++ b/local/recipes/shells/brush/source/brush-interactive/src/reedline/input_backend.rs @@ -0,0 +1,296 @@ +use nu_ansi_term::Style; +use reedline::MenuBuilder; + +use super::{completer, edit_mode, highlighter, history, validator}; +use crate::{InputBackend, ReadResult, ShellError, input_backend::InteractivePrompt, refs}; + +/// Represents an interactive shell capable of taking commands from standard input +/// and reporting results to standard output and standard error streams. +pub struct ReedlineInputBackend { + reedline: Option, +} + +const COMPLETION_MENU_NAME: &str = "completion_menu"; + +fn completion_menu_text_style() -> Style { + Style::new() +} + +fn completion_menu_selected_text_style() -> Style { + Style::new().bold().reverse() +} + +fn completion_menu_match_text_style() -> Style { + Style::new().underline() +} + +fn completion_menu_selected_match_text_style() -> Style { + completion_menu_selected_text_style().underline() +} + +fn history_hint_style() -> Style { + Style::new().italic().dimmed() +} + +impl ReedlineInputBackend { + /// Returns a new interactive shell instance, created with the provided options. + /// + /// # Arguments + /// + /// * `options` - Options for creating the input backend. + /// * `shell_ref` - Shell that the backend will be used with. + pub fn new( + options: &crate::UIOptions, + shell_ref: &refs::ShellRef, + ) -> Result { + // Set up key bindings. + let key_bindings = compose_key_bindings(COMPLETION_MENU_NAME); + + // Set up mutable edit mode. + let mutable_edit_mode = edit_mode::MutableEditMode::new(key_bindings); + let updatable_bindings = mutable_edit_mode.bindings(); + + // Create helper objects that implement reedline traits; each will + // hold a reference to the shell. + let completer = completer::ReedlineCompleter { + shell: shell_ref.clone(), + }; + let validator = validator::ReedlineValidator { + shell: shell_ref.clone(), + }; + let syntax_highlighter = highlighter::ReedlineHighlighter { + shell: shell_ref.clone(), + }; + let history = history::ReedlineHistory { + shell: shell_ref.clone(), + }; + + // Set up completion menu. Set an empty marker to avoid the + // line's text horizontally shifting around during/after completion. + // We set a max column count of 10 to ensure it's larger than the + // hard-coded default (4 last we checked); if there's not enough + // horizontal space in the terminal to fit that many columns, given + // the actual text to be displayed, it will get effectively dereased + // anyhow. + let completion_menu = Box::new( + reedline::ColumnarMenu::default() + .with_name(COMPLETION_MENU_NAME) + .with_marker("") + .with_columns(10) + .with_text_style(completion_menu_text_style()) + .with_match_text_style(completion_menu_match_text_style()) + .with_selected_text_style(completion_menu_selected_text_style()) + .with_selected_match_text_style(completion_menu_selected_match_text_style()), + ); + + // Set up default history-based hinter. + let mut hinter = reedline::DefaultHinter::default(); + if !options.disable_color { + hinter = hinter.with_style(history_hint_style()); + } + + // Instantiate reedline with some defaults and hand it ownership of + // the helpers. + let mut reedline = reedline::Reedline::create() + .with_ansi_colors(!options.disable_color) + .use_bracketed_paste(!options.disable_bracketed_paste) + .with_completer(Box::new(completer)) + .with_quick_completions(true) + .with_validator(Box::new(validator)) + .with_hinter(Box::new(hinter)) + .with_menu(reedline::ReedlineMenu::EngineCompleter(completion_menu)) + .with_edit_mode(Box::new(mutable_edit_mode)) + .with_history(Box::new(history)); + + // Override Reedline's default example highlighter, which hard-codes white as the + // neutral input color. When syntax highlighting is disabled we still install a plain + // highlighter so typed text follows the terminal's default foreground color. + if !options.disable_color { + reedline = if options.disable_highlighting { + reedline.with_highlighter(Box::new(highlighter::PlainTextHighlighter)) + } else { + reedline.with_highlighter(Box::new(syntax_highlighter)) + }; + } + + let mut shell = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(shell_ref.lock()) + }); + + shell.set_key_bindings(Some(updatable_bindings)); + drop(shell); + + Ok(Self { + reedline: Some(reedline), + }) + } +} + +impl Drop for ReedlineInputBackend { + fn drop(&mut self) { + // It's unpleasant to need to do so, but if we detect a panic in the process of being + // unwound, then we arrange for our reedline::Reedline instance to *not* get dropped. + // Without this, then there's a chance that our panic handler emitted important + // diagnostics to stdout but dropping the Reedline object will end up erasing it + // when the latter object's internal Painter gets dropped and, in turn, may flush + // some not-yet-flushed terminal control sequences. This isn't theoretical; we've + // actively seen this in various cases where a panic occurs with Reedline::read_line() + // on the stack. + if std::thread::panicking() { + let reedline = std::mem::take(&mut self.reedline); + std::mem::forget(reedline); + } + } +} + +impl InputBackend for ReedlineInputBackend { + /// Reads a line of input, using the given prompt. + /// + /// # Arguments + /// + /// * `prompt` - The prompt to display to the user. + fn read_line( + &mut self, + _shell: &crate::ShellRef, + prompt: InteractivePrompt, + ) -> Result { + if let Some(reedline) = &mut self.reedline { + match reedline.read_line(&prompt) { + Ok(reedline::Signal::Success(s)) => { + if edit_mode::is_reedline_host_command(s.as_str()) { + Ok(ReadResult::BoundCommand(s)) + } else { + Ok(ReadResult::Input(s)) + } + } + Ok(reedline::Signal::CtrlC) => Ok(ReadResult::Interrupted), + Ok(reedline::Signal::CtrlD) => Ok(ReadResult::Eof), + Ok(reedline::Signal::ExternalBreak(_)) => Err(ShellError::UnexpectedInputFailure), + Ok(_) => Err(ShellError::UnexpectedInputFailure), + Err(err) => Err(ShellError::InputError(err)), + } + } else { + Ok(ReadResult::Eof) + } + } + + fn get_read_buffer(&self) -> Option<(String, usize)> { + self.reedline.as_ref().map(|r| { + ( + r.current_buffer_contents().to_owned(), + r.current_insertion_point(), + ) + }) + } + + fn set_read_buffer(&mut self, buffer: String, cursor: usize) { + if let Some(reedline) = &mut self.reedline { + reedline.run_edit_commands(&[ + reedline::EditCommand::MoveToStart { select: false }, + reedline::EditCommand::ClearToLineEnd, + reedline::EditCommand::InsertString(buffer), + reedline::EditCommand::MoveToPosition { + position: cursor, + select: false, + }, + ]); + } + } +} + +fn compose_key_bindings(completion_menu_name: &str) -> reedline::Keybindings { + let mut key_bindings = reedline::default_emacs_keybindings(); + + // Wire up tab to completion. + key_bindings.add_binding( + reedline::KeyModifiers::NONE, + reedline::KeyCode::Tab, + reedline::ReedlineEvent::UntilFound(vec![ + reedline::ReedlineEvent::Menu(completion_menu_name.to_string()), + reedline::ReedlineEvent::MenuNext, + reedline::ReedlineEvent::Edit(vec![reedline::EditCommand::Complete]), + ]), + ); + // Wire up shift-tab for completion. + key_bindings.add_binding( + reedline::KeyModifiers::SHIFT, + reedline::KeyCode::BackTab, + reedline::ReedlineEvent::MenuPrevious, + ); + + // Add undo. + // NOTE: To match readline, we bind Ctrl+_ to undo; in practice, the only way + // to get that to work out is to specify Ctrl+7 for the binding. It's not clear + // that this is terribly portable across terminals/environments. + key_bindings.add_binding( + reedline::KeyModifiers::CONTROL, + reedline::KeyCode::Char('7'), + reedline::ReedlineEvent::Edit(vec![reedline::EditCommand::Undo]), + ); + + // Capitalize. + key_bindings.add_binding( + reedline::KeyModifiers::ALT, + reedline::KeyCode::Char('c'), + reedline::ReedlineEvent::Edit(vec![ + reedline::EditCommand::CapitalizeChar, + reedline::EditCommand::MoveWordRight { select: false }, + ]), + ); + + // Add comment. + key_bindings.add_binding( + reedline::KeyModifiers::ALT, + reedline::KeyCode::Char('#'), + reedline::ReedlineEvent::Multiple(vec![ + reedline::ReedlineEvent::Edit(vec![ + reedline::EditCommand::MoveToStart { select: false }, + reedline::EditCommand::InsertChar('#'), + ]), + reedline::ReedlineEvent::Enter, + ]), + ); + + key_bindings +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn history_hint_style_is_theme_adaptive() { + let style = history_hint_style(); + + assert_eq!(style.foreground, None); + assert_eq!(style.background, None); + assert!(style.is_italic); + assert!(style.is_dimmed); + } + + #[test] + fn completion_menu_styles_are_theme_adaptive() { + let text_style = completion_menu_text_style(); + let match_style = completion_menu_match_text_style(); + let selected_text_style = completion_menu_selected_text_style(); + let selected_match_text_style = completion_menu_selected_match_text_style(); + + assert_eq!(text_style.foreground, None); + assert_eq!(text_style.background, None); + + assert_eq!(match_style.foreground, None); + assert_eq!(match_style.background, None); + assert!(match_style.is_underline); + + assert_eq!(selected_text_style.foreground, None); + assert_eq!(selected_text_style.background, None); + assert!(selected_text_style.is_bold); + assert!(selected_text_style.is_reverse); + + assert_eq!(selected_match_text_style.foreground, None); + assert_eq!(selected_match_text_style.background, None); + assert!(selected_match_text_style.is_bold); + assert!(selected_match_text_style.is_reverse); + assert!(selected_match_text_style.is_underline); + } +} diff --git a/local/recipes/shells/brush/source/brush-interactive/src/reedline/mod.rs b/local/recipes/shells/brush/source/brush-interactive/src/reedline/mod.rs new file mode 100644 index 0000000000..0b2667f306 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-interactive/src/reedline/mod.rs @@ -0,0 +1,9 @@ +mod completer; +mod edit_mode; +mod highlighter; +mod history; +mod input_backend; +mod prompt; +mod validator; + +pub use input_backend::ReedlineInputBackend; diff --git a/local/recipes/shells/brush/source/brush-interactive/src/reedline/prompt.rs b/local/recipes/shells/brush/source/brush-interactive/src/reedline/prompt.rs new file mode 100644 index 0000000000..38b37dcf2d --- /dev/null +++ b/local/recipes/shells/brush/source/brush-interactive/src/reedline/prompt.rs @@ -0,0 +1,69 @@ +use crate::input_backend::InteractivePrompt; + +impl reedline::Prompt for InteractivePrompt { + fn render_prompt_left(&self) -> std::borrow::Cow<'_, str> { + // [Workaround: see https://github.com/nushell/reedline/issues/707] + // If the prompt starts with a newline character, then there's a chance + // that it won't be rendered correctly. For this specific case, insert + // an extra space character before the newline. + if self.prompt.starts_with('\n') { + std::format!(" {}", self.prompt).into() + } else { + self.prompt.as_str().into() + } + } + + fn render_prompt_right(&self) -> std::borrow::Cow<'_, str> { + self.alt_side_prompt.as_str().into() + } + + // N.B. For now, we don't support prompt indicators. + fn render_prompt_indicator( + &self, + _prompt_mode: reedline::PromptEditMode, + ) -> std::borrow::Cow<'_, str> { + "".into() + } + + fn render_prompt_multiline_indicator(&self) -> std::borrow::Cow<'_, str> { + self.continuation_prompt.as_str().into() + } + + fn render_prompt_history_search_indicator( + &self, + history_search: reedline::PromptHistorySearch, + ) -> std::borrow::Cow<'_, str> { + match history_search.status { + reedline::PromptHistorySearchStatus::Passing => { + if history_search.term.is_empty() { + "(rev search) ".into() + } else { + std::format!("(rev search: {}) ", history_search.term).into() + } + } + reedline::PromptHistorySearchStatus::Failing => { + std::format!("(failing rev search: {}) ", history_search.term).into() + } + } + } + + fn get_prompt_color(&self) -> reedline::Color { + reedline::Color::Reset + } + + fn get_prompt_multiline_color(&self) -> nu_ansi_term::Color { + nu_ansi_term::Color::LightBlue + } + + fn get_indicator_color(&self) -> reedline::Color { + reedline::Color::Cyan + } + + fn get_prompt_right_color(&self) -> reedline::Color { + reedline::Color::AnsiValue(5) + } + + fn right_prompt_on_last_line(&self) -> bool { + false + } +} diff --git a/local/recipes/shells/brush/source/brush-interactive/src/reedline/validator.rs b/local/recipes/shells/brush/source/brush-interactive/src/reedline/validator.rs new file mode 100644 index 0000000000..0b62f292d7 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-interactive/src/reedline/validator.rs @@ -0,0 +1,25 @@ +use crate::refs; + +pub(crate) struct ReedlineValidator { + pub shell: refs::ShellRef, +} + +impl reedline::Validator for ReedlineValidator { + fn validate(&self, line: &str) -> reedline::ValidationResult { + let shell = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(self.shell.lock()) + }); + + match shell.parse_string(line.to_owned()) { + Err(brush_parser::ParseError::Tokenizing { inner, position: _ }) + if inner.is_incomplete() => + { + reedline::ValidationResult::Incomplete + } + Err(brush_parser::ParseError::ParsingAtEndOfInput) => { + reedline::ValidationResult::Incomplete + } + _ => reedline::ValidationResult::Complete, + } + } +} diff --git a/local/recipes/shells/brush/source/brush-interactive/src/refs.rs b/local/recipes/shells/brush/source/brush-interactive/src/refs.rs new file mode 100644 index 0000000000..de092e4a20 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-interactive/src/refs.rs @@ -0,0 +1,9 @@ +use std::sync::Arc; + +use tokio::sync::Mutex; + +/// A reference-counted, thread-safe reference to a `brush_core::Shell`. +#[allow(type_alias_bounds)] +pub type ShellRef< + SE: brush_core::ShellExtensions = brush_core::extensions::DefaultShellExtensions, +> = Arc>>; diff --git a/local/recipes/shells/brush/source/brush-interactive/src/term_detection.rs b/local/recipes/shells/brush/source/brush-interactive/src/term_detection.rs new file mode 100644 index 0000000000..c3299a3d47 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-interactive/src/term_detection.rs @@ -0,0 +1,412 @@ +/// Holds information about the hosting terminal. +#[derive(Clone, Debug, Default)] +#[allow(dead_code)] +pub struct TerminalInfo { + /// The detected terminal, if any. + pub terminal: Option, + + /// If applicable, a session nonce assigned by the terminal. + pub session_nonce: Option, + + /// Whether the terminal's OSC support is unknown. + pub osc_support_unknown: bool, + + /// Whether the terminal supports OSC 0 sequences: setting terminal title and icon. + pub supports_osc_0: bool, + + /// Whether the terminal supports OSC 1 sequences: setting icon name. + pub supports_osc_1: bool, + + /// Whether the terminal supports OSC 2 sequences: setting terminal title. + pub supports_osc_2: bool, + + /// Whether the terminal supports OSC 3 sequences: setting X11 window properties. + pub supports_osc_3: bool, + + /// Whether the terminal supports OSC 4 sequences: setting color palette. + pub supports_osc_4: bool, + + /// Whether the terminal supports OSC 5 sequences: setting/querying special color number. + pub supports_osc_5: bool, + + /// Whether the terminal supports OSC 6 sequences: setting title tab color (iTerm2) + pub supports_osc_6: bool, + + /// Whether the terminal supports OSC 7 sequences: setting current working directory. + pub supports_osc_7: bool, + + /// Whether the terminal supports OSC 8 sequences: hyperlinks. + pub supports_osc_8: bool, + + /// Whether the terminal supports OSC 9 sequences: showing system notification (iTerm2). + pub supports_osc_9: bool, + + /// Whether the terminal supports OSC 10 sequences: setting default foreground color. + pub supports_osc_10: bool, + + /// Whether the terminal supports OSC 11 sequences: setting default background color. + pub supports_osc_11: bool, + + /// Whether the terminal supports OSC 12 sequences: setting cursor color. + pub supports_osc_12: bool, + + /// Whether the terminal supports OSC 13 sequences: setting pointer foreground color. + pub supports_osc_13: bool, + + /// Whether the terminal supports OSC 14 sequences: setting pointer background color. + pub supports_osc_14: bool, + + /// Whether the terminal supports OSC 15 sequences: setting Tektronix foreground color. + pub supports_osc_15: bool, + + /// Whether the terminal supports OSC 16 sequences: setting Tektronix background color. + pub supports_osc_16: bool, + + /// Whether the terminal supports OSC 17 sequences: setting highlight background color. + pub supports_osc_17: bool, + + /// Whether the terminal supports OSC 18 sequences: setting Tektronix cursor color. + pub supports_osc_18: bool, + + /// Whether the terminal supports OSC 19 sequences: setting highlight foreground color. + pub supports_osc_19: bool, + + /// Whether the terminal supports OSC 21 sequences: color control (Kitty extension). + pub supports_osc_21: bool, + + /// Whether the terminal supports OSC 22 sequences: setting mouse pointer. + pub supports_osc_22: bool, + + /// Whether the terminal supports OSC 50 sequences: setting font. + pub supports_osc_50: bool, + + /// Whether the terminal supports OSC 52 sequences: clipboard and primary selection. + pub supports_osc_52: bool, + + /// Whether the terminal supports OSC 66 sequences: scoped text size. + pub supports_osc_66: bool, + + /// Whether the terminal supports OSC 99 sequences: desktop notifications. + pub supports_osc_99: bool, + + /// Whether the terminal supports OSC 104 sequences: resetting color palette. + pub supports_osc_104: bool, + + /// Whether the terminal supports OSC 105 sequences: resetting special colors. + pub supports_osc_105: bool, + + /// Whether the terminal supports OSC 110 sequences: resetting default foreground color. + pub supports_osc_110: bool, + + /// Whether the terminal supports OSC 111 sequences: resetting default background color. + pub supports_osc_111: bool, + + /// Whether the terminal supports OSC 112 sequences: resetting cursor color. + pub supports_osc_112: bool, + + /// Whether the terminal supports OSC 113 sequences: resetting pointer foreground color. + pub supports_osc_113: bool, + + /// Whether the terminal supports OSC 114 sequences: resetting pointer background color. + pub supports_osc_114: bool, + + /// Whether the terminal supports OSC 115 sequences: resetting Tektronix foreground color. + pub supports_osc_115: bool, + + /// Whether the terminal supports OSC 116 sequences: resetting Tektronix background color. + pub supports_osc_116: bool, + + /// Whether the terminal supports OSC 117 sequences: resetting highlight background color + pub supports_osc_117: bool, + + /// Whether the terminal supports OSC 118 sequences: resetting Tektronix cursor color. + pub supports_osc_118: bool, + + /// Whether the terminal supports OSC 119 sequences: resetting highlight foreground color + pub supports_osc_119: bool, + + /// Whether the terminal supports OSC 133 sequences: shell integration (input, output, and + /// prompt zones). + pub supports_osc_133: bool, + + /// Whether the terminal supports OSC 176 sequences: setting app ID. + pub supports_osc_176: bool, + + /// Whether the terminal supports OSC 555 sequences: flashing screen (foot-specific) + pub supports_osc_555: bool, + + /// Whether the terminal supports OSC 633 sequences: shell integration (`VSCode` extension). + pub supports_osc_633: bool, + + /// Whether the terminal supports OSC 777 sequences: desktop notifications / rxvt extensions. + pub supports_osc_777: bool, + + /// Whether the terminal supports OSC 1337 sequences: custom iTerm2 sequences. + pub supports_osc_1337: bool, + + /// Whether the terminal supports OSC 5113 sequences: file transfer (Kitty extension). + pub supports_osc_5113: bool, + + /// Whether the terminal supports OSC 5522 sequences: advanced clipboard interaction (Kitty + /// extension). + pub supports_osc_5522: bool, + + /// Whether the terminal supports OSC 9001 sequences: Windows Terminal extensions. + pub supports_osc_9001: bool, +} + +/// Identifies a known terminal emulator hosting this process. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum KnownTerminal { + /// Alacritty + Alacritty, + /// Apple terminal + AppleTerminal, + /// Ghostty + Ghostty, + /// GNOME Terminal + GnomeTerminal, + /// iTerm2 + ITerm2, + /// Kitty + Kitty, + /// Konsole + Konsole, + /// `VSCode` Terminal + VSCode, + /// Other VTE-based terminal + Vte, + /// Warp Terminal + WarpTerminal, + /// `WezTerm` + WezTerm, + /// Windows Terminal + WindowsTerminal, +} + +/// Abstracts access to environment variables used for terminal detection. +pub(crate) trait TerminalEnvironment { + /// Gets the value of an environment variable. Returns `None` if the variable is not set. + fn get_env_var(&self, key: &str) -> Option; +} + +#[allow(clippy::too_many_lines)] +pub(crate) fn get_terminal_info(env: &impl TerminalEnvironment) -> TerminalInfo { + let mut info = TerminalInfo { + terminal: try_detect_terminal(env), + ..Default::default() + }; + + if let Some(terminal) = &info.terminal { + match terminal { + KnownTerminal::Alacritty => { + // https://github.com/alacritty/alacritty/blob/master/docs/escape_support.md + info.supports_osc_0 = true; + info.supports_osc_2 = true; + info.supports_osc_4 = true; + info.supports_osc_8 = true; + info.supports_osc_10 = true; + info.supports_osc_11 = true; + info.supports_osc_12 = true; + info.supports_osc_50 = true; // only cursor shape supported + info.supports_osc_52 = true; // only clipboard and primary selection supported + info.supports_osc_104 = true; + info.supports_osc_110 = true; + info.supports_osc_111 = true; + info.supports_osc_112 = true; + } + KnownTerminal::Ghostty => { + // https://ghostty.org/docs/vt/osc/0 + info.supports_osc_0 = true; + info.supports_osc_1 = true; + info.supports_osc_2 = true; + info.supports_osc_4 = true; + info.supports_osc_5 = true; + info.supports_osc_7 = true; + info.supports_osc_8 = true; + info.supports_osc_9 = true; + info.supports_osc_10 = true; + info.supports_osc_11 = true; + info.supports_osc_12 = true; + info.supports_osc_21 = true; + info.supports_osc_22 = true; + info.supports_osc_52 = true; + info.supports_osc_104 = true; + info.supports_osc_105 = true; + info.supports_osc_110 = true; + info.supports_osc_111 = true; + info.supports_osc_112 = true; + } + KnownTerminal::ITerm2 => { + // https://iterm2.com/documentation-escape-codes.html + info.supports_osc_4 = true; + info.supports_osc_6 = true; + info.supports_osc_7 = true; + info.supports_osc_8 = true; + info.supports_osc_133 = true; + info.supports_osc_1337 = true; + } + KnownTerminal::Kitty => { + // https://sw.kovidgoyal.net/kitty/protocol-extensions/ + info.supports_osc_21 = true; + info.supports_osc_22 = true; + info.supports_osc_66 = true; + info.supports_osc_5113 = true; + info.supports_osc_5522 = true; + } + KnownTerminal::VSCode => { + // https://code.visualstudio.com/docs/terminal/shell-integration + // https://github.com/microsoft/vscode/blob/main/src/vs/workbench/contrib/terminal/browser/terminalEscapeSequences.ts + info.supports_osc_7 = true; + info.supports_osc_9 = true; + info.supports_osc_133 = true; + info.supports_osc_633 = true; + info.supports_osc_1337 = true; + info.session_nonce = env.get_env_var("VSCODE_NONCE"); + } + KnownTerminal::WezTerm => { + // https://wezterm.org/escape-sequences.html + // https://wezterm.org/shell-integration.html + info.supports_osc_0 = true; + info.supports_osc_1 = true; + info.supports_osc_2 = true; + info.supports_osc_4 = true; + info.supports_osc_7 = true; + info.supports_osc_8 = true; + info.supports_osc_9 = true; + info.supports_osc_10 = true; + info.supports_osc_11 = true; + info.supports_osc_12 = true; + info.supports_osc_52 = true; + info.supports_osc_104 = true; + info.supports_osc_133 = true; + info.supports_osc_777 = true; + info.supports_osc_1337 = true; + } + KnownTerminal::WindowsTerminal => { + // https://learn.microsoft.com/en-us/windows/terminal/tutorials/shell-integration + // https://github.com/microsoft/terminal/blob/main/src/terminal/parser/OutputStateMachineEngine.hpp + info.supports_osc_0 = true; + info.supports_osc_1 = true; + info.supports_osc_2 = true; + info.supports_osc_4 = true; + info.supports_osc_8 = true; + info.supports_osc_9 = true; + info.supports_osc_10 = true; + info.supports_osc_11 = true; + info.supports_osc_12 = true; + info.supports_osc_17 = true; + info.supports_osc_21 = true; + info.supports_osc_52 = true; + info.supports_osc_104 = true; + info.supports_osc_110 = true; + info.supports_osc_111 = true; + info.supports_osc_112 = true; + info.supports_osc_117 = true; + info.supports_osc_133 = true; + info.supports_osc_633 = true; + info.supports_osc_1337 = true; + info.supports_osc_9001 = true; + } + _ => { + info.osc_support_unknown = true; + } + } + } else { + info.osc_support_unknown = true; + } + + info +} + +/// Tries to detect the hosting terminal. +/// +/// # Arguments +/// +/// * `env` - An implementation of `TerminalEnvironment` to access environment variables. +pub(crate) fn try_detect_terminal(env: &impl TerminalEnvironment) -> Option { + if let Some(detected) = try_detect_terminal_from_prog_var(env) { + Some(detected) + } else if env.get_env_var("WT_SESSION").is_some() { + Some(KnownTerminal::WindowsTerminal) + } else { + None + } +} + +fn try_detect_terminal_from_prog_var(env: &impl TerminalEnvironment) -> Option { + let term_prog = env.get_env_var("TERM_PROGRAM")?; + + // Remove punctuation and normalize. + let term_prog: String = term_prog + .chars() + .filter(|c| c.is_alphanumeric()) + .map(|c| c.to_ascii_lowercase()) + .collect(); + + match term_prog.as_str() { + "alacritty" => Some(KnownTerminal::Alacritty), + "appleterminal" => Some(KnownTerminal::AppleTerminal), + "ghostty" => Some(KnownTerminal::Ghostty), + "gnometerminal" => Some(KnownTerminal::GnomeTerminal), + "iterm" | "iterm2" | "itermapp" => Some(KnownTerminal::ITerm2), + "kitty" => Some(KnownTerminal::Kitty), + "konsole" => Some(KnownTerminal::Konsole), + "vscode" => Some(KnownTerminal::VSCode), + "vte" => Some(KnownTerminal::Vte), + "warp" | "warpterminal" => Some(KnownTerminal::WarpTerminal), + "wezterm" => Some(KnownTerminal::WezTerm), + "windowsterminal" => Some(KnownTerminal::WindowsTerminal), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_matches; + use std::collections::HashMap; + + impl TerminalEnvironment for HashMap<&str, &str> { + fn get_env_var(&self, key: &str) -> Option { + self.get(key).map(|v| (*v).to_string()) + } + } + + #[test] + fn no_term_program() { + let test_env = HashMap::new(); + + let term_info = get_terminal_info(&test_env); + assert_matches!(term_info.terminal, None); + assert!(term_info.osc_support_unknown); + } + + #[test] + fn unknown_term_program() { + let test_env = HashMap::from([("TERM_PROGRAM", "unknown_terminal")]); + + let term_info = get_terminal_info(&test_env); + assert_matches!(term_info.terminal, None); + assert!(term_info.osc_support_unknown); + } + + #[test] + fn vscode_recognition() { + let test_env = HashMap::from([("TERM_PROGRAM", "vscode"), ("VSCODE_NONCE", "test_nonce")]); + + let term_info = get_terminal_info(&test_env); + assert_matches!(term_info.terminal, Some(KnownTerminal::VSCode)); + assert!(term_info.supports_osc_633); + assert_eq!(term_info.session_nonce, Some("test_nonce".to_string())); + } + + #[test] + fn windows_terminal_recognition() { + let test_env = HashMap::from([("WT_SESSION", "some_value")]); + + let term_info = get_terminal_info(&test_env); + assert_matches!(term_info.terminal, Some(KnownTerminal::WindowsTerminal)); + assert!(term_info.supports_osc_9001); + } +} diff --git a/local/recipes/shells/brush/source/brush-interactive/src/term_integration.rs b/local/recipes/shells/brush/source/brush-interactive/src/term_integration.rs new file mode 100644 index 0000000000..cc89b49ac8 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-interactive/src/term_integration.rs @@ -0,0 +1,232 @@ +use std::borrow::Cow; +use std::fmt::Write; + +use crate::term_detection; + +/// Utility for integrating with terminal emulators. +#[derive(Default)] +pub(crate) struct TerminalIntegration { + /// Info about the hosting terminal. + term: term_detection::TerminalInfo, +} + +#[allow(dead_code)] +impl TerminalIntegration { + /// Creates a new terminal integration utility. + /// + /// # Arguments + /// + /// * `term_info` - Information about the terminal capabilities. + pub const fn new(term_info: term_detection::TerminalInfo) -> Self { + Self { term: term_info } + } + + /// Returns the terminal escape sequence that should be emitted to initialize terminal + /// integration. + pub fn initialize(&self) -> Cow<'_, str> { + if self.term.supports_osc_633 { + "\x1b]633;P;HasRichCommandDetection=True\x1b\\".into() + } else { + "".into() + } + } + + /// Returns the terminal escape sequence that should be emitted before the prompt. + pub fn pre_prompt(&self) -> Cow<'_, str> { + if self.term.supports_osc_633 { + "\x1b]633;A\x1b\\".into() + } else { + "".into() + } + } + + /// Returns the terminal escape sequence to report the current working directory. + pub fn report_cwd(&self, cwd: &std::path::Path) -> Cow<'_, str> { + if self.term.supports_osc_633 { + let escaped_cwd_str = osc_633_escape(cwd.to_string_lossy().as_ref()); + format!("\x1b]633;P;Cwd={escaped_cwd_str}\x1b\\").into() + } else { + "".into() + } + } + + /// Returns the terminal escape sequence that should be emitted before executing a command, + /// but after the prompt and the user has finished entering input. + /// + /// # Arguments + /// + /// * `command` - The command that is about to be executed. + pub fn pre_exec_command(&self, command: &str) -> Cow<'_, str> { + if self.term.supports_osc_633 { + let mut escaped_command = osc_633_escape(command); + escaped_command.insert_str(0, "\x1b]633;E;"); + + if let Some(session_nonce) = &self.term.session_nonce { + escaped_command.push(';'); + escaped_command.push_str(session_nonce); + } + + escaped_command.push_str("\x1b\\\x1b]633;C\x1b\\"); + + escaped_command.into() + } else { + "".into() + } + } + + /// Returns the terminal escape sequence that should be emitted after executing a command. + pub fn post_exec_command(&self, exit_code: i32) -> Cow<'_, str> { + if self.term.supports_osc_633 { + std::format!("\x1b]633;D;{exit_code}\x1b\\").into() + } else { + "".into() + } + } + + /// Returns the terminal escape sequence that should be emitted after the prompt. + pub fn post_prompt(&self) -> Cow<'_, str> { + if self.term.supports_osc_633 { + "\x1b]633;B\x1b\\".into() + } else { + "".into() + } + } + + /// Returns the terminal escape sequence that should be emitted before the continuation prompt. + pub fn pre_input_line_continuation(&self) -> Cow<'_, str> { + if self.term.supports_osc_633 { + "\x1b]633;F\x1b\\".into() + } else { + "".into() + } + } + + /// Returns the terminal escape sequence that should be emitted after the input line + /// continuation. + pub fn post_input_line_continuation(&self) -> Cow<'_, str> { + if self.term.supports_osc_633 { + "\x1b]633;G\x1b\\".into() + } else { + "".into() + } + } + + /// Returns the terminal escape sequence that should be emitted before the right-side prompt. + pub fn pre_right_prompt(&self) -> Cow<'_, str> { + if self.term.supports_osc_633 { + "\x1b]633;H\x1b\\".into() + } else { + "".into() + } + } + + /// Returns the terminal escape sequence that should be emitted after the right-side prompt. + pub fn post_right_prompt(&self) -> Cow<'_, str> { + if self.term.supports_osc_633 { + "\x1b]633;I\x1b\\".into() + } else { + "".into() + } + } +} + +/// Escapes a string for safe inclusion in an OSC 633 escape sequence. +/// Reference: +fn osc_633_escape(command: &str) -> String { + let mut result = String::new(); + + for c in command.chars() { + match c { + // Escape ASCII control characters (< 0x1f, i.e., < 31) + '\x00'..='\x1e' => { + let _ = write!(result, r"\x{:02x}", c as u8); + } + // Escape backslash with an extra prefixed backslash + '\\' => result.push_str(r"\\"), + // Escape semicolon via \xNN syntax (like control chars) + ';' => result.push_str(r"\x3b"), + // Keep other characters as-is + _ => result.push(c), + } + } + + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn osc_633_escape_basic() { + // Test simple alphanumeric string + assert_eq!(osc_633_escape("echo hello"), "echo hello"); + assert_eq!(osc_633_escape("ls -la"), "ls -la"); + } + + #[test] + fn osc_633_escape_semicolon() { + // Semicolons should be escaped + assert_eq!(osc_633_escape("cmd1; cmd2"), r"cmd1\x3b cmd2"); + assert_eq!(osc_633_escape(";"), r"\x3b"); + assert_eq!(osc_633_escape("a;b;c"), r"a\x3bb\x3bc"); + } + + #[test] + fn osc_633_escape_backslash() { + // Backslashes should be escaped + assert_eq!(osc_633_escape(r"echo \n"), r"echo \\n"); + assert_eq!(osc_633_escape(r"\"), r"\\"); + assert_eq!(osc_633_escape(r"C:\path\to\file"), r"C:\\path\\to\\file"); + } + + #[test] + fn osc_633_escape_control_chars() { + // ASCII control characters (0x00-0x1e, i.e., 0-30) should be escaped + assert_eq!(osc_633_escape("\x00"), r"\x00"); + assert_eq!(osc_633_escape("\x01"), r"\x01"); + assert_eq!(osc_633_escape("\t"), r"\x09"); // tab + assert_eq!(osc_633_escape("\n"), r"\x0a"); // newline + assert_eq!(osc_633_escape("\r"), r"\x0d"); // carriage return + assert_eq!(osc_633_escape("\x1e"), r"\x1e"); // last control char (30) + + // 0x1f (31) should NOT be escaped as a control char (not < 31) + assert_eq!(osc_633_escape("\x1f"), "\x1f"); + + // Space (0x20, 32) should NOT be escaped + assert_eq!(osc_633_escape(" "), " "); + } + + #[test] + fn osc_633_escape_mixed() { + // Test combinations of different escape scenarios + assert_eq!( + osc_633_escape("echo\nhello; world\\n"), + r"echo\x0ahello\x3b world\\n" + ); + + assert_eq!(osc_633_escape("cmd\t\t; \\path"), r"cmd\x09\x09\x3b \\path"); + + // Test with null bytes + assert_eq!(osc_633_escape("a\x00b\x01c"), r"a\x00b\x01c"); + + // Test all three special cases together + assert_eq!(osc_633_escape("\\;\n"), r"\\\x3b\x0a"); + } + + #[test] + fn osc_633_escape_empty() { + assert_eq!(osc_633_escape(""), ""); + } + + #[test] + fn osc_633_escape_unicode() { + // Unicode characters should pass through unchanged + assert_eq!(osc_633_escape("echo 你好"), "echo 你好"); + assert_eq!(osc_633_escape("café"), "café"); + assert_eq!(osc_633_escape("🦀"), "🦀"); + + // But should still escape special chars + assert_eq!(osc_633_escape("你好;世界"), r"你好\x3b世界"); + } +} diff --git a/local/recipes/shells/brush/source/brush-interactive/src/trace_categories.rs b/local/recipes/shells/brush/source/brush-interactive/src/trace_categories.rs new file mode 100644 index 0000000000..b4fab32833 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-interactive/src/trace_categories.rs @@ -0,0 +1,3 @@ +#![allow(dead_code)] + +pub(crate) const COMPLETION: &str = "completion"; diff --git a/local/recipes/shells/brush/source/brush-parser/Cargo.toml b/local/recipes/shells/brush/source/brush-parser/Cargo.toml new file mode 100644 index 0000000000..64ff7fcb93 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/Cargo.toml @@ -0,0 +1,66 @@ +[package] +name = "brush-parser" +description = "POSIX/bash shell tokenizer and parsers (used by brush-shell)" +version = "0.4.0" +authors.workspace = true +categories.workspace = true +edition.workspace = true +keywords.workspace = true +license.workspace = true +readme.workspace = true +repository.workspace = true +rust-version.workspace = true + +[lints] +workspace = true + +[lib] +bench = false + +[features] +arbitrary = ["dep:arbitrary"] +debug-tracing = ["peg/trace"] +diagnostics = ["dep:miette"] +serde = ["dep:serde"] +winnow-parser = ["dep:winnow"] + +[dependencies] +arbitrary = { version = "1.4.2", optional = true, features = ["derive"] } +bon = "3.9.1" +cached = "0.59.0" +indenter = "0.3.4" +miette = { version = "7.6.0", optional = true, default-features = false, features = [ + "derive", +] } +peg = "0.8.6" +serde = { version = "1.0.228", optional = true, features = ["derive", "rc"] } +thiserror = "2.0.18" +tracing = "0.1.44" +utf8-chars = "3.0.6" +winnow = { version = "1.0.0", optional = true } + +[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" +criterion = { version = "0.8.2", features = ["html_reports"] } +insta = { version = "1.47.1", features = ["glob", "ron", "yaml", "redactions"] } +miette = { version = "7.6.0", features = ["fancy"] } +pretty_assertions = { version = "1.4.1", features = ["unstable"] } +serde = { version = "1.0.228", features = ["derive", "rc"] } +serde_json = "1.0.149" +serde_yaml = "0.9.34" + +[[bench]] +name = "parser" +harness = false + +[[example]] +name = "miette" +required-features = ["diagnostics"] + +[[example]] +name = "serde" +required-features = ["serde"] diff --git a/local/recipes/shells/brush/source/brush-parser/LICENSE b/local/recipes/shells/brush/source/brush-parser/LICENSE new file mode 120000 index 0000000000..ea5b60640b --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/LICENSE @@ -0,0 +1 @@ +../LICENSE \ No newline at end of file diff --git a/local/recipes/shells/brush/source/brush-parser/benches/parser.rs b/local/recipes/shells/brush/source/brush-parser/benches/parser.rs new file mode 100644 index 0000000000..3b7af52fa3 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/benches/parser.rs @@ -0,0 +1,280 @@ +//! Benchmarks for the brush-parser crate. +//! +//! Compares parsing approaches: +//! 1. PEG parser (tokenize + peg parse) +//! 2. `Winnow_str` parser (direct string parse) - when winnow-parser feature enabled + +#![allow(missing_docs)] +#![allow(clippy::unwrap_used)] + +#[cfg(unix)] +mod unix { + use brush_parser::Token; + use criterion::Criterion; + + fn uncached_tokenize(content: &str) -> Vec { + brush_parser::uncached_tokenize_str(content, &brush_parser::TokenizerOptions::default()) + .unwrap() + } + + fn cacheable_tokenize(content: &str) -> Vec { + brush_parser::tokenize_str_with_options(content, &brush_parser::TokenizerOptions::default()) + .unwrap() + } + + fn parse_peg(tokens: &[Token]) -> brush_parser::ast::Program { + brush_parser::parse_tokens(tokens, &brush_parser::ParserOptions::default()).unwrap() + } + + #[cfg(feature = "winnow-parser")] + fn parse_winnow_str(content: &str) -> brush_parser::ast::Program { + use brush_parser::{ParserOptions, SourceInfo, winnow_str}; + winnow_str::parse_program(content, &ParserOptions::default(), &SourceInfo::default()) + .unwrap() + } + + // Combined tokenize + parse functions for full pipeline comparison + fn tokenize_and_parse_peg(content: &str) -> brush_parser::ast::Program { + let tokens = uncached_tokenize(content); + parse_peg(&tokens) + } + + const SAMPLE_SCRIPT: &str = r#" +for f in A B C; do + echo "${f@L}" >&2 +done +"#; + + const SIMPLE_SCRIPT: &str = "echo hello world"; + + const PIPELINE_SCRIPT: &str = "cat file.txt | grep pattern | wc -l"; + + const COMPLEX_SCRIPT: &str = r#" +#!/bin/bash +# Complex script with multiple constructs + +function process_file() { + local file="$1" + if [[ -f "$file" ]]; then + while read -r line; do + case "$line" in + start*) + echo "Starting: $line" + ;; + end*) + echo "Ending: $line" + ;; + *) + echo "Processing: $line" + ;; + esac + done < "$file" + fi +} + +for i in {1..10}; do + if (( i % 2 == 0 )); then + echo "$i is even" | tee -a output.txt + else + echo "$i is odd" >> output.txt + fi +done + +process_file "input.txt" && echo "Success" || echo "Failed" +"#; + + const NESTED_EXPANSIONS_SCRIPT: &str = r" +# Script with deeply nested expansions (tests balanced delimiter parsing) +result=$(echo $(echo $((1 + (2 * (3 - 4)))))) +fallback=${foo:-${bar:-${baz}}} +arithmetic=$((1 + (2 * (3 + (4 - 5))))) +command_subst=$(ls $(pwd)) +mixed=$(echo $((1 + 2)) | cat) +backtick=`echo (nested parens)` +"; + + // Extended test expression benchmarks - various patterns + #[allow(dead_code)] + const EXTENDED_TEST_SIMPLE: &str = "[[ -f file.txt ]]"; + #[allow(dead_code)] + const EXTENDED_TEST_BINARY: &str = "[[ $a == $b ]]"; + #[allow(dead_code)] + const EXTENDED_TEST_REGEX: &str = "[[ $str =~ ^[0-9]+$ ]]"; + #[allow(dead_code)] + const EXTENDED_TEST_COMPLEX_REGEX: &str = "[[ $input =~ ^(foo|bar)[0-9]+(baz|qux)$ ]]"; + #[allow(dead_code)] + const EXTENDED_TEST_LOGICAL: &str = "[[ -f file.txt && -r file.txt || -w other.txt ]]"; + #[allow(dead_code)] + const EXTENDED_TEST_NESTED: &str = "[[ ( -f $file && -r $file ) || ( -d $dir && -x $dir ) ]]"; + #[allow(dead_code)] + const EXTENDED_TEST_COMPLEX: &str = + "[[ ! ( $a -eq 5 && $b -gt 10 ) || ( $c =~ pattern && -f $file ) ]]"; + + fn benchmark_parsing_script_using_caches(c: &mut Criterion, script_path: &std::path::Path) { + let contents = std::fs::read_to_string(script_path).unwrap(); + let filename = script_path.file_name().unwrap().to_string_lossy(); + + c.bench_function(std::format!("parse_peg_{filename}").as_str(), |b| { + b.iter(|| parse_peg(&cacheable_tokenize(contents.as_str()))); + }); + } + + pub(crate) fn criterion_benchmark(c: &mut Criterion) { + const POSSIBLE_BASH_COMPLETION_SCRIPT_PATH: &str = + "/usr/share/bash-completion/bash_completion"; + + // Tokenization benchmark (applies to both parsers) + c.bench_function("tokenize_sample_script", |b| { + b.iter(|| uncached_tokenize(SAMPLE_SCRIPT)); + }); + + // Simple script benchmarks + let simple_tokens = uncached_tokenize(SIMPLE_SCRIPT); + c.bench_function("parse_peg_simple", |b| b.iter(|| parse_peg(&simple_tokens))); + #[cfg(feature = "winnow-parser")] + c.bench_function("parse_winnow_str_simple", |b| { + b.iter(|| parse_winnow_str(SIMPLE_SCRIPT)); + }); + + // Pipeline script benchmarks + let pipeline_tokens = uncached_tokenize(PIPELINE_SCRIPT); + c.bench_function("parse_peg_pipeline", |b| { + b.iter(|| parse_peg(&pipeline_tokens)); + }); + #[cfg(feature = "winnow-parser")] + c.bench_function("parse_winnow_str_pipeline", |b| { + b.iter(|| parse_winnow_str(PIPELINE_SCRIPT)); + }); + + // Sample script (for loop) benchmarks + let sample_tokens = uncached_tokenize(SAMPLE_SCRIPT); + c.bench_function("parse_peg_for_loop", |b| { + b.iter(|| parse_peg(&sample_tokens)); + }); + #[cfg(feature = "winnow-parser")] + c.bench_function("parse_winnow_str_for_loop", |b| { + b.iter(|| parse_winnow_str(SAMPLE_SCRIPT)); + }); + + // Complex script benchmarks + let complex_tokens = uncached_tokenize(COMPLEX_SCRIPT); + c.bench_function("parse_peg_complex", |b| { + b.iter(|| parse_peg(&complex_tokens)); + }); + #[cfg(feature = "winnow-parser")] + c.bench_function("parse_winnow_str_complex", |b| { + b.iter(|| parse_winnow_str(COMPLEX_SCRIPT)); + }); + + // Real-world bash completion script (if available) + let well_known_complicated_script = + std::path::PathBuf::from(POSSIBLE_BASH_COMPLETION_SCRIPT_PATH); + + if well_known_complicated_script.exists() { + benchmark_parsing_script_using_caches(c, &well_known_complicated_script); + } + + // ======================================================================== + // FULL PIPELINE BENCHMARKS (tokenize + parse) + // ======================================================================== + // These benchmarks measure the complete parsing pipeline from string to AST, + // allowing fair comparison between different approaches: + // - tokenize_and_parse_peg: Legacy tokenizer + PEG parser + // - parse_winnow_str: Direct string parsing (no separate tokenization) + + // Simple script full pipeline + c.bench_function("full_peg_simple", |b| { + b.iter(|| tokenize_and_parse_peg(SIMPLE_SCRIPT)); + }); + #[cfg(feature = "winnow-parser")] + c.bench_function("full_winnow_str_simple", |b| { + b.iter(|| parse_winnow_str(SIMPLE_SCRIPT)); + }); + + // Pipeline script full pipeline + c.bench_function("full_peg_pipeline", |b| { + b.iter(|| tokenize_and_parse_peg(PIPELINE_SCRIPT)); + }); + #[cfg(feature = "winnow-parser")] + c.bench_function("full_winnow_str_pipeline", |b| { + b.iter(|| parse_winnow_str(PIPELINE_SCRIPT)); + }); + + // For loop full pipeline + c.bench_function("full_peg_for_loop", |b| { + b.iter(|| tokenize_and_parse_peg(SAMPLE_SCRIPT)); + }); + #[cfg(feature = "winnow-parser")] + c.bench_function("full_winnow_str_for_loop", |b| { + b.iter(|| parse_winnow_str(SAMPLE_SCRIPT)); + }); + + // Complex script full pipeline + c.bench_function("full_peg_complex", |b| { + b.iter(|| tokenize_and_parse_peg(COMPLEX_SCRIPT)); + }); + #[cfg(feature = "winnow-parser")] + c.bench_function("full_winnow_str_complex", |b| { + b.iter(|| parse_winnow_str(COMPLEX_SCRIPT)); + }); + + // Nested expansions (balanced delimiter parsing stress test) + c.bench_function("full_peg_nested_expansions", |b| { + b.iter(|| tokenize_and_parse_peg(NESTED_EXPANSIONS_SCRIPT)); + }); + #[cfg(feature = "winnow-parser")] + c.bench_function("full_winnow_str_nested_expansions", |b| { + b.iter(|| parse_winnow_str(NESTED_EXPANSIONS_SCRIPT)); + }); + + // ======================================================================== + // EXTENDED TEST EXPRESSION BENCHMARKS + // ======================================================================== + // Benchmarks for the refactored extended test ([[ ]]) parser + // Tests various patterns: simple, binary, regex, logical operators, nesting + + #[cfg(feature = "winnow-parser")] + { + c.bench_function("extended_test_simple", |b| { + b.iter(|| parse_winnow_str(EXTENDED_TEST_SIMPLE)); + }); + + c.bench_function("extended_test_binary", |b| { + b.iter(|| parse_winnow_str(EXTENDED_TEST_BINARY)); + }); + + c.bench_function("extended_test_regex", |b| { + b.iter(|| parse_winnow_str(EXTENDED_TEST_REGEX)); + }); + + c.bench_function("extended_test_complex_regex", |b| { + b.iter(|| parse_winnow_str(EXTENDED_TEST_COMPLEX_REGEX)); + }); + + c.bench_function("extended_test_logical", |b| { + b.iter(|| parse_winnow_str(EXTENDED_TEST_LOGICAL)); + }); + + c.bench_function("extended_test_nested", |b| { + b.iter(|| parse_winnow_str(EXTENDED_TEST_NESTED)); + }); + + c.bench_function("extended_test_complex", |b| { + b.iter(|| parse_winnow_str(EXTENDED_TEST_COMPLEX)); + }); + } + } +} + +#[cfg(unix)] +criterion::criterion_group! { + name = benches; + config = criterion::Criterion::default(); + targets = unix::criterion_benchmark +} + +#[cfg(unix)] +criterion::criterion_main!(benches); + +#[cfg(not(unix))] +fn main() {} diff --git a/local/recipes/shells/brush/source/brush-parser/examples/miette.rs b/local/recipes/shells/brush/source/brush-parser/examples/miette.rs new file mode 100644 index 0000000000..729c1fe788 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/examples/miette.rs @@ -0,0 +1,24 @@ +//! Simple example of miette usage + +use std::io::Cursor; + +use brush_parser::Parser; +use miette::{IntoDiagnostic, miette}; + +fn main() -> miette::Result<()> { + let f = std::env::args() + .nth(1) + .ok_or_else(|| miette!("Please provide a file name"))?; + + let source = std::fs::read_to_string(&f).into_diagnostic()?; + let reader = Cursor::new(&source); + let mut parser = Parser::builder().build(reader); + + let ast = parser + .parse_program() + .map_err(|e| e.to_pretty_error(&source))?; + + println!("{ast:#?}"); + + Ok(()) +} diff --git a/local/recipes/shells/brush/source/brush-parser/examples/serde.rs b/local/recipes/shells/brush/source/brush-parser/examples/serde.rs new file mode 100644 index 0000000000..ee7facf45d --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/examples/serde.rs @@ -0,0 +1,31 @@ +//! Example demonstrating AST serialization and deserialization with the `serde` feature. +//! +//! Run with: `cargo run --package brush-parser --example serde --features serde` + +use brush_parser::{Parser, ParserOptions}; +use std::io::BufReader; + +fn main() -> Result<(), Box> { + // Parse a simple shell command + let input = "echo 'Hello, World!' && ls -la"; + let reader = BufReader::new(input.as_bytes()); + let options = ParserOptions::default(); + + let mut parser = Parser::new(reader, &options); + let program = parser.parse_program()?; + + // Serialize the AST to JSON + let json = serde_json::to_string_pretty(&program)?; + println!("Parsed AST:"); + println!("{json}"); + + // Demonstrate round-trip: deserialize the JSON back to AST + println!("\nRound-trip deserialization:"); + let deserialized: brush_parser::ast::Program = serde_json::from_str(&json)?; + println!( + "Successfully deserialized AST with {} command(s)", + deserialized.complete_commands.len() + ); + + Ok(()) +} diff --git a/local/recipes/shells/brush/source/brush-parser/src/arithmetic.rs b/local/recipes/shells/brush/source/brush-parser/src/arithmetic.rs new file mode 100644 index 0000000000..ffa0bd106f --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/arithmetic.rs @@ -0,0 +1,171 @@ +//! Parser for shell arithmetic expressions. + +use crate::ast; +use crate::error; + +/// Parses a shell arithmetic expression. +/// +/// # Arguments +/// +/// * `input` - The arithmetic expression to parse, in string form. +pub fn parse(input: &str) -> Result { + cacheable_parse(input.to_owned()) +} + +#[cached::proc_macro::cached(size = 64, result = true)] +fn cacheable_parse(input: String) -> Result { + tracing::debug!(target: "arithmetic", "parsing arithmetic expression: '{input}'"); + arithmetic::full_expression(input.as_str()) + .map_err(|e| error::WordParseError::ArithmeticExpression(e.into())) +} + +peg::parser! { + grammar arithmetic() for str { + pub(crate) rule full_expression() -> ast::ArithmeticExpr = + ![_] { ast::ArithmeticExpr::Literal(0) } / + _ e:expression() _ { e } + + pub(crate) rule expression() -> ast::ArithmeticExpr = precedence!{ + x:(@) _ "," _ y:@ { ast::ArithmeticExpr::BinaryOp(ast::BinaryOperator::Comma, Box::new(x), Box::new(y)) } + -- + x:lvalue() _ "*=" _ y:(@) { ast::ArithmeticExpr::BinaryAssignment(ast::BinaryOperator::Multiply, x, Box::new(y)) } + x:lvalue() _ "/=" _ y:(@) { ast::ArithmeticExpr::BinaryAssignment(ast::BinaryOperator::Divide, x, Box::new(y)) } + x:lvalue() _ "%=" _ y:(@) { ast::ArithmeticExpr::BinaryAssignment(ast::BinaryOperator::Modulo, x, Box::new(y)) } + x:lvalue() _ "+=" _ y:(@) { ast::ArithmeticExpr::BinaryAssignment(ast::BinaryOperator::Add, x, Box::new(y)) } + x:lvalue() _ "-=" _ y:(@) { ast::ArithmeticExpr::BinaryAssignment(ast::BinaryOperator::Subtract, x, Box::new(y)) } + x:lvalue() _ "<<=" _ y:(@) { ast::ArithmeticExpr::BinaryAssignment(ast::BinaryOperator::ShiftLeft, x, Box::new(y)) } + x:lvalue() _ ">>=" _ y:(@) { ast::ArithmeticExpr::BinaryAssignment(ast::BinaryOperator::ShiftRight, x, Box::new(y)) } + x:lvalue() _ "&=" _ y:(@) { ast::ArithmeticExpr::BinaryAssignment(ast::BinaryOperator::BitwiseAnd, x, Box::new(y)) } + x:lvalue() _ "|=" _ y:(@) { ast::ArithmeticExpr::BinaryAssignment(ast::BinaryOperator::BitwiseOr, x, Box::new(y)) } + x:lvalue() _ "^=" _ y:(@) { ast::ArithmeticExpr::BinaryAssignment(ast::BinaryOperator::BitwiseXor, x, Box::new(y)) } + x:lvalue() _ "=" _ y:(@) { ast::ArithmeticExpr::Assignment(x, Box::new(y)) } + -- + x:@ _ "?" _ y:expression() _ ":" _ z:(@) { ast::ArithmeticExpr::Conditional(Box::new(x), Box::new(y), Box::new(z)) } + -- + x:(@) _ "||" _ y:@ { ast::ArithmeticExpr::BinaryOp(ast::BinaryOperator::LogicalOr, Box::new(x), Box::new(y)) } + -- + x:(@) _ "&&" _ y:@ { ast::ArithmeticExpr::BinaryOp(ast::BinaryOperator::LogicalAnd, Box::new(x), Box::new(y)) } + -- + x:(@) _ "|" _ y:@ { ast::ArithmeticExpr::BinaryOp(ast::BinaryOperator::BitwiseOr, Box::new(x), Box::new(y)) } + -- + x:(@) _ "^" _ y:@ { ast::ArithmeticExpr::BinaryOp(ast::BinaryOperator::BitwiseXor, Box::new(x), Box::new(y)) } + -- + x:(@) _ "&" _ y:@ { ast::ArithmeticExpr::BinaryOp(ast::BinaryOperator::BitwiseAnd, Box::new(x), Box::new(y)) } + -- + x:(@) _ "==" _ y:@ { ast::ArithmeticExpr::BinaryOp(ast::BinaryOperator::Equals, Box::new(x), Box::new(y)) } + x:(@) _ "!=" _ y:@ { ast::ArithmeticExpr::BinaryOp(ast::BinaryOperator::NotEquals, Box::new(x), Box::new(y)) } + -- + x:(@) _ "<" _ y:@ { ast::ArithmeticExpr::BinaryOp(ast::BinaryOperator::LessThan, Box::new(x), Box::new(y)) } + x:(@) _ ">" _ y:@ { ast::ArithmeticExpr::BinaryOp(ast::BinaryOperator::GreaterThan, Box::new(x), Box::new(y)) } + x:(@) _ "<=" _ y:@ { ast::ArithmeticExpr::BinaryOp(ast::BinaryOperator::LessThanOrEqualTo, Box::new(x), Box::new(y)) } + x:(@) _ ">=" _ y:@ { ast::ArithmeticExpr::BinaryOp(ast::BinaryOperator::GreaterThanOrEqualTo, Box::new(x), Box::new(y)) } + -- + x:(@) _ "<<" _ y:@ { ast::ArithmeticExpr::BinaryOp(ast::BinaryOperator::ShiftLeft, Box::new(x), Box::new(y)) } + x:(@) _ ">>" _ y:@ { ast::ArithmeticExpr::BinaryOp(ast::BinaryOperator::ShiftRight, Box::new(x), Box::new(y)) } + -- + x:(@) _ "+" _ y:@ { ast::ArithmeticExpr::BinaryOp(ast::BinaryOperator::Add, Box::new(x), Box::new(y)) } + x:(@) _ "-" _ y:@ { ast::ArithmeticExpr::BinaryOp(ast::BinaryOperator::Subtract, Box::new(x), Box::new(y)) } + -- + x:(@) _ "*" _ y:@ { ast::ArithmeticExpr::BinaryOp(ast::BinaryOperator::Multiply, Box::new(x), Box::new(y)) } + x:(@) _ "%" _ y:@ { ast::ArithmeticExpr::BinaryOp(ast::BinaryOperator::Modulo, Box::new(x), Box::new(y)) } + x:(@) _ "/" _ y:@ { ast::ArithmeticExpr::BinaryOp(ast::BinaryOperator::Divide, Box::new(x), Box::new(y)) } + -- + x:@ _ "**" _ y:(@) { ast::ArithmeticExpr::BinaryOp(ast::BinaryOperator::Power, Box::new(x), Box::new(y)) } + -- + "!" _ x:(@) { ast::ArithmeticExpr::UnaryOp(ast::UnaryOperator::LogicalNot, Box::new(x)) } + "~" _ x:(@) { ast::ArithmeticExpr::UnaryOp(ast::UnaryOperator::BitwiseNot, Box::new(x)) } + -- + // NOTE: We add negative lookahead to avoid ambiguity with the pre-increment/pre-decrement operators. + "+" !['+'] _ x:(@) { ast::ArithmeticExpr::UnaryOp(ast::UnaryOperator::UnaryPlus, Box::new(x)) } + "-" !['-'] _ x:(@) { ast::ArithmeticExpr::UnaryOp(ast::UnaryOperator::UnaryMinus, Box::new(x)) } + -- + "++" _ x:lvalue() { ast::ArithmeticExpr::UnaryAssignment(ast::UnaryAssignmentOperator::PrefixIncrement, x) } + "--" _ x:lvalue() { ast::ArithmeticExpr::UnaryAssignment(ast::UnaryAssignmentOperator::PrefixDecrement, x) } + -- + x:lvalue() _ "++" { ast::ArithmeticExpr::UnaryAssignment(ast::UnaryAssignmentOperator::PostfixIncrement, x) } + x:lvalue() _ "--" { ast::ArithmeticExpr::UnaryAssignment(ast::UnaryAssignmentOperator::PostfixDecrement, x) } + -- + n:literal_number() { ast::ArithmeticExpr::Literal(n) } + l:lvalue() { ast::ArithmeticExpr::Reference(l) } + "(" _ expr:expression() _ ")" { expr } + } + + rule lvalue() -> ast::ArithmeticTarget = + name:variable_name() "[" index:expression() "]" { + ast::ArithmeticTarget::ArrayElement(name.to_owned(), Box::new(index)) + } / + name:variable_name() { + ast::ArithmeticTarget::Variable(name.to_owned()) + } + + rule variable_name() -> &'input str = + $(['a'..='z' | 'A'..='Z' | '_'](['a'..='z' | 'A'..='Z' | '_' | '0'..='9']*)) + + rule _() -> () = quiet!{[' ' | '\t' | '\n' | '\r']*} {} + + rule literal_number() -> i64 = + // Literal with explicit radix (format: #) + radix:decimal_literal() "#" s:$(['0'..='9' | 'a'..='z' | 'A'..='Z' | '@' | '_']+) {? + parse_shell_literal_number(s, radix.cast_unsigned()) + } / + // Hex literal + "0" ['x' | 'X'] s:$(['0'..='9' | 'a'..='f' | 'A'..='F']*) {? + i64::from_str_radix(s, 16).or(Err("i64")) + } / + // Octal literal + s:$("0" ['0'..='8']*) {? + i64::from_str_radix(s, 8).or(Err("i64")) + } / + // Decimal literal + decimal_literal() + + rule decimal_literal() -> i64 = + s:$(['1'..='9'] ['0'..='9']*) {? + // Parse as u64 first, then cast to i64. This handles values like + // 9223372036854775808 (i64::MAX + 1) which is needed for INT64_MIN + // when preceded by unary minus: -(9223372036854775808) wraps to i64::MIN. + s.parse::().map(|v| v.cast_signed()).or(Err("i64")) + } + } +} + +fn parse_shell_literal_number(s: &str, radix: u64) -> Result { + if !(2..=64).contains(&radix) { + return Err("invalid base"); + } + + // For bases <= 36: case-insensitive (a-z and A-Z both map to 10-35) + // For bases > 36 (bash extension): + // 0-9 = 0-9, a-z = 10-35, A-Z = 36-61, @ = 62, _ = 63 + let mut result: i64 = 0; + + for ch in s.chars() { + let digit_val = if radix <= 36 { + match ch { + '0'..='9' => (ch as u64) - ('0' as u64), + 'a'..='z' => (ch as u64) - ('a' as u64) + 10, + 'A'..='Z' => (ch as u64) - ('A' as u64) + 10, + _ => return Err("invalid digit"), + } + } else { + match ch { + '0'..='9' => (ch as u64) - ('0' as u64), + 'a'..='z' => (ch as u64) - ('a' as u64) + 10, + 'A'..='Z' => (ch as u64) - ('A' as u64) + 36, + '@' => 62, + '_' => 63, + _ => return Err("invalid digit"), + } + }; + + if digit_val >= radix { + return Err("value too great for base"); + } + + result = result + .wrapping_mul(radix.cast_signed()) + .wrapping_add(digit_val.cast_signed()); + } + + Ok(result) +} diff --git a/local/recipes/shells/brush/source/brush-parser/src/ast.rs b/local/recipes/shells/brush/source/brush-parser/src/ast.rs new file mode 100644 index 0000000000..c41a261bd7 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/ast.rs @@ -0,0 +1,2450 @@ +//! Defines the Abstract Syntax Tree (ast) for shell programs. Includes types and utilities +//! for manipulating the AST. + +use std::fmt::{Display, Write}; + +use crate::{SourceSpan, tokenizer}; + +const DISPLAY_INDENT: &str = " "; + +/// Trait implemented by all AST nodes. Used to aggregate traits expected +/// to be implemented. +pub trait Node: Display + SourceLocation {} + +/// Provides the source location for the syntax item +pub trait SourceLocation { + /// The location of the syntax item, when known + fn location(&self) -> Option; +} + +pub(crate) fn maybe_location( + start: Option<&SourceSpan>, + end: Option<&SourceSpan>, +) -> Option { + if let (Some(s), Some(e)) = (start, end) { + Some(SourceSpan::within(s, e)) + } else { + None + } +} + +/// Represents a complete shell program. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct Program { + /// A sequence of complete shell commands. + pub complete_commands: Vec, +} + +impl Node for Program {} + +impl SourceLocation for Program { + fn location(&self) -> Option { + let start = self + .complete_commands + .first() + .and_then(SourceLocation::location); + let end = self + .complete_commands + .last() + .and_then(SourceLocation::location); + maybe_location(start.as_ref(), end.as_ref()) + } +} + +impl Display for Program { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + for complete_command in &self.complete_commands { + write!(f, "{complete_command}")?; + } + Ok(()) + } +} + +/// Represents a complete shell command. +pub type CompleteCommand = CompoundList; + +/// Represents a complete shell command item. +pub type CompleteCommandItem = CompoundListItem; + +// TODO(tracing): decide if we want to trace this location or consider it a whitespace separator +/// Indicates whether the preceding command is executed synchronously or asynchronously. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum SeparatorOperator { + /// The preceding command is executed asynchronously. + Async, + /// The preceding command is executed synchronously. + Sequence, +} + +impl Display for SeparatorOperator { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Async => write!(f, "&"), + Self::Sequence => write!(f, ";"), + } + } +} + +/// Represents a sequence of command pipelines connected by boolean operators. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct AndOrList { + /// The first command pipeline. + pub first: Pipeline, + /// Any additional command pipelines, in sequence order. + #[cfg_attr( + any(test, feature = "serde"), + serde(skip_serializing_if = "Vec::is_empty", default) + )] + pub additional: Vec, +} + +impl Node for AndOrList {} + +impl SourceLocation for AndOrList { + fn location(&self) -> Option { + let start = self.first.location(); + let last = self.additional.last(); + let end = last.and_then(SourceLocation::location); + + match (start, end) { + (Some(s), Some(e)) => Some(SourceSpan::within(&s, &e)), + (start, _) => start, + } + } +} + +impl Display for AndOrList { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.first)?; + for item in &self.additional { + write!(f, "{item}")?; + } + + Ok(()) + } +} + +/// Represents a boolean operator used to connect command pipelines in an [`AndOrList`] +#[derive(PartialEq, Eq)] +pub enum PipelineOperator { + /// The command pipelines are connected by a boolean AND operator. + And, + /// The command pipelines are connected by a boolean OR operator. + Or, +} + +impl PartialEq for PipelineOperator { + fn eq(&self, other: &AndOr) -> bool { + matches!( + (self, other), + (Self::And, AndOr::And(_)) | (Self::Or, AndOr::Or(_)) + ) + } +} + +// We cannot losslessly convert into `AndOr`, hence we can only do `Into`. +#[expect(clippy::from_over_into)] +impl Into for AndOr { + fn into(self) -> PipelineOperator { + match self { + Self::And(_) => PipelineOperator::And, + Self::Or(_) => PipelineOperator::Or, + } + } +} + +/// An iterator over the pipelines in an [`AndOrList`]. +pub struct AndOrListIter<'a> { + first: Option<&'a Pipeline>, + additional_iter: std::slice::Iter<'a, AndOr>, +} + +impl<'a> Iterator for AndOrListIter<'a> { + type Item = (PipelineOperator, &'a Pipeline); + + fn next(&mut self) -> Option { + if let Some(first) = self.first.take() { + Some((PipelineOperator::And, first)) + } else { + self.additional_iter.next().map(|and_or| match and_or { + AndOr::And(pipeline) => (PipelineOperator::And, pipeline), + AndOr::Or(pipeline) => (PipelineOperator::Or, pipeline), + }) + } + } +} + +impl<'a> IntoIterator for &'a AndOrList { + type Item = (PipelineOperator, &'a Pipeline); + type IntoIter = AndOrListIter<'a>; + + fn into_iter(self) -> Self::IntoIter { + AndOrListIter { + first: Some(&self.first), + additional_iter: self.additional.iter(), + } + } +} + +impl<'a> From<(PipelineOperator, &'a Pipeline)> for AndOr { + fn from(value: (PipelineOperator, &'a Pipeline)) -> Self { + match value.0 { + PipelineOperator::Or => Self::Or(value.1.to_owned()), + PipelineOperator::And => Self::And(value.1.to_owned()), + } + } +} + +impl AndOrList { + /// Returns an iterator over the pipelines in this `AndOrList`. + pub fn iter(&self) -> AndOrListIter<'_> { + self.into_iter() + } +} + +/// Represents a boolean operator used to connect command pipelines, along with the +/// succeeding pipeline. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum AndOr { + /// Boolean AND operator; the embedded pipeline is only to be executed if the + /// preceding command has succeeded. + And(Pipeline), + /// Boolean OR operator; the embedded pipeline is only to be executed if the + /// preceding command has not succeeded. + Or(Pipeline), +} + +impl Node for AndOr {} + +// TODO(source-location): add a loc to account for the operator +impl SourceLocation for AndOr { + fn location(&self) -> Option { + match self { + Self::And(p) => p.location(), + Self::Or(p) => p.location(), + } + } +} + +impl Display for AndOr { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::And(pipeline) => write!(f, " && {pipeline}"), + Self::Or(pipeline) => write!(f, " || {pipeline}"), + } + } +} + +/// The type of timing requested for a pipeline. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum PipelineTimed { + /// The pipeline should be timed with bash-like output. + Timed(SourceSpan), + /// The pipeline should be timed with POSIX-like output. + TimedWithPosixOutput(SourceSpan), +} + +impl Node for PipelineTimed {} + +impl SourceLocation for PipelineTimed { + fn location(&self) -> Option { + match self { + Self::Timed(t) => Some(t.to_owned()), + Self::TimedWithPosixOutput(t) => Some(t.to_owned()), + } + } +} + +impl Display for PipelineTimed { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Timed(_) => write!(f, "time"), + Self::TimedWithPosixOutput(_) => write!(f, "time -p"), + } + } +} + +impl PipelineTimed { + /// Returns true if the pipeline should be timed with POSIX-like output. + pub const fn is_posix_output(&self) -> bool { + matches!(self, Self::TimedWithPosixOutput(_)) + } +} + +/// A pipeline of commands, where each command's output is passed as standard input +/// to the command that follows it. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct Pipeline { + /// Indicates whether the pipeline's execution should be timed with reported + /// timings in output. + #[cfg_attr( + any(test, feature = "serde"), + serde(skip_serializing_if = "Option::is_none", default) + )] + pub timed: Option, + /// Indicates whether the result of the overall pipeline should be the logical + /// negation of the result of the pipeline. + #[cfg_attr( + any(test, feature = "serde"), + serde(skip_serializing_if = "<&bool as std::ops::Not>::not", default) + )] + pub bang: bool, + /// The sequence of commands in the pipeline. + pub seq: Vec, +} + +impl Node for Pipeline {} + +// TODO(source-location): Handle the case where `self.timed` is `None` but there is a bang. +impl SourceLocation for Pipeline { + fn location(&self) -> Option { + let start = self + .timed + .as_ref() + .and_then(SourceLocation::location) + .or_else(|| self.seq.first().and_then(SourceLocation::location)); + let end = self.seq.last().and_then(SourceLocation::location); + + maybe_location(start.as_ref(), end.as_ref()) + } +} + +impl Display for Pipeline { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if let Some(timed) = &self.timed { + write!(f, "{timed} ")?; + } + + if self.bang { + write!(f, "! ")?; + } + for (i, command) in self.seq.iter().enumerate() { + if i > 0 { + write!(f, " |")?; + } + write!(f, "{command}")?; + } + + Ok(()) + } +} + +/// Represents a shell command. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum Command { + /// A simple command, directly invoking an external command, a built-in command, + /// a shell function, or similar. + Simple(SimpleCommand), + /// A compound command, composed of multiple commands. + Compound(CompoundCommand, Option), + /// A command whose side effect is to define a shell function. + Function(FunctionDefinition), + /// A command that evaluates an extended test expression. + ExtendedTest(ExtendedTestExprCommand, Option), +} + +impl Node for Command {} + +impl SourceLocation for Command { + fn location(&self) -> Option { + match self { + Self::Simple(s) => s.location(), + Self::Compound(c, r) => { + match (c.location(), r.as_ref().and_then(SourceLocation::location)) { + (Some(s), Some(e)) => Some(SourceSpan::within(&s, &e)), + (s, _) => s, + } + } + Self::Function(f) => f.location(), + Self::ExtendedTest(e, _) => e.location(), + } + } +} + +impl Display for Command { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Simple(simple_command) => write!(f, "{simple_command}"), + Self::Compound(compound_command, redirect_list) => { + write!(f, "{compound_command}")?; + if let Some(redirect_list) = redirect_list { + write!(f, "{redirect_list}")?; + } + Ok(()) + } + Self::Function(function_definition) => write!(f, "{function_definition}"), + Self::ExtendedTest(extended_test_expr, redirect_list) => { + write!(f, "[[ {extended_test_expr} ]]")?; + if let Some(redirect_list) = redirect_list { + write!(f, "{redirect_list}")?; + } + Ok(()) + } + } + } +} + +/// Represents a compound command, potentially made up of multiple nested commands. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum CompoundCommand { + /// An arithmetic command, evaluating an arithmetic expression. + Arithmetic(ArithmeticCommand), + /// An arithmetic for clause, which loops until an arithmetic condition is reached. + ArithmeticForClause(ArithmeticForClauseCommand), + /// A brace group, which groups commands together. + BraceGroup(BraceGroupCommand), + /// A subshell, which executes commands in a subshell. + Subshell(SubshellCommand), + /// A for clause, which loops over a set of values. + ForClause(ForClauseCommand), + /// A case clause, which selects a command based on a value and a set of + /// pattern-based filters. + CaseClause(CaseClauseCommand), + /// An if clause, which conditionally executes a command. + IfClause(IfClauseCommand), + /// A while clause, which loops while a condition is met. + WhileClause(WhileOrUntilClauseCommand), + /// An until clause, which loops until a condition is met. + UntilClause(WhileOrUntilClauseCommand), + /// A coprocess, which runs a command asynchronously in a subshell. + Coprocess(CoprocessCommand), +} + +impl Node for CompoundCommand {} + +impl SourceLocation for CompoundCommand { + fn location(&self) -> Option { + match self { + Self::Arithmetic(a) => a.location(), + Self::ArithmeticForClause(a) => a.location(), + Self::BraceGroup(b) => b.location(), + Self::Subshell(s) => s.location(), + Self::ForClause(f) => f.location(), + Self::CaseClause(c) => c.location(), + Self::IfClause(i) => i.location(), + Self::WhileClause(w) => w.location(), + Self::UntilClause(u) => u.location(), + Self::Coprocess(c) => c.location(), + } + } +} + +impl Display for CompoundCommand { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Arithmetic(arithmetic_command) => write!(f, "{arithmetic_command}"), + Self::ArithmeticForClause(arithmetic_for_clause_command) => { + write!(f, "{arithmetic_for_clause_command}") + } + Self::BraceGroup(brace_group_command) => { + write!(f, "{brace_group_command}") + } + Self::Subshell(subshell_command) => write!(f, "{subshell_command}"), + Self::ForClause(for_clause_command) => write!(f, "{for_clause_command}"), + Self::CaseClause(case_clause_command) => { + write!(f, "{case_clause_command}") + } + Self::IfClause(if_clause_command) => write!(f, "{if_clause_command}"), + Self::WhileClause(while_or_until_clause_command) => { + write!(f, "while {while_or_until_clause_command}") + } + Self::UntilClause(while_or_until_clause_command) => { + write!(f, "until {while_or_until_clause_command}") + } + Self::Coprocess(coproc_clause_command) => { + write!(f, "{coproc_clause_command}") + } + } + } +} + +/// An arithmetic command, evaluating an arithmetic expression. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct ArithmeticCommand { + /// The raw, unparsed and unexpanded arithmetic expression. + pub expr: UnexpandedArithmeticExpr, + /// Location of the command + pub loc: SourceSpan, +} + +impl Node for ArithmeticCommand {} + +impl SourceLocation for ArithmeticCommand { + fn location(&self) -> Option { + Some(self.loc.clone()) + } +} + +impl Display for ArithmeticCommand { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "(({}))", self.expr) + } +} + +/// A subshell, which executes commands in a subshell. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct SubshellCommand { + /// Command list in the subshell + pub list: CompoundList, + /// Location of the subshell + pub loc: SourceSpan, +} + +impl Node for SubshellCommand {} + +impl SourceLocation for SubshellCommand { + fn location(&self) -> Option { + Some(self.loc.clone()) + } +} + +impl Display for SubshellCommand { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "( ")?; + write!(f, "{}", self.list)?; + write!(f, " )") + } +} + +/// A for clause, which loops over a set of values. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct ForClauseCommand { + /// The name of the iterator variable. + pub variable_name: String, + /// The values being iterated over. + pub values: Option>, + /// The command to run for each iteration of the loop. + pub body: DoGroupCommand, + /// Location of the for command. + pub loc: SourceSpan, +} + +impl Node for ForClauseCommand {} + +impl SourceLocation for ForClauseCommand { + fn location(&self) -> Option { + Some(self.loc.clone()) + } +} + +impl Display for ForClauseCommand { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "for {} in ", self.variable_name)?; + + if let Some(values) = &self.values { + for (i, value) in values.iter().enumerate() { + if i > 0 { + write!(f, " ")?; + } + + write!(f, "{value}")?; + } + } + + writeln!(f, ";")?; + + write!(f, "{}", self.body) + } +} + +/// An arithmetic for clause, which loops until an arithmetic condition is reached. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct ArithmeticForClauseCommand { + /// Optionally, the initializer expression evaluated before the first iteration of the loop. + pub initializer: Option, + /// Optionally, the expression evaluated as the exit condition of the loop. + pub condition: Option, + /// Optionally, the expression evaluated after each iteration of the loop. + pub updater: Option, + /// The command to run for each iteration of the loop. + pub body: DoGroupCommand, + /// Location of the clause + pub loc: SourceSpan, +} + +impl Node for ArithmeticForClauseCommand {} + +impl SourceLocation for ArithmeticForClauseCommand { + fn location(&self) -> Option { + Some(self.loc.clone()) + } +} + +impl Display for ArithmeticForClauseCommand { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "for ((")?; + + if let Some(initializer) = &self.initializer { + write!(f, "{initializer}")?; + } + + write!(f, "; ")?; + + if let Some(condition) = &self.condition { + write!(f, "{condition}")?; + } + + write!(f, "; ")?; + + if let Some(updater) = &self.updater { + write!(f, "{updater}")?; + } + + writeln!(f, "))")?; + + write!(f, "{}", self.body) + } +} + +/// A case clause, which selects a command based on a value and a set of +/// pattern-based filters. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct CaseClauseCommand { + /// The value being matched on. + pub value: Word, + /// The individual case branches. + pub cases: Vec, + /// Location of the case command. + pub loc: SourceSpan, +} + +impl Node for CaseClauseCommand {} + +impl SourceLocation for CaseClauseCommand { + fn location(&self) -> Option { + Some(self.loc.clone()) + } +} + +impl Display for CaseClauseCommand { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "case {} in", self.value)?; + for case in &self.cases { + write!(indenter::indented(f).with_str(DISPLAY_INDENT), "{case}")?; + } + writeln!(f)?; + write!(f, "esac") + } +} + +/// A sequence of commands. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct CompoundList(pub Vec); + +impl Node for CompoundList {} + +// TODO(source-location): Handle the optional trailing separator. +impl SourceLocation for CompoundList { + fn location(&self) -> Option { + let start = self.0.first().and_then(SourceLocation::location); + let end = self.0.last().and_then(SourceLocation::location); + + if let (Some(s), Some(e)) = (start, end) { + Some(SourceSpan::within(&s, &e)) + } else { + None + } + } +} + +impl Display for CompoundList { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + for (i, item) in self.0.iter().enumerate() { + if i > 0 { + writeln!(f)?; + } + + // Write the and-or list. + write!(f, "{}", item.0)?; + + // Write the separator... unless we're on the list item and it's a ';'. + if i == self.0.len() - 1 && matches!(item.1, SeparatorOperator::Sequence) { + // Skip + } else { + write!(f, "{}", item.1)?; + } + } + + Ok(()) + } +} + +/// An element of a compound command list. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct CompoundListItem(pub AndOrList, pub SeparatorOperator); + +impl Node for CompoundListItem {} + +// TODO(source-location): Account for the location of the separator operator. +impl SourceLocation for CompoundListItem { + fn location(&self) -> Option { + self.0.location() + } +} + +impl Display for CompoundListItem { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0)?; + write!(f, "{}", self.1)?; + Ok(()) + } +} + +/// An if clause, which conditionally executes a command. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct IfClauseCommand { + /// The command whose execution result is inspected. + pub condition: CompoundList, + /// The command to execute if the condition is true. + pub then: CompoundList, + /// Optionally, `else` clauses that will be evaluated if the condition is false. + #[cfg_attr( + any(test, feature = "serde"), + serde(skip_serializing_if = "Option::is_none", default) + )] + pub elses: Option>, + /// Location of the if clause + pub loc: SourceSpan, +} + +impl Node for IfClauseCommand {} + +impl SourceLocation for IfClauseCommand { + fn location(&self) -> Option { + Some(self.loc.clone()) + } +} + +impl Display for IfClauseCommand { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!(f, "if {}; then", self.condition)?; + write!( + indenter::indented(f).with_str(DISPLAY_INDENT), + "{}", + self.then + )?; + if let Some(elses) = &self.elses { + for else_clause in elses { + write!(f, "{else_clause}")?; + } + } + + writeln!(f)?; + write!(f, "fi")?; + + Ok(()) + } +} + +/// Represents the `else` clause of a conditional command. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct ElseClause { + /// If present, the condition that must be met for this `else` clause to be executed. + #[cfg_attr( + any(test, feature = "serde"), + serde(skip_serializing_if = "Option::is_none", default) + )] + pub condition: Option, + /// The commands to execute if this `else` clause is selected. + pub body: CompoundList, +} + +impl Display for ElseClause { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!(f)?; + if let Some(condition) = &self.condition { + writeln!(f, "elif {condition}; then")?; + } else { + writeln!(f, "else")?; + } + + write!( + indenter::indented(f).with_str(DISPLAY_INDENT), + "{}", + self.body + ) + } +} + +/// A coprocess command, which runs a command asynchronously in a subshell. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct CoprocessCommand { + /// The optional name for the coprocess. + #[cfg_attr( + any(test, feature = "serde"), + serde(skip_serializing_if = "Option::is_none", default) + )] + pub name: Option, + /// The command to run as a coprocess (can be simple or compound). + pub body: Box, + /// The location of this command in the source. + #[cfg_attr(any(test, feature = "serde"), serde(skip_serializing, default))] + pub loc: SourceSpan, +} + +impl Node for CoprocessCommand {} + +impl SourceLocation for CoprocessCommand { + fn location(&self) -> Option { + Some(self.loc.clone()) + } +} + +impl Display for CoprocessCommand { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "coproc")?; + if let Some(name) = &self.name { + write!(f, " {name}")?; + } + write!(f, " {}", self.body)?; + Ok(()) + } +} + +/// An individual matching case item in a case clause. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct CaseItem { + /// The patterns that select this case branch. + pub patterns: Vec, + /// The commands to execute if this case branch is selected. + pub cmd: Option, + /// When the case branch is selected, the action to take after the command is executed. + pub post_action: CaseItemPostAction, + /// Location of the item + pub loc: Option, +} + +impl Node for CaseItem {} + +impl SourceLocation for CaseItem { + fn location(&self) -> Option { + self.loc.clone() + } +} + +impl Display for CaseItem { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!(f)?; + for (i, pattern) in self.patterns.iter().enumerate() { + if i > 0 { + write!(f, "|")?; + } + write!(f, "{pattern}")?; + } + writeln!(f, ")")?; + + if let Some(cmd) = &self.cmd { + write!(indenter::indented(f).with_str(DISPLAY_INDENT), "{cmd}")?; + } + writeln!(f)?; + write!(f, "{}", self.post_action) + } +} + +/// Describes the action to take after executing the body command of a case clause. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum CaseItemPostAction { + /// The containing case should be exited. + ExitCase, + /// If one is present, the command body of the succeeding case item should be + /// executed (without evaluating its pattern). + UnconditionallyExecuteNextCaseItem, + /// The case should continue evaluating the remaining case items, as if this + /// item had not been executed. + ContinueEvaluatingCases, +} + +impl Display for CaseItemPostAction { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::ExitCase => write!(f, ";;"), + Self::UnconditionallyExecuteNextCaseItem => write!(f, ";&"), + Self::ContinueEvaluatingCases => write!(f, ";;&"), + } + } +} + +/// A while or until clause, whose looping is controlled by a condition. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct WhileOrUntilClauseCommand(pub CompoundList, pub DoGroupCommand, pub SourceSpan); + +impl Node for WhileOrUntilClauseCommand {} + +impl SourceLocation for WhileOrUntilClauseCommand { + fn location(&self) -> Option { + Some(self.2.clone()) + } +} + +impl Display for WhileOrUntilClauseCommand { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}; {}", self.0, self.1) + } +} + +/// Encapsulates the definition of a shell function. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct FunctionDefinition { + /// The name of the function. + pub fname: Word, + /// The body of the function. + pub body: FunctionBody, +} + +impl Node for FunctionDefinition {} + +// TODO(source-location): Account for the optional 'function' keyword that may +// precede the function name. +impl SourceLocation for FunctionDefinition { + fn location(&self) -> Option { + let start = self.fname.location(); + let end = self.body.location(); + + if let (Some(s), Some(e)) = (start, end) { + Some(SourceSpan::within(&s, &e)) + } else { + None + } + } +} + +impl Display for FunctionDefinition { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!(f, "{} () ", self.fname.value)?; + write!(f, "{}", self.body)?; + Ok(()) + } +} + +/// Encapsulates the body of a function definition. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct FunctionBody(pub CompoundCommand, pub Option); + +impl Node for FunctionBody {} + +impl SourceLocation for FunctionBody { + fn location(&self) -> Option { + let cmd_span = self.0.location(); + let redirect_span = self.1.as_ref().and_then(SourceLocation::location); + + match (cmd_span, redirect_span) { + // If there's a redirect, include it in the span. + (Some(cmd_span), Some(redirect_span)) => { + Some(SourceSpan::within(&cmd_span, &redirect_span)) + } + // Otherwise, just return the command span. + (Some(cmd_span), None) => Some(cmd_span), + _ => None, + } + } +} + +impl Display for FunctionBody { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0)?; + if let Some(redirect_list) = &self.1 { + write!(f, "{redirect_list}")?; + } + + Ok(()) + } +} + +/// A brace group, which groups commands together. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct BraceGroupCommand { + /// List of commands + pub list: CompoundList, + /// Location of the group + pub loc: SourceSpan, +} + +impl Node for BraceGroupCommand {} + +impl SourceLocation for BraceGroupCommand { + fn location(&self) -> Option { + Some(self.loc.clone()) + } +} + +impl Display for BraceGroupCommand { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!(f, "{{ ")?; + write!( + indenter::indented(f).with_str(DISPLAY_INDENT), + "{}", + self.list + )?; + writeln!(f)?; + write!(f, "}}")?; + + Ok(()) + } +} + +/// A do group, which groups commands together. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct DoGroupCommand { + /// List of commands + pub list: CompoundList, + /// Location of the group + pub loc: SourceSpan, +} + +impl Display for DoGroupCommand { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!(f, "do")?; + write!( + indenter::indented(f).with_str(DISPLAY_INDENT), + "{}", + self.list + )?; + writeln!(f)?; + write!(f, "done") + } +} + +/// Represents the invocation of a simple command. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct SimpleCommand { + /// Optionally, a prefix to the command. + #[cfg_attr( + any(test, feature = "serde"), + serde(skip_serializing_if = "Option::is_none", default) + )] + pub prefix: Option, + /// The name of the command to execute. + #[cfg_attr( + any(test, feature = "serde"), + serde(skip_serializing_if = "Option::is_none", default) + )] + pub word_or_name: Option, + /// Optionally, a suffix to the command. + #[cfg_attr( + any(test, feature = "serde"), + serde(skip_serializing_if = "Option::is_none", default) + )] + pub suffix: Option, +} + +impl Node for SimpleCommand {} + +impl SourceLocation for SimpleCommand { + fn location(&self) -> Option { + let mid = &self + .word_or_name + .as_ref() + .and_then(SourceLocation::location); + let start = self.prefix.as_ref().and_then(SourceLocation::location); + let end = self.suffix.as_ref().and_then(SourceLocation::location); + + match (start, mid, end) { + (Some(start), _, Some(end)) => Some(SourceSpan::within(&start, &end)), + (Some(start), Some(mid), None) => Some(SourceSpan::within(&start, mid)), + (Some(start), None, None) => Some(start), + (None, Some(mid), Some(end)) => Some(SourceSpan::within(mid, &end)), + (None, Some(mid), None) => Some(mid.clone()), + _ => None, + } + } +} + +impl Display for SimpleCommand { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut wrote_something = false; + + if let Some(prefix) = &self.prefix { + if wrote_something { + write!(f, " ")?; + } + + write!(f, "{prefix}")?; + wrote_something = true; + } + + if let Some(word_or_name) = &self.word_or_name { + if wrote_something { + write!(f, " ")?; + } + + write!(f, "{word_or_name}")?; + wrote_something = true; + } + + if let Some(suffix) = &self.suffix { + if wrote_something { + write!(f, " ")?; + } + + write!(f, "{suffix}")?; + } + + Ok(()) + } +} + +/// Represents a prefix to a simple command. +#[derive(Clone, Debug, Default)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct CommandPrefix(pub Vec); + +impl Node for CommandPrefix {} + +impl SourceLocation for CommandPrefix { + fn location(&self) -> Option { + let start = self.0.first().and_then(SourceLocation::location); + let end = self.0.last().and_then(SourceLocation::location); + + maybe_location(start.as_ref(), end.as_ref()) + } +} + +impl Display for CommandPrefix { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + for (i, item) in self.0.iter().enumerate() { + if i > 0 { + write!(f, " ")?; + } + + write!(f, "{item}")?; + } + Ok(()) + } +} + +/// Represents a suffix to a simple command; a word argument, declaration, or I/O redirection. +#[derive(Clone, Default, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct CommandSuffix(pub Vec); + +impl Node for CommandSuffix {} + +impl SourceLocation for CommandSuffix { + fn location(&self) -> Option { + let start = self.0.first().and_then(SourceLocation::location); + let end = self.0.last().and_then(SourceLocation::location); + + maybe_location(start.as_ref(), end.as_ref()) + } +} + +impl Display for CommandSuffix { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + for (i, item) in self.0.iter().enumerate() { + if i > 0 { + write!(f, " ")?; + } + + write!(f, "{item}")?; + } + Ok(()) + } +} + +/// Represents the I/O direction of a process substitution. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum ProcessSubstitutionKind { + /// The process is read from. + Read, + /// The process is written to. + Write, +} + +impl Display for ProcessSubstitutionKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Read => write!(f, "<"), + Self::Write => write!(f, ">"), + } + } +} + +/// A prefix or suffix for a simple command. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum CommandPrefixOrSuffixItem { + /// An I/O redirection. + IoRedirect(IoRedirect), + /// A word. + Word(Word), + /// An assignment/declaration word. + AssignmentWord(Assignment, Word), + /// A process substitution. + ProcessSubstitution(ProcessSubstitutionKind, SubshellCommand), +} + +impl Node for CommandPrefixOrSuffixItem {} + +impl SourceLocation for CommandPrefixOrSuffixItem { + fn location(&self) -> Option { + match self { + Self::Word(w) => w.location(), + Self::IoRedirect(io_redirect) => io_redirect.location(), + Self::AssignmentWord(assignment, _word) => assignment.location(), + // TODO(source-location): account for the kind token + Self::ProcessSubstitution(_kind, cmd) => cmd.location(), + } + } +} + +impl Display for CommandPrefixOrSuffixItem { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::IoRedirect(io_redirect) => write!(f, "{io_redirect}"), + Self::Word(word) => write!(f, "{word}"), + Self::AssignmentWord(_assignment, word) => write!(f, "{word}"), + Self::ProcessSubstitution(kind, subshell_command) => { + write!(f, "{kind}({subshell_command})") + } + } + } +} + +/// Encapsulates an assignment declaration. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct Assignment { + /// Name being assigned to. + pub name: AssignmentName, + /// Value being assigned. + pub value: AssignmentValue, + /// Whether or not to append to the preexisting value associated with the named variable. + #[cfg_attr( + any(test, feature = "serde"), + serde(skip_serializing_if = "<&bool as std::ops::Not>::not", default) + )] + pub append: bool, + /// Location of the assignment + pub loc: SourceSpan, +} + +impl Node for Assignment {} + +impl SourceLocation for Assignment { + fn location(&self) -> Option { + Some(self.loc.clone()) + } +} + +impl Display for Assignment { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.name)?; + if self.append { + write!(f, "+")?; + } + write!(f, "={}", self.value) + } +} + +/// The target of an assignment. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum AssignmentName { + /// A named variable. + VariableName(String), + /// An element in a named array. + ArrayElementName(String, String), +} + +impl Display for AssignmentName { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::VariableName(name) => write!(f, "{name}"), + Self::ArrayElementName(name, index) => { + write!(f, "{name}[{index}]") + } + } + } +} + +/// A value being assigned to a variable. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum AssignmentValue { + /// A scalar (word) value. + Scalar(Word), + /// An array of elements. + Array(Vec<(Option, Word)>), +} + +impl Node for AssignmentValue {} + +impl SourceLocation for AssignmentValue { + fn location(&self) -> Option { + match self { + Self::Scalar(word) => word.location(), + Self::Array(words) => { + // TODO(source-location): account for the surrounding parentheses + let first = words.first().and_then(|(_key, value)| value.location()); + let last = words.last().and_then(|(_key, value)| value.location()); + maybe_location(first.as_ref(), last.as_ref()) + } + } + } +} + +impl Display for AssignmentValue { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Scalar(word) => write!(f, "{word}"), + Self::Array(words) => { + write!(f, "(")?; + for (i, value) in words.iter().enumerate() { + if i > 0 { + write!(f, " ")?; + } + match value { + (Some(key), value) => write!(f, "[{key}]={value}")?, + (None, value) => write!(f, "{value}")?, + } + } + write!(f, ")") + } + } + } +} + +/// A list of I/O redirections to be applied to a command. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct RedirectList(pub Vec); + +impl Node for RedirectList {} + +impl SourceLocation for RedirectList { + fn location(&self) -> Option { + let first = self.0.first().and_then(SourceLocation::location); + let last = self.0.last().and_then(SourceLocation::location); + maybe_location(first.as_ref(), last.as_ref()) + } +} + +impl Display for RedirectList { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + for item in &self.0 { + write!(f, "{item}")?; + } + Ok(()) + } +} + +/// A file descriptor number. +pub type IoFd = i32; + +/// An I/O redirection. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum IoRedirect { + /// Redirection to a file. + File(Option, IoFileRedirectKind, IoFileRedirectTarget), + /// Redirection from a here-document. + HereDocument(Option, IoHereDocument), + /// Redirection from a here-string. + HereString(Option, Word), + /// Redirection of both standard output and standard error (with optional append). + OutputAndError(Word, bool), +} + +impl Node for IoRedirect {} + +impl SourceLocation for IoRedirect { + fn location(&self) -> Option { + // TODO(source-location): complete + None + } +} + +impl Display for IoRedirect { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::File(fd_num, kind, target) => { + if let Some(fd_num) = fd_num { + write!(f, "{fd_num}")?; + } + + write!(f, "{kind} {target}")?; + } + Self::OutputAndError(target, append) => { + write!(f, "&>")?; + if *append { + write!(f, ">")?; + } + write!(f, " {target}")?; + } + Self::HereDocument(fd_num, here_doc) => { + if let Some(fd_num) = fd_num { + write!(f, "{fd_num}")?; + } + + write!(f, "<<{here_doc}")?; + } + Self::HereString(fd_num, s) => { + if let Some(fd_num) = fd_num { + write!(f, "{fd_num}")?; + } + + write!(f, "<<< {s}")?; + } + } + + Ok(()) + } +} + +/// Kind of file I/O redirection. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum IoFileRedirectKind { + /// Read (`<`). + Read, + /// Write (`>`). + Write, + /// Append (`>>`). + Append, + /// Read and write (`<>`). + ReadAndWrite, + /// Clobber (`>|`). + Clobber, + /// Duplicate input (`<&`). + DuplicateInput, + /// Duplicate output (`>&`). + DuplicateOutput, +} + +impl Display for IoFileRedirectKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Read => write!(f, "<"), + Self::Write => write!(f, ">"), + Self::Append => write!(f, ">>"), + Self::ReadAndWrite => write!(f, "<>"), + Self::Clobber => write!(f, ">|"), + Self::DuplicateInput => write!(f, "<&"), + Self::DuplicateOutput => write!(f, ">&"), + } + } +} + +/// Target for an I/O file redirection. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum IoFileRedirectTarget { + /// Path to a file. + Filename(Word), + /// File descriptor number. + Fd(IoFd), + /// Process substitution: substitution with the results of executing the given + /// command in a subshell. + ProcessSubstitution(ProcessSubstitutionKind, SubshellCommand), + /// Item to duplicate in a word redirection. After expansion, this could be a + /// filename, a file descriptor, or a file descriptor and a "-" to indicate + /// requested closure. + Duplicate(Word), +} + +impl Display for IoFileRedirectTarget { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Filename(word) => write!(f, "{word}"), + Self::Fd(fd) => write!(f, "{fd}"), + Self::ProcessSubstitution(kind, subshell_command) => { + write!(f, "{kind}{subshell_command}") + } + Self::Duplicate(word) => write!(f, "{word}"), + } + } +} + +/// Represents an I/O here document. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct IoHereDocument { + /// Whether to remove leading tabs from the here document. + #[cfg_attr( + any(test, feature = "serde"), + serde(skip_serializing_if = "<&bool as std::ops::Not>::not", default) + )] + pub remove_tabs: bool, + /// Whether to basic-expand the contents of the here document. + #[cfg_attr( + any(test, feature = "serde"), + serde(skip_serializing_if = "<&bool as std::ops::Not>::not", default) + )] + pub requires_expansion: bool, + /// The delimiter marking the end of the here document. + pub here_end: Word, + /// The contents of the here document. + pub doc: Word, +} + +impl Node for IoHereDocument {} + +impl SourceLocation for IoHereDocument { + fn location(&self) -> Option { + // TODO(source-location): complete + None + } +} + +impl Display for IoHereDocument { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.remove_tabs { + write!(f, "-")?; + } + + writeln!(f, "{}", self.here_end)?; + write!(f, "{}", self.doc)?; + writeln!(f, "{}", self.here_end)?; + + Ok(()) + } +} + +/// A (non-extended) test expression. +#[derive(Clone, Debug)] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum TestExpr { + /// Always evaluates to false. + False, + /// A literal string. + Literal(String), + /// Logical AND operation on two nested expressions. + And(Box, Box), + /// Logical OR operation on two nested expressions. + Or(Box, Box), + /// Logical NOT operation on a nested expression. + Not(Box), + /// A parenthesized expression. + Parenthesized(Box), + /// A unary test operation. + UnaryTest(UnaryPredicate, String), + /// A binary test operation. + BinaryTest(BinaryPredicate, String, String), +} + +impl Node for TestExpr {} + +impl SourceLocation for TestExpr { + fn location(&self) -> Option { + // TODO(source-location): complete + None + } +} + +impl Display for TestExpr { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::False => Ok(()), + Self::Literal(s) => write!(f, "{s}"), + Self::And(left, right) => write!(f, "{left} -a {right}"), + Self::Or(left, right) => write!(f, "{left} -o {right}"), + Self::Not(expr) => write!(f, "! {expr}"), + Self::Parenthesized(expr) => write!(f, "( {expr} )"), + Self::UnaryTest(pred, word) => write!(f, "{pred} {word}"), + Self::BinaryTest(left, op, right) => write!(f, "{left} {op} {right}"), + } + } +} + +/// An extended test expression. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum ExtendedTestExpr { + /// Logical AND operation on two nested expressions. + And(Box, Box), + /// Logical OR operation on two nested expressions. + Or(Box, Box), + /// Logical NOT operation on a nested expression. + Not(Box), + /// A parenthesized expression. + Parenthesized(Box), + /// A unary test operation. + UnaryTest(UnaryPredicate, Word), + /// A binary test operation. + BinaryTest(BinaryPredicate, Word, Word), +} + +impl Display for ExtendedTestExpr { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::And(left, right) => { + write!(f, "{left} && {right}") + } + Self::Or(left, right) => { + write!(f, "{left} || {right}") + } + Self::Not(expr) => { + write!(f, "! {expr}") + } + Self::Parenthesized(expr) => { + write!(f, "( {expr} )") + } + Self::UnaryTest(pred, word) => { + write!(f, "{pred} {word}") + } + Self::BinaryTest(pred, left, right) => { + write!(f, "{left} {pred} {right}") + } + } + } +} + +/// An extended test expression command. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct ExtendedTestExprCommand { + /// The extended test expression + pub expr: ExtendedTestExpr, + /// Location of the expression + pub loc: SourceSpan, +} + +impl Node for ExtendedTestExprCommand {} + +impl SourceLocation for ExtendedTestExprCommand { + fn location(&self) -> Option { + Some(self.loc.clone()) + } +} + +impl Display for ExtendedTestExprCommand { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.expr.fmt(f) + } +} + +/// A unary predicate usable in an extended test expression. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum UnaryPredicate { + /// Computes if the operand is a path to an existing file. + FileExists, + /// Computes if the operand is a path to an existing block device file. + FileExistsAndIsBlockSpecialFile, + /// Computes if the operand is a path to an existing character device file. + FileExistsAndIsCharSpecialFile, + /// Computes if the operand is a path to an existing directory. + FileExistsAndIsDir, + /// Computes if the operand is a path to an existing regular file. + FileExistsAndIsRegularFile, + /// Computes if the operand is a path to an existing file with the setgid bit set. + FileExistsAndIsSetgid, + /// Computes if the operand is a path to an existing symbolic link. + FileExistsAndIsSymlink, + /// Computes if the operand is a path to an existing file with the sticky bit set. + FileExistsAndHasStickyBit, + /// Computes if the operand is a path to an existing FIFO file. + FileExistsAndIsFifo, + /// Computes if the operand is a path to an existing file that is readable. + FileExistsAndIsReadable, + /// Computes if the operand is a path to an existing file with a non-zero length. + FileExistsAndIsNotZeroLength, + /// Computes if the operand is a file descriptor that is an open terminal. + FdIsOpenTerminal, + /// Computes if the operand is a path to an existing file with the setuid bit set. + FileExistsAndIsSetuid, + /// Computes if the operand is a path to an existing file that is writable. + FileExistsAndIsWritable, + /// Computes if the operand is a path to an existing file that is executable. + FileExistsAndIsExecutable, + /// Computes if the operand is a path to an existing file owned by the current context's + /// effective group ID. + FileExistsAndOwnedByEffectiveGroupId, + /// Computes if the operand is a path to an existing file that has been modified since last + /// being read. + FileExistsAndModifiedSinceLastRead, + /// Computes if the operand is a path to an existing file owned by the current context's + /// effective user ID. + FileExistsAndOwnedByEffectiveUserId, + /// Computes if the operand is a path to an existing socket file. + FileExistsAndIsSocket, + /// Computes if the operand is a 'set -o' option that is enabled. + ShellOptionEnabled, + /// Computes if the operand names a shell variable that is set and assigned a value. + ShellVariableIsSetAndAssigned, + /// Computes if the operand names a shell variable that is set and of nameref type. + ShellVariableIsSetAndNameRef, + /// Computes if the operand is a string with zero length. + StringHasZeroLength, + /// Computes if the operand is a string with non-zero length. + StringHasNonZeroLength, +} + +impl Display for UnaryPredicate { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::FileExists => write!(f, "-e"), + Self::FileExistsAndIsBlockSpecialFile => write!(f, "-b"), + Self::FileExistsAndIsCharSpecialFile => write!(f, "-c"), + Self::FileExistsAndIsDir => write!(f, "-d"), + Self::FileExistsAndIsRegularFile => write!(f, "-f"), + Self::FileExistsAndIsSetgid => write!(f, "-g"), + Self::FileExistsAndIsSymlink => write!(f, "-h"), + Self::FileExistsAndHasStickyBit => write!(f, "-k"), + Self::FileExistsAndIsFifo => write!(f, "-p"), + Self::FileExistsAndIsReadable => write!(f, "-r"), + Self::FileExistsAndIsNotZeroLength => write!(f, "-s"), + Self::FdIsOpenTerminal => write!(f, "-t"), + Self::FileExistsAndIsSetuid => write!(f, "-u"), + Self::FileExistsAndIsWritable => write!(f, "-w"), + Self::FileExistsAndIsExecutable => write!(f, "-x"), + Self::FileExistsAndOwnedByEffectiveGroupId => write!(f, "-G"), + Self::FileExistsAndModifiedSinceLastRead => write!(f, "-N"), + Self::FileExistsAndOwnedByEffectiveUserId => write!(f, "-O"), + Self::FileExistsAndIsSocket => write!(f, "-S"), + Self::ShellOptionEnabled => write!(f, "-o"), + Self::ShellVariableIsSetAndAssigned => write!(f, "-v"), + Self::ShellVariableIsSetAndNameRef => write!(f, "-R"), + Self::StringHasZeroLength => write!(f, "-z"), + Self::StringHasNonZeroLength => write!(f, "-n"), + } + } +} + +/// A binary predicate usable in an extended test expression. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum BinaryPredicate { + /// Computes if two files refer to the same device and inode numbers. + FilesReferToSameDeviceAndInodeNumbers, + /// Computes if the left file is newer than the right, or exists when the right does not. + LeftFileIsNewerOrExistsWhenRightDoesNot, + /// Computes if the left file is older than the right, or does not exist when the right does. + LeftFileIsOlderOrDoesNotExistWhenRightDoes, + /// Computes if a string exactly matches a pattern. + StringExactlyMatchesPattern, + /// Computes if a string does not exactly match a pattern. + StringDoesNotExactlyMatchPattern, + /// Computes if a string matches a regular expression. + StringMatchesRegex, + /// Computes if a string exactly matches another string. + StringExactlyMatchesString, + /// Computes if a string does not exactly match another string. + StringDoesNotExactlyMatchString, + /// Computes if a string contains a substring. + StringContainsSubstring, + /// Computes if the left value sorts before the right. + LeftSortsBeforeRight, + /// Computes if the left value sorts after the right. + LeftSortsAfterRight, + /// Computes if two values are equal via arithmetic comparison. + ArithmeticEqualTo, + /// Computes if two values are not equal via arithmetic comparison. + ArithmeticNotEqualTo, + /// Computes if the left value is less than the right via arithmetic comparison. + ArithmeticLessThan, + /// Computes if the left value is less than or equal to the right via arithmetic comparison. + ArithmeticLessThanOrEqualTo, + /// Computes if the left value is greater than the right via arithmetic comparison. + ArithmeticGreaterThan, + /// Computes if the left value is greater than or equal to the right via arithmetic comparison. + ArithmeticGreaterThanOrEqualTo, +} + +impl Display for BinaryPredicate { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::FilesReferToSameDeviceAndInodeNumbers => write!(f, "-ef"), + Self::LeftFileIsNewerOrExistsWhenRightDoesNot => write!(f, "-nt"), + Self::LeftFileIsOlderOrDoesNotExistWhenRightDoes => write!(f, "-ot"), + Self::StringExactlyMatchesPattern => write!(f, "=="), + Self::StringDoesNotExactlyMatchPattern => write!(f, "!="), + Self::StringMatchesRegex => write!(f, "=~"), + Self::StringContainsSubstring => write!(f, "=~"), + Self::StringExactlyMatchesString => write!(f, "=="), + Self::StringDoesNotExactlyMatchString => write!(f, "!="), + Self::LeftSortsBeforeRight => write!(f, "<"), + Self::LeftSortsAfterRight => write!(f, ">"), + Self::ArithmeticEqualTo => write!(f, "-eq"), + Self::ArithmeticNotEqualTo => write!(f, "-ne"), + Self::ArithmeticLessThan => write!(f, "-lt"), + Self::ArithmeticLessThanOrEqualTo => write!(f, "-le"), + Self::ArithmeticGreaterThan => write!(f, "-gt"), + Self::ArithmeticGreaterThanOrEqualTo => write!(f, "-ge"), + } + } +} + +/// Represents a shell word. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct Word { + /// Raw text of the word. + pub value: String, + /// Location of the word + pub loc: Option, +} + +impl Node for Word {} + +impl SourceLocation for Word { + fn location(&self) -> Option { + self.loc.clone() + } +} + +impl Display for Word { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.value) + } +} + +impl From<&tokenizer::Token> for Word { + fn from(t: &tokenizer::Token) -> Self { + match t { + tokenizer::Token::Word(value, loc) => Self { + value: value.clone(), + loc: Some(loc.clone()), + }, + tokenizer::Token::Operator(value, loc) => Self { + value: value.clone(), + loc: Some(loc.clone()), + }, + } + } +} + +impl From for Word { + fn from(s: String) -> Self { + Self { + value: s, + loc: None, + } + } +} + +impl AsRef for Word { + fn as_ref(&self) -> &str { + &self.value + } +} + +impl Word { + /// Constructs a new `Word` from a given string. + pub fn new(s: &str) -> Self { + Self { + value: s.to_owned(), + loc: None, + } + } + + /// Constructs a new `Word` from a given string and location. + pub fn with_location(s: &str, loc: &SourceSpan) -> Self { + Self { + value: s.to_owned(), + loc: Some(loc.to_owned()), + } + } + + /// Returns the raw text of the word, consuming the `Word`. + pub fn flatten(&self) -> String { + self.value.clone() + } +} + +/// Encapsulates an unparsed arithmetic expression. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct UnexpandedArithmeticExpr { + /// The raw text of the expression. + pub value: String, +} + +impl Display for UnexpandedArithmeticExpr { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.value) + } +} + +/// An arithmetic expression. +#[derive(Clone, Debug)] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum ArithmeticExpr { + /// A literal integer value. + Literal(i64), + /// A dereference of a variable or array element. + Reference(ArithmeticTarget), + /// A unary operation on an the result of a given nested expression. + UnaryOp(UnaryOperator, Box), + /// A binary operation on two nested expressions. + BinaryOp(BinaryOperator, Box, Box), + /// A ternary conditional expression. + Conditional(Box, Box, Box), + /// An assignment operation. + Assignment(ArithmeticTarget, Box), + /// A binary assignment operation. + BinaryAssignment(BinaryOperator, ArithmeticTarget, Box), + /// A unary assignment operation. + UnaryAssignment(UnaryAssignmentOperator, ArithmeticTarget), +} + +impl Node for ArithmeticExpr {} + +impl SourceLocation for ArithmeticExpr { + fn location(&self) -> Option { + // TODO(source-location): complete and add loc for literal + None + } +} + +#[cfg(feature = "arbitrary")] +impl<'a> arbitrary::Arbitrary<'a> for ArithmeticExpr { + fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result { + let variant = u.choose(&[ + "Literal", + "Reference", + "UnaryOp", + "BinaryOp", + "Conditional", + "Assignment", + "BinaryAssignment", + "UnaryAssignment", + ])?; + + match *variant { + "Literal" => Ok(Self::Literal(i64::arbitrary(u)?)), + "Reference" => Ok(Self::Reference(ArithmeticTarget::arbitrary(u)?)), + "UnaryOp" => Ok(Self::UnaryOp( + UnaryOperator::arbitrary(u)?, + Box::new(Self::arbitrary(u)?), + )), + "BinaryOp" => Ok(Self::BinaryOp( + BinaryOperator::arbitrary(u)?, + Box::new(Self::arbitrary(u)?), + Box::new(Self::arbitrary(u)?), + )), + "Conditional" => Ok(Self::Conditional( + Box::new(Self::arbitrary(u)?), + Box::new(Self::arbitrary(u)?), + Box::new(Self::arbitrary(u)?), + )), + "Assignment" => Ok(Self::Assignment( + ArithmeticTarget::arbitrary(u)?, + Box::new(Self::arbitrary(u)?), + )), + "BinaryAssignment" => Ok(Self::BinaryAssignment( + BinaryOperator::arbitrary(u)?, + ArithmeticTarget::arbitrary(u)?, + Box::new(Self::arbitrary(u)?), + )), + "UnaryAssignment" => Ok(Self::UnaryAssignment( + UnaryAssignmentOperator::arbitrary(u)?, + ArithmeticTarget::arbitrary(u)?, + )), + _ => unreachable!(), + } + } +} + +impl Display for ArithmeticExpr { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Literal(literal) => write!(f, "{literal}"), + Self::Reference(target) => write!(f, "{target}"), + Self::UnaryOp(op, operand) => write!(f, "{op}{operand}"), + Self::BinaryOp(op, left, right) => { + if matches!(op, BinaryOperator::Comma) { + write!(f, "{left}{op} {right}") + } else { + write!(f, "{left} {op} {right}") + } + } + Self::Conditional(condition, if_branch, else_branch) => { + write!(f, "{condition} ? {if_branch} : {else_branch}") + } + Self::Assignment(target, value) => write!(f, "{target} = {value}"), + Self::BinaryAssignment(op, target, operand) => { + write!(f, "{target} {op}= {operand}") + } + Self::UnaryAssignment(op, target) => match op { + UnaryAssignmentOperator::PrefixIncrement + | UnaryAssignmentOperator::PrefixDecrement => write!(f, "{op}{target}"), + UnaryAssignmentOperator::PostfixIncrement + | UnaryAssignmentOperator::PostfixDecrement => write!(f, "{target}{op}"), + }, + } + } +} + +/// A binary arithmetic operator. +#[derive(Clone, Copy, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum BinaryOperator { + /// Exponentiation (e.g., `x ** y`). + Power, + /// Multiplication (e.g., `x * y`). + Multiply, + /// Division (e.g., `x / y`). + Divide, + /// Modulo (e.g., `x % y`). + Modulo, + /// Comma (e.g., `x, y`). + Comma, + /// Addition (e.g., `x + y`). + Add, + /// Subtraction (e.g., `x - y`). + Subtract, + /// Bitwise left shift (e.g., `x << y`). + ShiftLeft, + /// Bitwise right shift (e.g., `x >> y`). + ShiftRight, + /// Less than (e.g., `x < y`). + LessThan, + /// Less than or equal to (e.g., `x <= y`). + LessThanOrEqualTo, + /// Greater than (e.g., `x > y`). + GreaterThan, + /// Greater than or equal to (e.g., `x >= y`). + GreaterThanOrEqualTo, + /// Equals (e.g., `x == y`). + Equals, + /// Not equals (e.g., `x != y`). + NotEquals, + /// Bitwise AND (e.g., `x & y`). + BitwiseAnd, + /// Bitwise exclusive OR (xor) (e.g., `x ^ y`). + BitwiseXor, + /// Bitwise OR (e.g., `x | y`). + BitwiseOr, + /// Logical AND (e.g., `x && y`). + LogicalAnd, + /// Logical OR (e.g., `x || y`). + LogicalOr, +} + +impl Display for BinaryOperator { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Power => write!(f, "**"), + Self::Multiply => write!(f, "*"), + Self::Divide => write!(f, "/"), + Self::Modulo => write!(f, "%"), + Self::Comma => write!(f, ","), + Self::Add => write!(f, "+"), + Self::Subtract => write!(f, "-"), + Self::ShiftLeft => write!(f, "<<"), + Self::ShiftRight => write!(f, ">>"), + Self::LessThan => write!(f, "<"), + Self::LessThanOrEqualTo => write!(f, "<="), + Self::GreaterThan => write!(f, ">"), + Self::GreaterThanOrEqualTo => write!(f, ">="), + Self::Equals => write!(f, "=="), + Self::NotEquals => write!(f, "!="), + Self::BitwiseAnd => write!(f, "&"), + Self::BitwiseXor => write!(f, "^"), + Self::BitwiseOr => write!(f, "|"), + Self::LogicalAnd => write!(f, "&&"), + Self::LogicalOr => write!(f, "||"), + } + } +} + +/// A unary arithmetic operator. +#[derive(Clone, Copy, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum UnaryOperator { + /// Unary plus (e.g., `+x`). + UnaryPlus, + /// Unary minus (e.g., `-x`). + UnaryMinus, + /// Bitwise not (e.g., `~x`). + BitwiseNot, + /// Logical not (e.g., `!x`). + LogicalNot, +} + +impl Display for UnaryOperator { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::UnaryPlus => write!(f, "+"), + Self::UnaryMinus => write!(f, "-"), + Self::BitwiseNot => write!(f, "~"), + Self::LogicalNot => write!(f, "!"), + } + } +} + +/// A unary arithmetic assignment operator. +#[derive(Clone, Copy, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum UnaryAssignmentOperator { + /// Prefix increment (e.g., `++x`). + PrefixIncrement, + /// Prefix increment (e.g., `--x`). + PrefixDecrement, + /// Postfix increment (e.g., `x++`). + PostfixIncrement, + /// Postfix decrement (e.g., `x--`). + PostfixDecrement, +} + +impl Display for UnaryAssignmentOperator { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::PrefixIncrement => write!(f, "++"), + Self::PrefixDecrement => write!(f, "--"), + Self::PostfixIncrement => write!(f, "++"), + Self::PostfixDecrement => write!(f, "--"), + } + } +} + +/// Identifies the target of an arithmetic assignment expression. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum ArithmeticTarget { + /// A named variable. + Variable(String), + /// An element in an array. + ArrayElement(String, Box), +} + +impl Node for ArithmeticTarget {} + +impl SourceLocation for ArithmeticTarget { + fn location(&self) -> Option { + // TODO(source-location): complete and add loc + None + } +} + +impl Display for ArithmeticTarget { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Variable(name) => write!(f, "{name}"), + Self::ArrayElement(name, index) => write!(f, "{name}[{index}]"), + } + } +} + +#[cfg(test)] +#[allow(clippy::panic)] +mod tests { + use super::*; + use crate::{ParserOptions, SourcePosition}; + use std::io::BufReader; + + fn parse(input: &str) -> Program { + let reader = BufReader::new(input.as_bytes()); + let mut parser = crate::Parser::new(reader, &ParserOptions::default()); + parser.parse_program().unwrap() + } + + #[test] + fn program_source_loc() { + const INPUT: &str = r"echo hi +echo there +"; + + let loc = parse(INPUT).location().unwrap(); + + assert_eq!( + *(loc.start), + SourcePosition { + line: 1, + column: 1, + index: 0 + } + ); + assert_eq!( + *(loc.end), + SourcePosition { + line: 2, + column: 11, + index: 18 + } + ); + } + + #[test] + fn function_def_loc() { + const INPUT: &str = r"my_func() { + echo hi + echo there +} + +my_func +"; + + let program = parse(INPUT); + + let Command::Function(func_def) = &program.complete_commands[0].0[0].0.first.seq[0] else { + panic!("expected function definition"); + }; + + let loc = func_def.location().unwrap(); + + assert_eq!( + *(loc.start), + SourcePosition { + line: 1, + column: 1, + index: 0 + } + ); + assert_eq!( + *(loc.end), + SourcePosition { + line: 4, + column: 2, + index: 36 + } + ); + } + + #[test] + fn simple_cmd_loc() { + const INPUT: &str = r"var=value somecmd arg1 arg2 +"; + + let program = parse(INPUT); + + let Command::Simple(cmd) = &program.complete_commands[0].0[0].0.first.seq[0] else { + panic!("expected function definition"); + }; + + let loc = cmd.location().unwrap(); + + assert_eq!( + *(loc.start), + SourcePosition { + line: 1, + column: 1, + index: 0 + } + ); + assert_eq!( + *(loc.end), + SourcePosition { + line: 1, + column: 28, + index: 27 + } + ); + } +} diff --git a/local/recipes/shells/brush/source/brush-parser/src/error.rs b/local/recipes/shells/brush/source/brush-parser/src/error.rs new file mode 100644 index 0000000000..111809b147 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/error.rs @@ -0,0 +1,132 @@ +use crate::tokenizer; + +/// Represents an error that occurred while parsing tokens. +#[derive(thiserror::Error, Debug)] +pub enum ParseError { + /// A parsing error occurred near the given position. + #[error("syntax error at line {} col {}", .0.line, .0.column)] + ParsingNear(crate::SourcePosition), + + /// A parsing error occurred at the end of the input. + #[error("syntax error at end of input")] + ParsingAtEndOfInput, + + /// An error occurred while tokenizing the input stream. + #[error("{} (detected near {})", .inner, .position.as_ref().map_or_else(|| String::from(""), |p| std::format!("line {} col {}", p.line, p.column)))] + Tokenizing { + /// The inner error. + inner: tokenizer::TokenizerError, + /// Optionally provides the position of the error. + position: Option, + }, +} + +#[cfg(feature = "diagnostics")] +#[allow(clippy::cast_sign_loss)] +#[allow(unused)] // Workaround unused warnings in nightly versions of the compiler +pub mod miette { + use super::ParseError; + use miette::SourceOffset; + + impl ParseError { + /// Convert the original error to one miette can pretty print + pub fn to_pretty_error(self, input: impl Into) -> PrettyError { + let input = input.into(); + let location = match self { + Self::ParsingNear(ref pos) => { + Some(SourceOffset::from_location(&input, pos.line, pos.column)) + } + Self::Tokenizing { ref position, .. } => position + .as_ref() + .map(|p| SourceOffset::from_location(&input, p.line, p.column)), + Self::ParsingAtEndOfInput => { + Some(SourceOffset::from_location(&input, usize::MAX, usize::MAX)) + } + }; + + PrettyError { + cause: self, + input, + location, + } + } + } + + /// Represents an error that occurred while parsing tokens. + #[derive(thiserror::Error, Debug, miette::Diagnostic)] + #[error("Cannot parse the input script")] + pub struct PrettyError { + cause: ParseError, + #[source_code] + input: String, + #[label("{cause}")] + location: Option, + } +} + +/// Represents a parsing error with its location information +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub struct ParseErrorLocation { + #[from] + inner: peg::error::ParseError, +} + +/// Represents an error that occurred while parsing a word. +#[derive(Debug, thiserror::Error)] +pub enum WordParseError { + /// An error occurred while parsing an arithmetic expression. + #[error("failed to parse arithmetic expression")] + ArithmeticExpression(ParseErrorLocation), + + /// An error occurred while parsing a shell pattern. + #[error("failed to parse pattern")] + Pattern(ParseErrorLocation), + + /// An error occurred while parsing a prompt string. + #[error("failed to parse prompt string")] + Prompt(ParseErrorLocation), + + /// An error occurred while parsing a parameter. + #[error("failed to parse parameter '{0}'")] + Parameter(String, ParseErrorLocation), + + /// An error occurred while parsing for brace expansion. + #[error("failed to parse for brace expansion: '{0}'")] + BraceExpansion(String, ParseErrorLocation), + + /// An error occurred while parsing a word. + #[error("failed to parse word '{0}'")] + Word(String, ParseErrorLocation), +} + +/// Represents an error that occurred while parsing a (non-extended) test command. +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub struct TestCommandParseError(#[from] peg::error::ParseError); + +/// Represents an error that occurred while parsing a key-binding specification. +#[derive(Debug, thiserror::Error)] +pub enum BindingParseError { + /// An unknown error occurred while parsing a key-binding specification. + #[error("unknown error while parsing key-binding: '{0}'")] + Unknown(String), + + /// A key code was missing from the key-binding specification. + #[error("missing key code in key-binding")] + MissingKeyCode, +} + +pub(crate) fn convert_peg_parse_error( + err: &peg::error::ParseError, + tokens: &[crate::Token], +) -> ParseError { + let approx_token_index = err.location; + + if approx_token_index < tokens.len() { + let token = &tokens[approx_token_index]; + ParseError::ParsingNear((*token.location().start).clone()) + } else { + ParseError::ParsingAtEndOfInput + } +} diff --git a/local/recipes/shells/brush/source/brush-parser/src/lib.rs b/local/recipes/shells/brush/source/brush-parser/src/lib.rs new file mode 100644 index 0000000000..1935e628f9 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/lib.rs @@ -0,0 +1,37 @@ +//! Implements a tokenizer and parsers for POSIX / bash shell syntax. + +// TODO(unwrap): remove or scope this allow attribute +#![allow(clippy::unwrap_used)] + +pub mod arithmetic; +pub mod ast; +pub mod pattern; +pub mod prompt; +pub mod readline_binding; +pub mod test_command; +pub mod word; + +mod error; +mod parser; +mod source; +mod tokenizer; + +#[cfg(test)] +mod snapshot_tests; + +pub use error::{ + BindingParseError, ParseError, ParseErrorLocation, TestCommandParseError, WordParseError, +}; + +#[cfg(feature = "diagnostics")] +pub use error::miette::PrettyError; + +#[cfg(feature = "winnow-parser")] +pub use parser::winnow_str; +pub use parser::{Parser, ParserBuilder, ParserImpl, ParserOptions, SourceInfo, parse_tokens}; + +pub use source::{SourcePosition, SourcePositionOffset, SourceSpan}; +pub use tokenizer::{ + Token, TokenLocation, TokenizerError, TokenizerOptions, tokenize_str, + tokenize_str_with_options, uncached_tokenize_str, unquote_str, +}; diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/mod.rs b/local/recipes/shells/brush/source/brush-parser/src/parser/mod.rs new file mode 100644 index 0000000000..cfefc6cd6f --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/mod.rs @@ -0,0 +1,258 @@ +use std::path::PathBuf; + +use bon::bon; + +use crate::ast; +use crate::tokenizer::{Token, TokenEndReason, Tokenizer, TokenizerOptions, Tokens}; + +pub mod peg; +#[cfg(feature = "winnow-parser")] +pub mod winnow_str; + +/// Parser implementation to use +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Default)] +pub enum ParserImpl { + /// PEG-based parser (token-based) + #[default] + Peg, + /// Winnow-based parser (string-based, direct) + #[cfg(feature = "winnow-parser")] + Winnow, +} + +/// Options used to control the behavior of the parser. +#[derive(Clone, Eq, Hash, PartialEq)] +pub struct ParserOptions { + /// Whether or not to enable extended globbing (a.k.a. `extglob`). + pub enable_extended_globbing: bool, + /// Whether or not to enable POSIX compliance mode. + pub posix_mode: bool, + /// Whether or not to enable maximal compatibility with the `sh` shell. + pub sh_mode: bool, + /// Whether or not to perform tilde expansion for tildes at the start of words. + pub tilde_expansion_at_word_start: bool, + /// Whether or not to perform tilde expansion for tildes after colons. + pub tilde_expansion_after_colon: bool, + /// Select the parser internal implementation + pub parser_impl: ParserImpl, +} + +impl Default for ParserOptions { + fn default() -> Self { + Self { + enable_extended_globbing: true, + posix_mode: false, + sh_mode: false, + tilde_expansion_at_word_start: true, + tilde_expansion_after_colon: false, + parser_impl: ParserImpl::default(), + } + } +} + +impl ParserOptions { + /// Returns the tokenizer options implied by these parser options. + pub const fn tokenizer_options(&self) -> TokenizerOptions { + TokenizerOptions { + enable_extended_globbing: self.enable_extended_globbing, + posix_mode: self.posix_mode, + sh_mode: self.sh_mode, + } + } +} + +/// Information about the source of tokens. +#[derive(Clone, Debug, Default)] +#[allow(dead_code)] +pub struct SourceInfo { + /// The source of the tokens. + pub source: String, +} + +impl From for SourceInfo { + fn from(path: PathBuf) -> Self { + Self { + source: path.to_string_lossy().to_string(), + } + } +} + +/// Implements parsing for shell programs. +pub struct Parser { + /// The reader to use for input + reader: R, + /// Parsing options + options: ParserOptions, +} + +#[bon] +impl Parser { + /// + /// # Arguments + /// + /// * `reader` - The reader to use for input. + /// * `options` - The options to use when parsing. + pub fn new(reader: R, options: &ParserOptions) -> Self { + Self { + reader, + options: options.clone(), + } + } + + /// Create a new parser instance through a builder + #[builder( + finish_fn(doc { + /// Instantiate a parser with the provided reader as input + }) + )] + pub const fn builder( + /// The reader to use for input + #[builder(finish_fn)] + reader: R, + + #[builder(default = true)] + /// Whether or not to enable extended globbing (a.k.a. `extglob`). + enable_extended_globbing: bool, + #[builder(default = false)] + /// Whether or not to enable POSIX compliance mode. + posix_mode: bool, + #[builder(default = false)] + /// Whether or not to enable maximal compatibility with the `sh` shell. + sh_mode: bool, + #[builder(default = true)] + /// Whether or not to perform tilde expansion for tildes at the start of words. + tilde_expansion_at_word_start: bool, + #[builder(default = false)] + /// Whether or not to perform tilde expansion for tildes after colons. + tilde_expansion_after_colon: bool, + #[builder(default)] + /// Select the parser internal implementation + parser_impl: ParserImpl, + ) -> Self { + let options = ParserOptions { + enable_extended_globbing, + posix_mode, + sh_mode, + tilde_expansion_at_word_start, + tilde_expansion_after_colon, + parser_impl, + }; + Self { reader, options } + } + + /// Parses the input into an abstract syntax tree (AST) of a shell program. + pub fn parse_program(&mut self) -> Result { + // + // References: + // * https://www.gnu.org/software/bash/manual/bash.html#Shell-Syntax + // * https://mywiki.wooledge.org/BashParser + // * https://aosabook.org/en/v1/bash.html + // * https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html + // + match self.options.parser_impl { + ParserImpl::Peg => { + let tokens = self.tokenize()?; + parse_tokens(&tokens, &self.options) + } + #[cfg(feature = "winnow-parser")] + ParserImpl::Winnow => { + // Read entire input to string for winnow_str parser + let mut input_str = String::new(); + std::io::Read::read_to_string(&mut self.reader, &mut input_str).map_err(|e| { + crate::error::ParseError::Tokenizing { + inner: crate::tokenizer::TokenizerError::from(e), + position: None, + } + })?; + + winnow_str::parse_program(&input_str, &self.options, &SourceInfo::default()) + .map_err(|_e| { + // Convert winnow error to ParseError + // TODO: Extract position information from winnow error + crate::error::ParseError::ParsingAtEndOfInput + }) + } + } + } + + /// Parses a function definition body from the input. The body is expected to be + /// preceded by "()", but no function name. + pub fn parse_function_parens_and_body( + &mut self, + ) -> Result { + let tokens = self.tokenize()?; + let parse_result = + peg::token_parser::function_parens_and_body(&Tokens { tokens: &tokens }, &self.options); + parse_result_to_error(parse_result, &tokens) + } + + fn tokenize(&mut self) -> Result, crate::error::ParseError> { + // First we tokenize the input, according to the policy implied by provided options. + let mut tokenizer = Tokenizer::new(&mut self.reader, &self.options.tokenizer_options()); + + tracing::debug!(target: "tokenize", "Tokenizing..."); + + let mut tokens = vec![]; + loop { + let result = match tokenizer.next_token() { + Ok(result) => result, + Err(e) => { + return Err(crate::error::ParseError::Tokenizing { + inner: e, + position: tokenizer.current_location(), + }); + } + }; + + let reason = result.reason; + if let Some(token) = result.token { + tracing::debug!(target: "tokenize", "TOKEN {}: {:?} {reason:?}", tokens.len(), token); + tokens.push(token); + } + + if matches!(reason, TokenEndReason::EndOfInput) { + break; + } + } + + tracing::debug!(target: "tokenize", " => {} token(s)", tokens.len()); + + Ok(tokens) + } +} + +/// Parses a sequence of tokens into the abstract syntax tree (AST) of a shell program. +/// +/// # Arguments +/// +/// * `tokens` - The tokens to parse. +/// * `options` - The options to use when parsing. +pub fn parse_tokens( + tokens: &[Token], + options: &ParserOptions, +) -> Result { + let parse_result = peg::token_parser::program(&Tokens { tokens }, options); + parse_result_to_error(parse_result, tokens) +} + +fn parse_result_to_error( + parse_result: Result>, + tokens: &[Token], +) -> Result +where + R: std::fmt::Debug, +{ + match parse_result { + Ok(program) => { + tracing::debug!(target: "parse", "PROG: {:?}", program); + Ok(program) + } + Err(parse_error) => { + tracing::debug!(target: "parse", "Parse error: {:?}", parse_error); + Err(crate::error::convert_peg_parse_error(&parse_error, tokens)) + } + } +} + +#[cfg(test)] +mod tests; diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/peg.rs b/local/recipes/shells/brush/source/brush-parser/src/parser/peg.rs new file mode 100644 index 0000000000..9db57acdc8 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/peg.rs @@ -0,0 +1,825 @@ +//! PEG-based parser implementation for shell scripts. + +use crate::SourceSpan; +use crate::ast::{self, SeparatorOperator, SourceLocation, maybe_location}; +use crate::tokenizer::Token; +use crate::word; + +use super::{ParserOptions, Tokens}; + +peg::parser! { + pub grammar token_parser<'a>(parser_options: &ParserOptions) for Tokens<'a> { + pub(crate) rule program() -> ast::Program = + linebreak() c:complete_commands() linebreak() { ast::Program { complete_commands: c } } / + linebreak() { ast::Program { complete_commands: vec![] } } + + rule complete_commands() -> Vec = + c:complete_command() ++ newline_list() + + rule complete_command() -> ast::CompleteCommand = + first:and_or() remainder:(s:separator_op() l:and_or() { (s, l) })* last_sep:separator_op()? { + let mut and_ors = vec![first]; + let mut seps = vec![]; + + for (sep, ao) in remainder { + seps.push(sep); + and_ors.push(ao); + } + + // N.B. We default to synchronous if no separator op is given. + seps.push(last_sep.unwrap_or(SeparatorOperator::Sequence)); + + let mut items = vec![]; + for (i, ao) in and_ors.into_iter().enumerate() { + items.push(ast::CompoundListItem(ao, seps[i].clone())); + } + + ast::CompoundList(items) + } + + rule and_or() -> ast::AndOrList = + first:pipeline() additional:_and_or_item()* { ast::AndOrList { first, additional } } + + rule _and_or_item() -> ast::AndOr = + op:_and_or_op() linebreak() p:pipeline() { op(p) } + + rule _and_or_op() -> fn(ast::Pipeline) -> ast::AndOr = + specific_operator("&&") { ast::AndOr::And } / + specific_operator("||") { ast::AndOr::Or } + + rule pipeline() -> ast::Pipeline = + timed:pipeline_timed()? bang:bang()* seq:pipe_sequence() {? + if timed.is_none() && bang.is_empty() && seq.is_empty() { + Err("empty pipeline") + } else { + let invert = bang.len() % 2 == 1; + Ok(ast::Pipeline { timed, bang: invert, seq }) + } + } + + rule pipeline_timed() -> ast::PipelineTimed = + non_posix_extensions_enabled() s:specific_word("time") posix_output:specific_word("-p")? { + let start = s.location(); + if let Some(end) = posix_output { + ast::PipelineTimed::TimedWithPosixOutput(SourceSpan::within(start, end.location())) + } else { + ast::PipelineTimed::Timed(start.to_owned()) + } + } + + rule bang() -> bool = specific_word("!") { true } + + pub(crate) rule pipe_sequence() -> Vec = + c:(c:command() r:&pipe_extension_redirection()? {? // check for `|&` without consuming the stream. + let mut c = c; + if r.is_some() { + add_pipe_extension_redirection(&mut c)?; + } + Ok(c) + }) ** (pipe_operator() linebreak()) { + c + } + + rule pipe_operator() = + specific_operator("|") / + pipe_extension_redirection() + + rule pipe_extension_redirection() -> &'input Token = + non_posix_extensions_enabled() p:specific_operator("|&") { p } + + // N.B. We needed to move the function definition branch up to avoid conflicts with array assignment syntax. + rule command() -> ast::Command = + f:function_definition() { ast::Command::Function(f) } / + c:simple_command() { ast::Command::Simple(c) } / + c:compound_command() r:redirect_list()? { ast::Command::Compound(c, r) } / + // N.B. Extended test commands are bash extensions. + non_posix_extensions_enabled() c:extended_test_command() r:redirect_list()? { ast::Command::ExtendedTest(c, r) } / + expected!("command") + + // N.B. The arithmetic command is a non-sh extension. + // N.B. The arithmetic for clause command is a non-sh extension. + pub(crate) rule compound_command() -> ast::CompoundCommand = + non_posix_extensions_enabled() a:arithmetic_command() { ast::CompoundCommand::Arithmetic(a) } / + non_posix_extensions_enabled() c:coproc_clause() { ast::CompoundCommand::Coprocess(c) } / + b:brace_group() { ast::CompoundCommand::BraceGroup(b) } / + s:subshell() { ast::CompoundCommand::Subshell(s) } / + f:for_clause() { ast::CompoundCommand::ForClause(f) } / + c:case_clause() { ast::CompoundCommand::CaseClause(c) } / + i:if_clause() { ast::CompoundCommand::IfClause(i) } / + w:while_clause() { ast::CompoundCommand::WhileClause(w) } / + u:until_clause() { ast::CompoundCommand::UntilClause(u) } / + non_posix_extensions_enabled() c:arithmetic_for_clause() { ast::CompoundCommand::ArithmeticForClause(c) } / + expected!("compound command") + + pub(crate) rule arithmetic_command() -> ast::ArithmeticCommand = + start:specific_operator("(") specific_operator("(") expr:arithmetic_expression() specific_operator(")") end:specific_operator(")") { + let loc = SourceSpan::within( + start.location(), + end.location() + ); + ast::ArithmeticCommand { expr, loc } + } + + pub(crate) rule arithmetic_expression() -> ast::UnexpandedArithmeticExpr = + raw_expr:$(arithmetic_expression_piece()*) { ast::UnexpandedArithmeticExpr { value: raw_expr } } + + rule arithmetic_expression_piece() = + // Allow a parenthesized expression (with matching opening and closing parens). + specific_operator("(") (!specific_operator(")") arithmetic_expression_piece())* specific_operator(")") {} / + // Otherwise consume any token that's neither the normal end of the entire arithmetic expression, nor an + // unexpected mismatched closing parenthesis. In the latter case, it may be that this really was never an + // arithmetic expression in the first place and we need to backtrack and instead try parsing as a subshell + // command instead. + !arithmetic_end() !specific_operator(")") [_] {} + + // TODO(arithmetic): evaluate arithmetic end; the semicolon is used in arithmetic for loops. + rule arithmetic_end() -> () = + specific_operator(")") specific_operator(")") {} / + specific_operator(";") {} + + rule subshell() -> ast::SubshellCommand = + start:specific_operator("(") list:compound_list() end:specific_operator(")") { + let loc = SourceSpan::within(start.location(), end.location()); + ast::SubshellCommand { list, loc } + } + + rule compound_list() -> ast::CompoundList = + linebreak() first:and_or() remainder:(s:separator() l:and_or() { (s, l) })* last_sep:separator()? { + let mut and_ors = vec![first]; + let mut seps = vec![]; + + for (sep, ao) in remainder { + seps.push(sep.unwrap_or(SeparatorOperator::Sequence)); + and_ors.push(ao); + } + + // N.B. We default to synchronous if no separator op is given. + let last_sep = last_sep.unwrap_or(None); + seps.push(last_sep.unwrap_or(SeparatorOperator::Sequence)); + + let mut items = vec![]; + for (i, ao) in and_ors.into_iter().enumerate() { + items.push(ast::CompoundListItem(ao, seps[i].clone())); + } + + ast::CompoundList(items) + } + + rule for_clause() -> ast::ForClauseCommand = + s:specific_word("for") n:name() linebreak() _in() w:wordlist()? sequential_sep() d:do_group() { + let start = s.location(); + let end = &d.loc; + let loc = SourceSpan::within(start, end); + ast::ForClauseCommand { variable_name: n.to_owned(), values: w, body: d, loc } + } / + s:specific_word("for") n:name() sequential_sep()? d:do_group() { + let start = s.location(); + let end = &d.loc; + let loc = SourceSpan::within(start, end); + ast::ForClauseCommand { variable_name: n.to_owned(), values: None, body: d, loc } + } + + // N.B. The arithmetic for loop is a non-sh extension. + rule arithmetic_for_clause() -> ast::ArithmeticForClauseCommand = + s:specific_word("for") + specific_operator("(") specific_operator("(") + initializer:arithmetic_expression()? specific_operator(";") + condition:arithmetic_expression()? specific_operator(";") + updater:arithmetic_expression()? + specific_operator(")") specific_operator(")") + body:arithmetic_for_body() { + let start = s.location(); + let end = &body.loc; + let loc = SourceSpan::within(start, end); + ast::ArithmeticForClauseCommand { initializer, condition, updater, body, loc } + } + + rule arithmetic_for_body() -> ast::DoGroupCommand = + sequential_sep()? body:do_group() { body } / + body:brace_group() { ast::DoGroupCommand { list: body.list, loc: body.loc } } + + rule extended_test_command() -> ast::ExtendedTestExprCommand = + s:specific_word("[[") linebreak() expr:extended_test_expression() e:specific_word("]]") { + let start = s.location(); + let end = e.location(); + let loc = SourceSpan::within(start, end); + + ast::ExtendedTestExprCommand { expr, loc } + } + + // N.B. The placement of linebreak() here matches bash's handling of newlines + // within `[[ ]]` conditionals. Newlines are consumed (1) at the start of every + // term (after `[[`, `(`, `&&`, `||`, `!`), and (2) trailing after a *complete* + // binary test, unary-predicate test, or parenthesized expression -- but never + // after a bare-word string test (e.g. `[[ x \n ]]` is a syntax error in bash). + rule extended_test_expression() -> ast::ExtendedTestExpr = precedence! { + left:(@) specific_operator("||") linebreak() right:@ { ast::ExtendedTestExpr::Or(Box::from(left), Box::from(right)) } + -- + left:(@) specific_operator("&&") linebreak() right:@ { ast::ExtendedTestExpr::And(Box::from(left), Box::from(right)) } + -- + specific_word("!") linebreak() e:@ { ast::ExtendedTestExpr::Not(Box::from(e)) } + -- + specific_operator("(") linebreak() e:extended_test_expression() specific_operator(")") linebreak() { ast::ExtendedTestExpr::Parenthesized(Box::from(e)) } + -- + e:extended_test_binary_predicate() linebreak() { e } + -- + p:extended_unary_predicate() f:word() linebreak() { ast::ExtendedTestExpr::UnaryTest(p, ast::Word::from(f)) } + -- + w:word() { ast::ExtendedTestExpr::UnaryTest(ast::UnaryPredicate::StringHasNonZeroLength, ast::Word::from(w)) } + } + + rule extended_test_binary_predicate() -> ast::ExtendedTestExpr = + // Arithmetic operators + left:word() specific_word("-eq") right:word() { ast::ExtendedTestExpr::BinaryTest(ast::BinaryPredicate::ArithmeticEqualTo, ast::Word::from(left), ast::Word::from(right)) } / + left:word() specific_word("-ne") right:word() { ast::ExtendedTestExpr::BinaryTest(ast::BinaryPredicate::ArithmeticNotEqualTo, ast::Word::from(left), ast::Word::from(right)) } / + left:word() specific_word("-lt") right:word() { ast::ExtendedTestExpr::BinaryTest(ast::BinaryPredicate::ArithmeticLessThan, ast::Word::from(left), ast::Word::from(right)) } / + left:word() specific_word("-le") right:word() { ast::ExtendedTestExpr::BinaryTest(ast::BinaryPredicate::ArithmeticLessThanOrEqualTo, ast::Word::from(left), ast::Word::from(right)) } / + left:word() specific_word("-gt") right:word() { ast::ExtendedTestExpr::BinaryTest(ast::BinaryPredicate::ArithmeticGreaterThan, ast::Word::from(left), ast::Word::from(right)) } / + left:word() specific_word("-ge") right:word() { ast::ExtendedTestExpr::BinaryTest(ast::BinaryPredicate::ArithmeticGreaterThanOrEqualTo, ast::Word::from(left), ast::Word::from(right)) } / + // Non-arithmetic binary operators + left:word() specific_word("-ef") right:word() { ast::ExtendedTestExpr::BinaryTest(ast::BinaryPredicate::FilesReferToSameDeviceAndInodeNumbers, ast::Word::from(left), ast::Word::from(right)) } / + left:word() specific_word("-nt") right:word() { ast::ExtendedTestExpr::BinaryTest(ast::BinaryPredicate::LeftFileIsNewerOrExistsWhenRightDoesNot, ast::Word::from(left), ast::Word::from(right)) } / + left:word() specific_word("-ot") right:word() { ast::ExtendedTestExpr::BinaryTest(ast::BinaryPredicate::LeftFileIsOlderOrDoesNotExistWhenRightDoes, ast::Word::from(left), ast::Word::from(right)) } / + left:word() (specific_word("==") / specific_word("=")) right:word() { ast::ExtendedTestExpr::BinaryTest(ast::BinaryPredicate::StringExactlyMatchesPattern, ast::Word::from(left), ast::Word::from(right)) } / + left:word() specific_word("!=") right:word() { ast::ExtendedTestExpr::BinaryTest(ast::BinaryPredicate::StringDoesNotExactlyMatchPattern, ast::Word::from(left), ast::Word::from(right)) } / + left:word() specific_word("=~") right:regex_word() { + if right.value.starts_with(['\'', '\"']) { + // TODO(test): Confirm it ends with that too? + ast::ExtendedTestExpr::BinaryTest(ast::BinaryPredicate::StringContainsSubstring, ast::Word::from(left), right) + } else { + ast::ExtendedTestExpr::BinaryTest(ast::BinaryPredicate::StringMatchesRegex, ast::Word::from(left), right) + } + } / + left:word() specific_operator("<") right:word() { ast::ExtendedTestExpr::BinaryTest(ast::BinaryPredicate::LeftSortsBeforeRight, ast::Word::from(left), ast::Word::from(right)) } / + left:word() specific_operator(">") right:word() { ast::ExtendedTestExpr::BinaryTest(ast::BinaryPredicate::LeftSortsAfterRight, ast::Word::from(left), ast::Word::from(right)) } + + rule extended_unary_predicate() -> ast::UnaryPredicate = + specific_word("-a") { ast::UnaryPredicate::FileExists } / + specific_word("-b") { ast::UnaryPredicate::FileExistsAndIsBlockSpecialFile } / + specific_word("-c") { ast::UnaryPredicate::FileExistsAndIsCharSpecialFile } / + specific_word("-d") { ast::UnaryPredicate::FileExistsAndIsDir } / + specific_word("-e") { ast::UnaryPredicate::FileExists } / + specific_word("-f") { ast::UnaryPredicate::FileExistsAndIsRegularFile } / + specific_word("-g") { ast::UnaryPredicate::FileExistsAndIsSetgid } / + specific_word("-h") { ast::UnaryPredicate::FileExistsAndIsSymlink } / + specific_word("-k") { ast::UnaryPredicate::FileExistsAndHasStickyBit } / + specific_word("-n") { ast::UnaryPredicate::StringHasNonZeroLength } / + specific_word("-o") { ast::UnaryPredicate::ShellOptionEnabled } / + specific_word("-p") { ast::UnaryPredicate::FileExistsAndIsFifo } / + specific_word("-r") { ast::UnaryPredicate::FileExistsAndIsReadable } / + specific_word("-s") { ast::UnaryPredicate::FileExistsAndIsNotZeroLength } / + specific_word("-t") { ast::UnaryPredicate::FdIsOpenTerminal } / + specific_word("-u") { ast::UnaryPredicate::FileExistsAndIsSetuid } / + specific_word("-v") { ast::UnaryPredicate::ShellVariableIsSetAndAssigned } / + specific_word("-w") { ast::UnaryPredicate::FileExistsAndIsWritable } / + specific_word("-x") { ast::UnaryPredicate::FileExistsAndIsExecutable } / + specific_word("-z") { ast::UnaryPredicate::StringHasZeroLength } / + specific_word("-G") { ast::UnaryPredicate::FileExistsAndOwnedByEffectiveGroupId } / + specific_word("-L") { ast::UnaryPredicate::FileExistsAndIsSymlink } / + specific_word("-N") { ast::UnaryPredicate::FileExistsAndModifiedSinceLastRead } / + specific_word("-O") { ast::UnaryPredicate::FileExistsAndOwnedByEffectiveUserId } / + specific_word("-R") { ast::UnaryPredicate::ShellVariableIsSetAndNameRef } / + specific_word("-S") { ast::UnaryPredicate::FileExistsAndIsSocket } + + // N.B. For some reason we seem to need to allow a select subset + // of unescaped operators in regex words. + rule regex_word() -> ast::Word = + value:$((!specific_word("]]") regex_word_piece())+) { + ast::Word::from(value) + } + + rule regex_word_piece() = + word() {} / + specific_operator("|") {} / + specific_operator("(") parenthesized_regex_word()* specific_operator(")") {} + + rule parenthesized_regex_word() = + regex_word_piece() / + !specific_operator(")") !specific_operator("]]") [_] + + rule name() -> &'input str = + w:[Token::Word(_, _)] { w.to_str() } + + rule _in() -> () = + specific_word("in") { } + + rule wordlist() -> Vec = + (w:word() { ast::Word::from(w) })+ + + pub(crate) rule case_clause() -> ast::CaseClauseCommand = + start:specific_word("case") w:word() linebreak() _in() linebreak() first_items:case_item()* last_item:case_item_ns()? end:specific_word("esac") { + let mut cases = first_items; + + if let Some(last_item) = last_item { + cases.push(last_item); + } + + let loc = SourceSpan::within(start.location(), end.location()); + ast::CaseClauseCommand { value: ast::Word::from(w), cases, loc } + } + + pub(crate) rule case_item_ns() -> ast::CaseItem = + s:specific_operator("(")? p:pattern() specific_operator(")") c:compound_list() { + let start = s.map(Token::location).or_else(|| p.first().and_then(|w| w.loc.as_ref())); + let end = c.location(); + + let loc = maybe_location(start, end.as_ref()); + + ast::CaseItem { patterns: p, cmd: Some(c), post_action: ast::CaseItemPostAction::ExitCase, loc } + } / + s:specific_operator("(")? p:pattern() e:specific_operator(")") linebreak() { + let start = s.map(Token::location).or_else(|| p.first().and_then(|w| w.loc.as_ref())); + let end = Some(e.location()); + + let loc = maybe_location(start, end); + ast::CaseItem { patterns: p, cmd: None, post_action: ast::CaseItemPostAction::ExitCase, loc } + } + + pub(crate) rule case_item() -> ast::CaseItem = + s:specific_operator("(")? p:pattern() specific_operator(")") linebreak() post_action:case_item_post_action() linebreak() { + let start = s.map(Token::location).or_else(|| p.first().and_then(|w| w.loc.as_ref())); + let end = Some(post_action.1); + let loc = maybe_location(start, end); + ast::CaseItem { patterns: p, cmd: None, post_action: post_action.0, loc } + } / + s:specific_operator("(")? p:pattern() specific_operator(")") c:compound_list() post_action:case_item_post_action() linebreak() { + let start = s.map(Token::location).or_else(|| p.first().and_then(|w| w.loc.as_ref())); + let end = Some(post_action.1); + let loc = maybe_location(start, end); + ast::CaseItem { patterns: p, cmd: Some(c), post_action: post_action.0, loc } + } + + rule case_item_post_action() -> (ast::CaseItemPostAction, &'input SourceSpan) = + s:specific_operator(";;") { + (ast::CaseItemPostAction::ExitCase, s.location()) + } / + non_posix_extensions_enabled() s:specific_operator(";;&") { + (ast::CaseItemPostAction::ContinueEvaluatingCases, s.location()) + } / + non_posix_extensions_enabled() s:specific_operator(";&") { + (ast::CaseItemPostAction::UnconditionallyExecuteNextCaseItem, s.location()) + } + + rule pattern() -> Vec = + (w:word() { ast::Word::from(w) }) ++ specific_operator("|") + + rule if_clause() -> ast::IfClauseCommand = + s:specific_word("if") condition:compound_list() specific_word("then") then:compound_list() elses:else_part()? e:specific_word("fi") { + let start = s.location(); + let end = e.location(); + let loc = SourceSpan::within(start, end); + + ast::IfClauseCommand { + condition, + then, + elses, + loc + } + } + + rule else_part() -> Vec = + cs:_conditional_else_part()+ u:_unconditional_else_part()? { + let mut parts = vec![]; + for c in cs { + parts.push(c); + } + + if let Some(uncond) = u { + parts.push(uncond); + } + + parts + } / + e:_unconditional_else_part() { vec![e] } + + rule _conditional_else_part() -> ast::ElseClause = + specific_word("elif") condition:compound_list() specific_word("then") body:compound_list() { + ast::ElseClause { condition: Some(condition), body } + } + + rule _unconditional_else_part() -> ast::ElseClause = + specific_word("else") body:compound_list() { + ast::ElseClause { condition: None, body } + } + + rule while_clause() -> ast::WhileOrUntilClauseCommand = + s:specific_word("while") c:compound_list() d:do_group() { + let start = s.location(); + let end = &d.loc; + let loc = SourceSpan::within(start, end); + + ast::WhileOrUntilClauseCommand(c, d, loc) + } + + rule until_clause() -> ast::WhileOrUntilClauseCommand = + s:specific_word("until") c:compound_list() d:do_group() { + let start = s.location(); + let end = &d.loc; + let loc = SourceSpan::within(start, end); + + ast::WhileOrUntilClauseCommand(c, d, loc) + } + + // N.B. Coproc is a bash extension. + rule coproc_clause() -> ast::CoprocessCommand = + s:specific_word("coproc") linebreak() name:coproc_name()? body:command() { + let start = s.location(); + let loc = body.location().unwrap_or_else(|| start.clone()); + + ast::CoprocessCommand { name, body: Box::new(body), loc: SourceSpan::within(start, &loc) } + } + + // N.B. The name must be followed by a compound command start ({ or () + rule coproc_name() -> ast::Word = + w:fname() linebreak() &(specific_word("{") / specific_operator("(")) { + w + } + + // N.B. Non-sh extensions allows use of the 'function' word to indicate a function definition. + // N.B. Without the 'function' keyword, reserved words cannot be used as function names + // (bash rejects e.g. `for (){ :; }`). With the 'function' keyword, reserved words are + // allowed as function names (bash accepts e.g. `function for { :; }`). + rule function_definition() -> ast::FunctionDefinition = + specific_word("function") fname:fname() body:function_parens_and_body() { + ast::FunctionDefinition { fname, body } + } / + fname:non_reserved_fname() body:function_parens_and_body() { + ast::FunctionDefinition { fname, body } + } / + specific_word("function") fname:fname() linebreak() body:function_body() { + ast::FunctionDefinition { fname, body } + } / + expected!("function definition") + + pub(crate) rule function_parens_and_body() -> ast::FunctionBody = + specific_operator("(") specific_operator(")") linebreak() body:function_body() { body } + + rule function_body() -> ast::FunctionBody = + c:compound_command() r:redirect_list()? { ast::FunctionBody(c, r) } + + rule fname() -> ast::Word = + // Special-case: don't allow it to end with an equals sign, to avoid the challenge of + // misinterpreting certain declaration assignments as function definitions. + // TODO(parser): Find a way to make this still work without requiring this targeted exception. + w:[Token::Word(word, l) if !word.ends_with('=')] { ast::Word::with_location(word, l) } + + rule non_reserved_fname() -> ast::Word = + !reserved_word() w:fname() { w } + + rule brace_group() -> ast::BraceGroupCommand = + start:specific_word("{") list:compound_list() end:specific_word("}") { + let loc = SourceSpan::within(start.location(), end.location()); + ast::BraceGroupCommand { list, loc } + } + + rule do_group() -> ast::DoGroupCommand = + start:specific_word("do") list:compound_list() end:specific_word("done") { + let loc = SourceSpan::within(start.location(), end.location()); + ast::DoGroupCommand { list, loc } + } + + rule simple_command() -> ast::SimpleCommand = + prefix:cmd_prefix() word_and_suffix:(word_or_name:cmd_word() suffix:cmd_suffix()? { (word_or_name, suffix) })? { + match word_and_suffix { + Some((word_or_name, suffix)) => { + ast::SimpleCommand { prefix: Some(prefix), word_or_name: Some(ast::Word::from(word_or_name)), suffix } + } + None => { + ast::SimpleCommand { prefix: Some(prefix), word_or_name: None, suffix: None } + } + } + } / + word_or_name:cmd_name() suffix:cmd_suffix()? { + ast::SimpleCommand { prefix: None, word_or_name: Some(ast::Word::from(word_or_name)), suffix } } / + expected!("simple command") + + rule cmd_name() -> &'input Token = + non_reserved_word() + + rule cmd_word() -> &'input Token = + !assignment_word() w:non_reserved_word() { w } + + rule cmd_prefix() -> ast::CommandPrefix = + p:( + i:io_redirect() { ast::CommandPrefixOrSuffixItem::IoRedirect(i) } / + assignment_and_word:assignment_word() { + let (assignment, word) = assignment_and_word; + ast::CommandPrefixOrSuffixItem::AssignmentWord(assignment, word) + } + )+ { ast::CommandPrefix(p) } + + rule cmd_suffix() -> ast::CommandSuffix = + s:( + non_posix_extensions_enabled() sub:process_substitution() { + let (kind, subshell) = sub; + ast::CommandPrefixOrSuffixItem::ProcessSubstitution(kind, subshell) + } / + i:io_redirect() { + ast::CommandPrefixOrSuffixItem::IoRedirect(i) + } / + assignment_and_word:assignment_word() { + let (assignment, word) = assignment_and_word; + ast::CommandPrefixOrSuffixItem::AssignmentWord(assignment, word) + } / + w:word() { + ast::CommandPrefixOrSuffixItem::Word(ast::Word::from(w)) + } + )+ { ast::CommandSuffix(s) } + + rule redirect_list() -> ast::RedirectList = + r:io_redirect()+ { ast::RedirectList(r) } / + expected!("redirect list") + + // N.B. here strings are extensions to the POSIX standard. + rule io_redirect() -> ast::IoRedirect = + n:io_number()? f:io_file() { + let (kind, target) = f; + ast::IoRedirect::File(n, kind, target) + } / + non_posix_extensions_enabled() specific_operator("&>>") target:filename() { ast::IoRedirect::OutputAndError(ast::Word::from(target), true) } / + non_posix_extensions_enabled() specific_operator("&>") target:filename() { ast::IoRedirect::OutputAndError(ast::Word::from(target), false) } / + non_posix_extensions_enabled() n:io_number()? specific_operator("<<<") w:word() { ast::IoRedirect::HereString(n, ast::Word::from(w)) } / + n:io_number()? h:io_here() { ast::IoRedirect::HereDocument(n, h) } / + expected!("I/O redirect") + + // N.B. Process substitution forms are extensions to the POSIX standard. + rule io_file() -> (ast::IoFileRedirectKind, ast::IoFileRedirectTarget) = + specific_operator("<") f:io_filename() { (ast::IoFileRedirectKind::Read, f) } / + specific_operator("<&") f:io_fd_duplication_source() { (ast::IoFileRedirectKind::DuplicateInput, f) } / + specific_operator(">") f:io_filename() { (ast::IoFileRedirectKind::Write, f) } / + specific_operator(">&") f:io_fd_duplication_source() { (ast::IoFileRedirectKind::DuplicateOutput, f) } / + specific_operator(">>") f:io_filename() { (ast::IoFileRedirectKind::Append, f) } / + specific_operator("<>") f:io_filename() { (ast::IoFileRedirectKind::ReadAndWrite, f) } / + specific_operator(">|") f:io_filename() { (ast::IoFileRedirectKind::Clobber, f) } + + rule io_fd_duplication_source() -> ast::IoFileRedirectTarget = + w:word() { ast::IoFileRedirectTarget::Duplicate(ast::Word::from(w)) } + + rule io_fd() -> u32 = + w:[Token::Word(_, _)] {? w.to_str().parse().or(Err("io_fd u32")) } + + rule io_filename() -> ast::IoFileRedirectTarget = + non_posix_extensions_enabled() sub:process_substitution() { + let (kind, subshell) = sub; + ast::IoFileRedirectTarget::ProcessSubstitution(kind, subshell) + } / + f:filename() { ast::IoFileRedirectTarget::Filename(ast::Word::from(f)) } + + rule filename() -> &'input Token = + word() + + pub(crate) rule io_here() -> ast::IoHereDocument = + specific_operator("<<-") here_tag:here_tag() doc:[_] closing_tag:here_tag() { + let requires_expansion = !here_tag.to_str().contains(['\'', '"', '\\']); + ast::IoHereDocument { + remove_tabs: true, + requires_expansion, + here_end: ast::Word::from(here_tag), + doc: ast::Word::from(doc) + } + } / + specific_operator("<<") here_tag:here_tag() doc:[_] closing_tag:here_tag() { + let requires_expansion = !here_tag.to_str().contains(['\'', '"', '\\']); + ast::IoHereDocument { + remove_tabs: false, + requires_expansion, + here_end: ast::Word::from(here_tag), + doc: ast::Word::from(doc) + } + } + + rule here_tag() -> &'input Token = + word() + + rule process_substitution() -> (ast::ProcessSubstitutionKind, ast::SubshellCommand) = + specific_operator("<") s:subshell() { (ast::ProcessSubstitutionKind::Read, s) } / + specific_operator(">") s:subshell() { (ast::ProcessSubstitutionKind::Write, s) } + + rule newline_list() -> () = + newline()+ {} + + rule linebreak() -> () = + quiet! { + newline()* {} + } + + rule separator_op() -> ast::SeparatorOperator = + specific_operator("&") { ast::SeparatorOperator::Async } / + specific_operator(";") { ast::SeparatorOperator::Sequence } + + rule separator() -> Option = + s:separator_op() linebreak() { Some(s) } / + newline_list() { None } + + rule sequential_sep() -> () = + specific_operator(";") linebreak() / + newline_list() + + // + // Token interpretation + // + + rule non_reserved_word() -> &'input Token = + !reserved_word() w:word() { w } + + rule word() -> &'input Token = + [Token::Word(_, _)] + + rule reserved_word() -> &'input Token = + [Token::Word(w, _) if matches!(w.as_str(), + "!" | + "{" | + "}" | + "case" | + "do" | + "done" | + "elif" | + "else" | + "esac" | + "fi" | + "for" | + "if" | + "in" | + "then" | + "until" | + "while" + )] / + + // N.B. bash also treats the following as reserved. + non_posix_extensions_enabled() token:non_posix_reserved_word_token() { token } + + rule non_posix_reserved_word_token() -> &'input Token = + specific_word("[[") / + specific_word("]]") / + specific_word("function") / + specific_word("select") / + specific_word("coproc") + + rule newline() -> () = quiet! { + specific_operator("\n") {} + } + + pub(crate) rule assignment_word() -> (ast::Assignment, ast::Word) = + non_posix_extensions_enabled() [Token::Word(w, l)] specific_operator("(") elements:array_elements() end:specific_operator(")") {? + let mut parsed = word::parse_array_assignment(w.as_str(), elements.as_slice())?; + + let mut all_as_word = w.to_owned(); + all_as_word.push('('); + for (i, e) in elements.iter().enumerate() { + if i > 0 { + all_as_word.push(' '); + } + all_as_word.push_str(e); + } + all_as_word.push(')'); + + let loc = SourceSpan::within(l, end.location()); + parsed.loc = loc.clone(); + Ok((parsed, ast::Word::with_location(&all_as_word, &loc))) + } / + [Token::Word(w, l)] {? + let mut parsed = word::parse_assignment_word(w.as_str()).map_err(|_| "not assignment word")?; + parsed.loc = l.clone(); + Ok((parsed, ast::Word::with_location(w, l))) + } + + rule array_elements() -> Vec<&'input String> = + linebreak() e:array_element()* { e } + + rule array_element() -> &'input String = + linebreak() [Token::Word(e, _)] linebreak() { e } + + // N.B. An I/O number must be a string of only digits, and it must be + // followed by a '<' or '>' character (but not consume them). We also + // need to make sure that there was no space between the number and the + // redirection operator; unfortunately we don't have the space anymore + // but we can infer it by looking at the tokens' locations. + rule io_number() -> ast::IoFd = + [Token::Word(w, num_loc) if w.chars().all(|c: char| c.is_ascii_digit())] + &([Token::Operator(o, redir_loc) if + o.starts_with(['<', '>']) && + locations_are_contiguous(num_loc, redir_loc)]) { + + w.parse().unwrap() + } + + // + // Helpers + // + rule specific_operator(expected: &str) -> &'input Token = + [Token::Operator(w, _) if w.as_str() == expected] + + rule specific_word(expected: &str) -> &'input Token = + [Token::Word(w, _) if w.as_str() == expected] + + rule non_posix_extensions_enabled() -> () = + &[_] {? if !parser_options.sh_mode { Ok(()) } else { Err("posix") } } + } +} + +// add `2>&1` to the command if the pipeline is `|&` +fn add_pipe_extension_redirection(c: &mut ast::Command) -> Result<(), &'static str> { + fn add_to_redirect_list(l: &mut Option, r: ast::IoRedirect) { + if let Some(l) = l { + l.0.push(r); + } else { + let v = vec![r]; + *l = Some(ast::RedirectList(v)); + } + } + + let r = ast::IoRedirect::File( + Some(2), + ast::IoFileRedirectKind::DuplicateOutput, + ast::IoFileRedirectTarget::Fd(1), + ); + + match c { + ast::Command::Simple(c) => { + let r = ast::CommandPrefixOrSuffixItem::IoRedirect(r); + if let Some(l) = &mut c.suffix { + l.0.push(r); + } else { + c.suffix = Some(ast::CommandSuffix(vec![r])); + } + } + ast::Command::Compound(_, l) => add_to_redirect_list(l, r), + ast::Command::Function(f) => add_to_redirect_list(&mut f.body.1, r), + ast::Command::ExtendedTest(..) => return Err("|& unimplemented for extended tests"), + } + + Ok(()) +} + +#[inline] +fn locations_are_contiguous(loc_left: &crate::SourceSpan, loc_right: &crate::SourceSpan) -> bool { + loc_left.end.index == loc_right.start.index +} + +impl peg::Parse for Tokens<'_> { + type PositionRepr = usize; + + #[inline] + fn start(&self) -> usize { + 0 + } + + #[inline] + fn is_eof(&self, p: usize) -> bool { + p >= self.tokens.len() + } + + #[inline] + fn position_repr(&self, p: usize) -> Self::PositionRepr { + p + } +} + +impl<'a> peg::ParseElem<'a> for Tokens<'a> { + type Element = &'a Token; + + #[inline] + fn parse_elem(&'a self, pos: usize) -> peg::RuleResult { + match self.tokens.get(pos) { + Some(c) => peg::RuleResult::Matched(pos + 1, c), + None => peg::RuleResult::Failed, + } + } +} + +impl<'a> peg::ParseSlice<'a> for Tokens<'a> { + type Slice = String; + + /// Reconstructs a source string from a slice of tokens. + /// + /// Uses each token's source position to detect whether whitespace existed + /// between adjacent tokens in the original source, preserving it as a + /// single space. This matters for constructs like `[[ x =~ (a| *) ]]` + /// where the space inside the regex group is significant. + /// + /// N.B. This relies on tokens having accurate, contiguous source + /// positions. All tokens produced by the normal tokenizer path satisfy + /// this. If positions are missing or non-monotonic (as can happen with + /// synthetic here-document end-tag tokens), the gap check evaluates + /// false and no space is inserted — a safe degradation to the previous + /// behavior, which also omitted spaces between non-word tokens. + fn parse_slice(&'a self, start: usize, end: usize) -> Self::Slice { + let mut result = String::new(); + let mut prev_end_index: Option = None; + + for token in &self.tokens[start..end] { + let loc = token.location(); + + if let Some(prev_end) = prev_end_index { + if loc.start.index > prev_end { + result.push(' '); + } + } + + result.push_str(token.to_str()); + prev_end_index = Some(loc.end.index); + } + + result + } +} diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/snapshots/brush_parser__parser__tests__parse_arith_and_non_arith_parens.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/snapshots/brush_parser__parser__tests__parse_arith_and_non_arith_parens.snap new file mode 100644 index 0000000000..2696c380a3 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/snapshots/brush_parser__parser__tests__parse_arith_and_non_arith_parens.snap @@ -0,0 +1,126 @@ +--- +source: brush-parser/src/parser/mod.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "( : && ( (( 0 )) || : ) )", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(Subshell(SubshellCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: ":", + loc: Some(SourceSpan( + start: SourcePosition( + index: 2, + line: 1, + column: 3, + ), + end: SourcePosition( + index: 3, + line: 1, + column: 4, + ), + )), + )), + )), + ], + ), + additional: [ + And(Pipeline( + seq: [ + Compound(Subshell(SubshellCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(Arithmetic(ArithmeticCommand( + expr: UnexpandedArithmeticExpr( + value: "0", + ), + loc: SourceSpan( + start: SourcePosition( + index: 9, + line: 1, + column: 10, + ), + end: SourcePosition( + index: 16, + line: 1, + column: 17, + ), + ), + )), None), + ], + ), + additional: [ + Or(Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: ":", + loc: Some(SourceSpan( + start: SourcePosition( + index: 20, + line: 1, + column: 21, + ), + end: SourcePosition( + index: 21, + line: 1, + column: 22, + ), + )), + )), + )), + ], + )), + ], + ), Sequence), + ]), + loc: SourceSpan( + start: SourcePosition( + index: 7, + line: 1, + column: 8, + ), + end: SourcePosition( + index: 23, + line: 1, + column: 24, + ), + ), + )), None), + ], + )), + ], + ), Sequence), + ]), + loc: SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 25, + line: 1, + column: 26, + ), + ), + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/snapshots/brush_parser__parser__tests__parse_case.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/snapshots/brush_parser__parser__tests__parse_case.snap new file mode 100644 index 0000000000..9355327ed2 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/snapshots/brush_parser__parser__tests__parse_case.snap @@ -0,0 +1,124 @@ +--- +source: brush-parser/src/parser/mod.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "\\\ncase x in\nx)\n echo y;;\nesac\\\n", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(CaseClause(CaseClauseCommand( + value: Word( + value: "x", + loc: Some(SourceSpan( + start: SourcePosition( + index: 7, + line: 2, + column: 6, + ), + end: SourcePosition( + index: 8, + line: 2, + column: 7, + ), + )), + ), + cases: [ + CaseItem( + patterns: [ + Word( + value: "x", + loc: Some(SourceSpan( + start: SourcePosition( + index: 12, + line: 3, + column: 1, + ), + end: SourcePosition( + index: 13, + line: 3, + column: 2, + ), + )), + ), + ], + cmd: Some(CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: Some(SourceSpan( + start: SourcePosition( + index: 19, + line: 4, + column: 5, + ), + end: SourcePosition( + index: 23, + line: 4, + column: 9, + ), + )), + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "y", + loc: Some(SourceSpan( + start: SourcePosition( + index: 24, + line: 4, + column: 10, + ), + end: SourcePosition( + index: 25, + line: 4, + column: 11, + ), + )), + )), + ])), + )), + ], + ), + ), Sequence), + ])), + post_action: ExitCase, + loc: Some(SourceSpan( + start: SourcePosition( + index: 12, + line: 3, + column: 1, + ), + end: SourcePosition( + index: 27, + line: 4, + column: 13, + ), + )), + ), + ], + loc: SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 34, + line: 6, + column: 1, + ), + ), + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/snapshots/brush_parser__parser__tests__parse_case_ns.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/snapshots/brush_parser__parser__tests__parse_case_ns.snap new file mode 100644 index 0000000000..3a84a5f304 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/snapshots/brush_parser__parser__tests__parse_case_ns.snap @@ -0,0 +1,124 @@ +--- +source: brush-parser/src/parser/mod.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "\\\ncase x in\nx)\n echo y\nesac\\\n", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(CaseClause(CaseClauseCommand( + value: Word( + value: "x", + loc: Some(SourceSpan( + start: SourcePosition( + index: 7, + line: 2, + column: 6, + ), + end: SourcePosition( + index: 8, + line: 2, + column: 7, + ), + )), + ), + cases: [ + CaseItem( + patterns: [ + Word( + value: "x", + loc: Some(SourceSpan( + start: SourcePosition( + index: 12, + line: 3, + column: 1, + ), + end: SourcePosition( + index: 13, + line: 3, + column: 2, + ), + )), + ), + ], + cmd: Some(CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: Some(SourceSpan( + start: SourcePosition( + index: 19, + line: 4, + column: 5, + ), + end: SourcePosition( + index: 23, + line: 4, + column: 9, + ), + )), + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "y", + loc: Some(SourceSpan( + start: SourcePosition( + index: 24, + line: 4, + column: 10, + ), + end: SourcePosition( + index: 25, + line: 4, + column: 11, + ), + )), + )), + ])), + )), + ], + ), + ), Sequence), + ])), + post_action: ExitCase, + loc: Some(SourceSpan( + start: SourcePosition( + index: 12, + line: 3, + column: 1, + ), + end: SourcePosition( + index: 25, + line: 4, + column: 11, + ), + )), + ), + ], + loc: SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 32, + line: 6, + column: 1, + ), + ), + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/snapshots/brush_parser__parser__tests__parse_function_with_pipe_redirection-2.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/snapshots/brush_parser__parser__tests__parse_function_with_pipe_redirection-2.snap new file mode 100644 index 0000000000..096dae1958 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/snapshots/brush_parser__parser__tests__parse_function_with_pipe_redirection-2.snap @@ -0,0 +1,111 @@ +--- +source: brush-parser/src/parser/mod.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "foo() { echo 1; } |& cat", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Function(FunctionDefinition( + fname: Word( + value: "foo", + loc: Some(SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 3, + line: 1, + column: 4, + ), + )), + ), + body: FunctionBody(BraceGroup(BraceGroupCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: Some(SourceSpan( + start: SourcePosition( + index: 8, + line: 1, + column: 9, + ), + end: SourcePosition( + index: 12, + line: 1, + column: 13, + ), + )), + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "1", + loc: Some(SourceSpan( + start: SourcePosition( + index: 13, + line: 1, + column: 14, + ), + end: SourcePosition( + index: 14, + line: 1, + column: 15, + ), + )), + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: SourceSpan( + start: SourcePosition( + index: 6, + line: 1, + column: 7, + ), + end: SourcePosition( + index: 17, + line: 1, + column: 18, + ), + ), + )), Some(RedirectList([ + File(Some(2), DuplicateOutput, Fd(1)), + ]))), + )), + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cat", + loc: Some(SourceSpan( + start: SourcePosition( + index: 21, + line: 1, + column: 22, + ), + end: SourcePosition( + index: 24, + line: 1, + column: 25, + ), + )), + )), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/snapshots/brush_parser__parser__tests__parse_function_with_pipe_redirection.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/snapshots/brush_parser__parser__tests__parse_function_with_pipe_redirection.snap new file mode 100644 index 0000000000..acb5d88369 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/snapshots/brush_parser__parser__tests__parse_function_with_pipe_redirection.snap @@ -0,0 +1,125 @@ +--- +source: brush-parser/src/parser/mod.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "foo() { echo 1; } 2>&1 | cat", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Function(FunctionDefinition( + fname: Word( + value: "foo", + loc: Some(SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 3, + line: 1, + column: 4, + ), + )), + ), + body: FunctionBody(BraceGroup(BraceGroupCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: Some(SourceSpan( + start: SourcePosition( + index: 8, + line: 1, + column: 9, + ), + end: SourcePosition( + index: 12, + line: 1, + column: 13, + ), + )), + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "1", + loc: Some(SourceSpan( + start: SourcePosition( + index: 13, + line: 1, + column: 14, + ), + end: SourcePosition( + index: 14, + line: 1, + column: 15, + ), + )), + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: SourceSpan( + start: SourcePosition( + index: 6, + line: 1, + column: 7, + ), + end: SourcePosition( + index: 17, + line: 1, + column: 18, + ), + ), + )), Some(RedirectList([ + File(Some(2), DuplicateOutput, Duplicate(Word( + value: "1", + loc: Some(SourceSpan( + start: SourcePosition( + index: 21, + line: 1, + column: 22, + ), + end: SourcePosition( + index: 22, + line: 1, + column: 23, + ), + )), + ))), + ]))), + )), + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cat", + loc: Some(SourceSpan( + start: SourcePosition( + index: 25, + line: 1, + column: 26, + ), + end: SourcePosition( + index: 28, + line: 1, + column: 29, + ), + )), + )), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/snapshots/brush_parser__parser__tests__parse_here_doc_with_no_trailing_newline.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/snapshots/brush_parser__parser__tests__parse_here_doc_with_no_trailing_newline.snap new file mode 100644 index 0000000000..cb83307ea0 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/snapshots/brush_parser__parser__tests__parse_here_doc_with_no_trailing_newline.snap @@ -0,0 +1,71 @@ +--- +source: brush-parser/src/parser/mod.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "cat <&2\n\n done\n\n", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(ForClause(ForClauseCommand( + variable_name: "f", + values: Some([ + Word( + value: "A", + loc: Some(SourceSpan( + start: SourcePosition( + index: 32, + line: 5, + column: 10, + ), + end: SourcePosition( + index: 33, + line: 5, + column: 11, + ), + )), + ), + Word( + value: "B", + loc: Some(SourceSpan( + start: SourcePosition( + index: 34, + line: 5, + column: 12, + ), + end: SourcePosition( + index: 35, + line: 5, + column: 13, + ), + )), + ), + Word( + value: "C", + loc: Some(SourceSpan( + start: SourcePosition( + index: 36, + line: 5, + column: 14, + ), + end: SourcePosition( + index: 37, + line: 5, + column: 15, + ), + )), + ), + ]), + body: DoGroupCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: Some(SourceSpan( + start: SourcePosition( + index: 60, + line: 8, + column: 5, + ), + end: SourcePosition( + index: 64, + line: 8, + column: 9, + ), + )), + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "\"${f@L}\"", + loc: Some(SourceSpan( + start: SourcePosition( + index: 65, + line: 8, + column: 10, + ), + end: SourcePosition( + index: 73, + line: 8, + column: 18, + ), + )), + )), + IoRedirect(File(None, DuplicateOutput, Duplicate(Word( + value: "2", + loc: Some(SourceSpan( + start: SourcePosition( + index: 76, + line: 8, + column: 21, + ), + end: SourcePosition( + index: 77, + line: 8, + column: 22, + ), + )), + )))), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: SourceSpan( + start: SourcePosition( + index: 39, + line: 5, + column: 17, + ), + end: SourcePosition( + index: 86, + line: 10, + column: 8, + ), + ), + ), + loc: SourceSpan( + start: SourcePosition( + index: 23, + line: 5, + column: 1, + ), + end: SourcePosition( + index: 86, + line: 10, + column: 8, + ), + ), + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/snapshots/brush_parser__parser__tests__parse_redirection.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/snapshots/brush_parser__parser__tests__parse_redirection.snap new file mode 100644 index 0000000000..dd851ae28c --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/snapshots/brush_parser__parser__tests__parse_redirection.snap @@ -0,0 +1,56 @@ +--- +source: brush-parser/src/parser/mod.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "echo |& wc", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: Some(SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 4, + line: 1, + column: 5, + ), + )), + )), + suffix: Some(CommandSuffix([ + IoRedirect(File(Some(2), DuplicateOutput, Fd(1))), + ])), + )), + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "wc", + loc: Some(SourceSpan( + start: SourcePosition( + index: 8, + line: 1, + column: 9, + ), + end: SourcePosition( + index: 10, + line: 1, + column: 11, + ), + )), + )), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/and_or_lists.rs b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/and_or_lists.rs new file mode 100644 index 0000000000..f0df1ea106 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/and_or_lists.rs @@ -0,0 +1,82 @@ +//! Tests for and/or list parsing. + +use super::{ParseResult, test_with_snapshot}; +use crate::assert_snapshot_redacted; +use anyhow::Result; + +#[test] +fn parse_simple_and() -> Result<()> { + let input = "true && echo yes"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_simple_or() -> Result<()> { + let input = "false || echo no"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_chained_and() -> Result<()> { + let input = "cmd1 && cmd2 && cmd3"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_chained_or() -> Result<()> { + let input = "cmd1 || cmd2 || cmd3"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_mixed_and_or() -> Result<()> { + let input = "cmd1 && cmd2 || cmd3"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_and_or_with_pipes() -> Result<()> { + let input = "cmd1 | cmd2 && cmd3 | cmd4"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_and_or_in_sequence() -> Result<()> { + let input = "cmd1 && cmd2; cmd3 || cmd4"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/assignments.rs b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/assignments.rs new file mode 100644 index 0000000000..d579546e45 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/assignments.rs @@ -0,0 +1,248 @@ +//! Tests for assignment parsing. + +use super::{ParseResult, test_with_snapshot}; +use crate::assert_snapshot_redacted; +use anyhow::Result; + +// Scalar assignments + +#[test] +fn parse_assignment_simple() -> Result<()> { + let input = "x=value"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_assignment_empty() -> Result<()> { + let input = "x="; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_assignment_quoted() -> Result<()> { + let input = r#"x="hello world""#; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_assignment_single_quoted() -> Result<()> { + let input = "x='hello world'"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_assignment_with_expansion() -> Result<()> { + let input = "x=$HOME/bin"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_assignment_with_command_substitution() -> Result<()> { + let input = "x=$(pwd)"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +// Append assignments + +#[test] +fn parse_assignment_append() -> Result<()> { + let input = "x+=more"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_assignment_append_quoted() -> Result<()> { + let input = r#"x+=" more text""#; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +// Array assignments + +#[test] +fn parse_assignment_array() -> Result<()> { + let input = "arr=(a b c)"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_assignment_array_empty() -> Result<()> { + let input = "arr=()"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_assignment_array_with_indices() -> Result<()> { + let input = "arr=([0]=a [1]=b [2]=c)"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_assignment_array_mixed() -> Result<()> { + let input = "arr=(a [5]=b c)"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_assignment_array_quoted_elements() -> Result<()> { + let input = r#"arr=("hello world" "foo bar")"#; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +// Array element assignment + +#[test] +fn parse_assignment_array_element() -> Result<()> { + let input = "arr[0]=value"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_assignment_array_element_expression() -> Result<()> { + let input = "arr[i+1]=value"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +// Multiple assignments + +#[test] +fn parse_multiple_assignments() -> Result<()> { + let input = "x=1 y=2 z=3"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_assignment_with_command() -> Result<()> { + let input = "VAR=value command arg1 arg2"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_multiple_assignments_with_command() -> Result<()> { + let input = "A=1 B=2 command"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +// Export/local with assignment + +#[test] +fn parse_export_assignment() -> Result<()> { + let input = "export VAR=value"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_local_assignment() -> Result<()> { + let input = "local x=5"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_declare_assignment() -> Result<()> { + let input = "declare -i x=5"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/complex.rs b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/complex.rs new file mode 100644 index 0000000000..5ede5fe9e2 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/complex.rs @@ -0,0 +1,255 @@ +//! Complex and integration tests that combine multiple parser features. + +use super::{ParseResult, test_with_snapshot}; +use crate::assert_snapshot_redacted; +use anyhow::Result; + +#[test] +fn parse_shebang_and_program() -> Result<()> { + let input = r#"#!/usr/bin/env bash + +for f in A B C; do + + # sdfsdf + echo "${f@L}" >&2 + + done +"#; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_case_with_newlines() -> Result<()> { + let input = r"case x in +x) + echo y;; +esac"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_case_no_semicolon() -> Result<()> { + let input = r"case x in +x) + echo y +esac"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_nested_if_while() -> Result<()> { + let input = r#"if true; then + while read line; do + echo "$line" + done < file.txt +fi"#; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_function_with_if() -> Result<()> { + let input = r#"myfunc() { + if [[ -z "$1" ]]; then + echo "No argument" + return 1 + fi + echo "Got: $1" +}"#; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_pipeline_with_redirections() -> Result<()> { + let input = "cat < input.txt | grep pattern | tee output.txt > /dev/null 2>&1"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_command_substitution_nested() -> Result<()> { + let input = "echo \"$(echo $(pwd))\""; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_for_with_command_substitution() -> Result<()> { + let input = "for f in $(ls *.txt); do cat \"$f\"; done"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_arithmetic_in_condition() -> Result<()> { + let input = "if (( x > 5 )); then echo big; else echo small; fi"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_brace_expansion_context() -> Result<()> { + let input = "echo {a,b,c}"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +#[allow(clippy::literal_string_with_formatting_args)] +fn parse_parameter_expansion_complex() -> Result<()> { + let input = r#"echo "${var:-default}" "${var:+alt}" "${var:=assign}""#; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_subshell_with_assignments() -> Result<()> { + let input = "( x=1; y=2; echo $((x + y)) )"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_coprocess() -> Result<()> { + let input = "coproc cat"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_multiple_here_docs() -> Result<()> { + let input = r"cmd < Result<()> { + let input = "cmd1 & cmd2 & cmd3"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_mixed_sequences() -> Result<()> { + let input = "cmd1; cmd2 && cmd3 || cmd4; cmd5"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_complex_array_operations() -> Result<()> { + let input = r#"arr=($(seq 1 10)); echo "${arr[@]}"; echo "${#arr[@]}""#; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_glob_patterns() -> Result<()> { + let input = "ls *.txt **/foo.* file?.txt"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_extglob_patterns() -> Result<()> { + let input = "ls !(*.txt) +(foo|bar) ?(a|b)"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_tilde_expansion() -> Result<()> { + let input = "cd ~/projects; ls ~user/home"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/compound_commands.rs b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/compound_commands.rs new file mode 100644 index 0000000000..a0880422dd --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/compound_commands.rs @@ -0,0 +1,317 @@ +//! Tests for compound command parsing. + +use super::{ParseResult, test_with_snapshot}; +use crate::assert_snapshot_redacted; +use anyhow::Result; + +// Arithmetic commands + +#[test] +fn parse_arithmetic_simple() -> Result<()> { + let input = "(( 1 + 2 ))"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_arithmetic_increment() -> Result<()> { + let input = "(( x++ ))"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_arithmetic_complex() -> Result<()> { + let input = "(( x = 5 + 3 * 2 ))"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +// Arithmetic for clause + +#[test] +fn parse_arithmetic_for() -> Result<()> { + let input = "for (( i = 0; i < 10; i++ )); do echo $i; done"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_arithmetic_for_empty_parts() -> Result<()> { + let input = "for (( ; ; )); do echo loop; done"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +// Brace group + +#[test] +fn parse_brace_group() -> Result<()> { + let input = "{ echo hello; }"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_brace_group_multiline() -> Result<()> { + let input = r"{ + echo hello + echo world +}"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +// Subshell + +#[test] +fn parse_subshell() -> Result<()> { + let input = "( echo hello )"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_subshell_multiple_commands() -> Result<()> { + let input = "( echo hello; echo world )"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_nested_subshell() -> Result<()> { + let input = "( ( echo nested ) )"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +// For clause + +#[test] +fn parse_for_in() -> Result<()> { + let input = "for x in a b c; do echo $x; done"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_for_in_multiline() -> Result<()> { + let input = r"for x in a b c +do + echo $x +done"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_for_no_in() -> Result<()> { + let input = "for x; do echo $x; done"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +// Case clause + +#[test] +fn parse_case_simple() -> Result<()> { + let input = "case x in a) echo a;; esac"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_case_multiple_patterns() -> Result<()> { + let input = r"case x in + a|b) echo ab;; + c) echo c;; + *) echo default;; +esac"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_case_fallthrough() -> Result<()> { + let input = "case x in a) echo a;& b) echo b;; esac"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_case_continue() -> Result<()> { + let input = "case x in a) echo a;;& b) echo b;; esac"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +// If clause + +#[test] +fn parse_if_simple() -> Result<()> { + let input = "if true; then echo yes; fi"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_if_else() -> Result<()> { + let input = "if true; then echo yes; else echo no; fi"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_if_elif() -> Result<()> { + let input = "if false; then echo one; elif true; then echo two; else echo three; fi"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_if_multiline() -> Result<()> { + let input = r"if true +then + echo yes +else + echo no +fi"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +// While/Until + +#[test] +fn parse_while() -> Result<()> { + let input = "while true; do echo loop; done"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_until() -> Result<()> { + let input = "until false; do echo loop; done"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_while_multiline() -> Result<()> { + let input = r"while true +do + echo loop + break +done"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +// Mixed/nested + +#[test] +fn parse_arith_and_non_arith_parens() -> Result<()> { + let input = "( : && ( (( 0 )) || : ) )"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/extended_test.rs b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/extended_test.rs new file mode 100644 index 0000000000..99be163805 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/extended_test.rs @@ -0,0 +1,268 @@ +//! Tests for extended test expression [[ ]] parsing. + +use super::{ParseResult, test_with_snapshot}; +use crate::assert_snapshot_redacted; +use anyhow::Result; + +// File tests + +#[test] +fn parse_extended_test_file_exists() -> Result<()> { + let input = "[[ -f file ]]"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_extended_test_directory() -> Result<()> { + let input = "[[ -d /path/to/dir ]]"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_extended_test_readable() -> Result<()> { + let input = "[[ -r file ]]"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_extended_test_writable() -> Result<()> { + let input = "[[ -w file ]]"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_extended_test_executable() -> Result<()> { + let input = "[[ -x file ]]"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +// String tests + +#[test] +fn parse_extended_test_string_zero_length() -> Result<()> { + let input = r#"[[ -z "$var" ]]"#; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_extended_test_string_non_zero() -> Result<()> { + let input = r#"[[ -n "$var" ]]"#; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_extended_test_string_equal() -> Result<()> { + let input = r#"[[ "$a" == "$b" ]]"#; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_extended_test_string_not_equal() -> Result<()> { + let input = r#"[[ "$a" != "$b" ]]"#; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_extended_test_string_pattern() -> Result<()> { + let input = r#"[[ "$str" == *pattern* ]]"#; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_extended_test_regex() -> Result<()> { + let input = r#"[[ "$str" =~ ^[0-9]+$ ]]"#; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_extended_test_regex_with_spaces() -> Result<()> { + let input = r#"[[ "x" =~ (a| *) ]]"#; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +// Logical operators + +#[test] +fn parse_extended_test_and() -> Result<()> { + let input = "[[ -f file && -r file ]]"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_extended_test_or() -> Result<()> { + let input = "[[ -f file || -d file ]]"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_extended_test_not() -> Result<()> { + let input = "[[ ! -f file ]]"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_extended_test_parenthesized() -> Result<()> { + let input = "[[ ( -f file ) ]]"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_extended_test_complex() -> Result<()> { + let input = "[[ ( -f file && -r file ) || -d file ]]"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +// Comparison operators + +#[test] +fn parse_extended_test_less_than() -> Result<()> { + let input = r#"[[ "$a" < "$b" ]]"#; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_extended_test_greater_than() -> Result<()> { + let input = r#"[[ "$a" > "$b" ]]"#; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +// Arithmetic comparison + +#[test] +fn parse_extended_test_arith_equal() -> Result<()> { + let input = "[[ 5 -eq 5 ]]"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_extended_test_arith_not_equal() -> Result<()> { + let input = "[[ 5 -ne 3 ]]"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_extended_test_arith_less_than() -> Result<()> { + let input = "[[ 3 -lt 5 ]]"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_extended_test_arith_greater_than() -> Result<()> { + let input = "[[ 5 -gt 3 ]]"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/functions.rs b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/functions.rs new file mode 100644 index 0000000000..857928ec00 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/functions.rs @@ -0,0 +1,99 @@ +//! Tests for function definition parsing. + +use super::{ParseResult, test_with_snapshot}; +use crate::assert_snapshot_redacted; +use anyhow::Result; + +#[test] +fn parse_function_basic() -> Result<()> { + let input = "foo() { echo hello; }"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_function_keyword() -> Result<()> { + let input = "function foo { echo hello; }"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_function_keyword_with_parens() -> Result<()> { + let input = "function foo() { echo hello; }"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_function_with_redirect() -> Result<()> { + let input = "foo() { echo 1; } 2>&1 | cat"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_function_with_stderr_redirect() -> Result<()> { + let input = "foo() { echo 1; } |& cat"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_function_multiline() -> Result<()> { + let input = r"foo() { + echo hello + echo world +}"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_function_with_subshell_body() -> Result<()> { + let input = "foo() ( echo subshell )"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_function_with_local_vars() -> Result<()> { + let input = r"foo() { + local x=1 + echo $x +}"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/here_docs.rs b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/here_docs.rs new file mode 100644 index 0000000000..a52e343d60 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/here_docs.rs @@ -0,0 +1,129 @@ +//! Tests for here-document parsing. + +use super::{ParseResult, test_with_snapshot}; +use crate::assert_snapshot_redacted; +use anyhow::Result; + +#[test] +fn parse_here_doc_basic() -> Result<()> { + let input = r"cat < Result<()> { + let input = r"cat < Result<()> { + let input = "cat <<-EOF\n\tcontent with tab\nEOF\n"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_here_doc_quoted_delimiter() -> Result<()> { + let input = r"cat <<'EOF' +$variable should not expand +EOF +"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_here_doc_double_quoted_delimiter() -> Result<()> { + let input = r#"cat <<"EOF" +$variable should not expand +EOF +"#; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_here_doc_with_expansion() -> Result<()> { + let input = r"cat < Result<()> { + let input = r"cat < Result<()> { + let input = r"cat < Result<()> { + let input = r"command 3< { + pub input: &'a str, + pub result: &'a T, +} + +/// Macro to assert snapshots with location information redacted. +/// This makes snapshots stable across parser changes that only affect source locations. +#[macro_export] +macro_rules! assert_snapshot_redacted { + ($value:expr) => {{ + let mut settings = insta::Settings::clone_current(); + settings.add_redaction(".**.loc", "[location]"); + settings.bind(|| { + insta::assert_ron_snapshot!($value); + }); + }}; +} + +/// Recursively redact location fields from a JSON value. +/// This normalizes AST representations for comparison by removing source location info. +#[cfg(feature = "winnow-parser")] +fn redact_locations(value: &mut Value) { + match value { + Value::Object(map) => { + // Remove location-related fields + map.remove("loc"); + // Also handle tuple structs that store SourceSpan as positional element + // These appear as arrays with SourceSpan objects + + // Recursively process remaining fields + for (_, v) in map.iter_mut() { + // If this value is a SourceSpan object, normalize it + if is_source_span(v) { + normalize_source_span(v); + } else { + redact_locations(v); + } + } + } + Value::Array(arr) => { + // Check if this array looks like a tuple struct containing SourceSpan at the end + // SourceSpan has: start: SourcePosition, end: SourcePosition + // SourcePosition has: index, line, column + if let Some(last) = arr.last() { + if is_source_span(last) { + arr.pop(); + } + } + + for item in arr.iter_mut() { + redact_locations(item); + } + } + _ => {} + } +} + +/// Check if a value looks like a `SourceSpan` object +#[cfg(feature = "winnow-parser")] +fn is_source_span(value: &Value) -> bool { + if let Value::Object(map) = value { + map.contains_key("start") && map.contains_key("end") && map.len() == 2 + } else { + false + } +} + +/// Normalize a `SourceSpan` object by replacing its positions with placeholder values. +/// This allows comparing ASTs without position differences. +#[cfg(feature = "winnow-parser")] +fn normalize_source_span(value: &mut Value) { + if let Value::Object(map) = value { + let placeholder_pos = serde_json::json!({ + "index": 0, + "line": 0, + "column": 0 + }); + map.insert("start".to_string(), placeholder_pos.clone()); + map.insert("end".to_string(), placeholder_pos); + } +} + +/// Convert a Program to a normalized JSON value with locations redacted +#[cfg(feature = "winnow-parser")] +#[allow(clippy::expect_used)] +fn normalize_ast(program: &Program) -> Value { + let mut value = serde_json::to_value(program).expect("Failed to serialize Program to JSON"); + redact_locations(&mut value); + value +} + +/// A named parser configuration for test output clarity +#[derive(Debug, Clone)] +pub struct ParserConfig { + pub name: &'static str, + pub parser_impl: ParserImpl, +} + +/// Returns all available parser implementations for testing. +/// - Without `winnow-parser`: returns only Peg +/// - With `winnow-parser`: returns both Peg and Winnow +pub fn parser_configs() -> Vec { + #[allow(unused_mut)] + let mut configs = vec![ParserConfig { + name: "peg", + parser_impl: ParserImpl::Peg, + }]; + + #[cfg(feature = "winnow-parser")] + configs.push(ParserConfig { + name: "winnow", + parser_impl: ParserImpl::Winnow, + }); + + configs +} + +/// Helper to parse input with a specific parser configuration +pub fn parse_with_config(input: &str, config: &ParserConfig) -> Result { + let options = ParserOptions { + parser_impl: config.parser_impl, + ..Default::default() + }; + + let mut parser = Parser::new(std::io::Cursor::new(input), &options); + parser.parse_program() +} + +/// Verify all parser implementations produce the same result. +/// +/// This function parses the input with each available parser and verifies +/// they all produce structurally equivalent ASTs. Returns an error if +/// parsing fails or if the results differ. +#[cfg(feature = "winnow-parser")] +#[allow(dead_code)] +pub fn test_all_parsers_match(input: &str) -> Result<()> { + let configs = parser_configs(); + + // Parse with each configuration + let mut results: Vec<(&str, Program)> = Vec::new(); + + for config in &configs { + let result = parse_with_config(input, config).map_err(|e| { + anyhow::anyhow!( + "Parser '{}' failed to parse input: {}\nInput: {}", + config.name, + e, + input + ) + })?; + results.push((config.name, result)); + } + + // Compare all results against the first (peg) implementation + // Normalize ASTs by redacting location info before comparison + if results.len() > 1 { + let (base_name, base_result) = &results[0]; + let base_normalized = normalize_ast(base_result); + for (name, result) in results.iter().skip(1) { + let result_normalized = normalize_ast(result); + pretty_assertions::assert_eq!( + base_normalized, + result_normalized, + "Parser outputs differ between '{}' and '{}'.\nInput: {}", + base_name, + name, + input + ); + } + } + + Ok(()) +} + +/// Run a test and create snapshot for peg parser (canonical implementation). +/// +/// This function parses the input with the peg parser and returns the result +/// for snapshot testing. When the winnow-parser feature is enabled, it also +/// verifies that winnow produces the same result. +pub fn test_with_snapshot(input: &str) -> Result { + // Always parse with peg (canonical implementation) + let peg_config = ParserConfig { + name: "peg", + parser_impl: ParserImpl::Peg, + }; + let peg_result = parse_with_config(input, &peg_config) + .map_err(|e| anyhow::anyhow!("Peg parser failed: {e}\nInput: {input}"))?; + + // When winnow is enabled, verify it matches (ignoring location differences) + #[cfg(feature = "winnow-parser")] + { + let winnow_config = ParserConfig { + name: "winnow", + parser_impl: ParserImpl::Winnow, + }; + let winnow_result = parse_with_config(input, &winnow_config) + .map_err(|e| anyhow::anyhow!("Winnow parser failed: {e}\nInput: {input}"))?; + + // Normalize both ASTs by redacting location info before comparison + let peg_normalized = normalize_ast(&peg_result); + let winnow_normalized = normalize_ast(&winnow_result); + + pretty_assertions::assert_eq!( + peg_normalized, + winnow_normalized, + "Parser outputs differ between 'peg' and 'winnow'.\nInput: {}", + input + ); + } + + Ok(peg_result) +} + +#[cfg(test)] +mod harness_tests { + use super::*; + + #[test] + fn test_parser_configs_includes_peg() { + let configs = parser_configs(); + assert!(!configs.is_empty()); + assert_eq!(configs[0].name, "peg"); + } + + #[test] + #[cfg(feature = "winnow-parser")] + fn test_parser_configs_includes_winnow() { + let configs = parser_configs(); + assert!(configs.len() >= 2); + assert!(configs.iter().any(|c| c.name == "winnow")); + } + + #[test] + fn test_parse_with_config_basic() { + let config = ParserConfig { + name: "peg", + parser_impl: ParserImpl::Peg, + }; + let result = parse_with_config("echo hello", &config); + assert!(result.is_ok()); + } +} diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/pipelines.rs b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/pipelines.rs new file mode 100644 index 0000000000..e87db1fd2c --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/pipelines.rs @@ -0,0 +1,93 @@ +//! Tests for pipeline parsing. + +use super::{ParseResult, test_with_snapshot}; +use crate::assert_snapshot_redacted; +use anyhow::Result; + +#[test] +fn parse_simple_pipe() -> Result<()> { + let input = "echo hello | grep world"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_multi_stage_pipe() -> Result<()> { + let input = "cat file | grep pattern | wc -l"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_pipe_with_stderr() -> Result<()> { + let input = "echo |& wc"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_timed_pipeline() -> Result<()> { + let input = "time echo hello"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_timed_pipeline_posix() -> Result<()> { + let input = "time -p echo hello"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_negated_pipeline() -> Result<()> { + let input = "! echo hello"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_negated_timed_pipeline() -> Result<()> { + let input = "time ! echo hello"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_pipe_with_multiple_commands() -> Result<()> { + let input = "ls -la | head -10 | tail -5"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/redirections.rs b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/redirections.rs new file mode 100644 index 0000000000..91f57401d2 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/redirections.rs @@ -0,0 +1,191 @@ +//! Tests for redirection parsing. + +use super::{ParseResult, test_with_snapshot}; +use crate::assert_snapshot_redacted; +use anyhow::Result; + +// File redirections + +#[test] +fn parse_redirect_output() -> Result<()> { + let input = "echo hello > file.txt"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_redirect_input() -> Result<()> { + let input = "cat < input.txt"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_redirect_append() -> Result<()> { + let input = "echo hello >> file.txt"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_redirect_clobber() -> Result<()> { + let input = "echo hello >| file.txt"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_redirect_read_write() -> Result<()> { + let input = "cat <> file.txt"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +// FD operations + +#[test] +fn parse_redirect_stderr_to_stdout() -> Result<()> { + let input = "command 2>&1"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_redirect_stdout_to_stderr() -> Result<()> { + let input = "command 1>&2"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_redirect_fd_close() -> Result<()> { + let input = "command 2>&-"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_redirect_stdin_dup() -> Result<()> { + let input = "command <&3"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +// Combined redirections + +#[test] +fn parse_redirect_output_and_error() -> Result<()> { + let input = "command &> file.txt"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_redirect_output_and_error_append() -> Result<()> { + let input = "command &>> file.txt"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_redirect_multiple() -> Result<()> { + let input = "command < input.txt > output.txt 2>&1"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +// Process substitution + +#[test] +fn parse_process_substitution_read() -> Result<()> { + let input = "diff <(sort file1) <(sort file2)"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_process_substitution_write() -> Result<()> { + let input = "tee >(grep error > errors.txt)"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +// Here string + +#[test] +fn parse_here_string() -> Result<()> { + let input = "cat <<< 'hello world'"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_here_string_with_variable() -> Result<()> { + let input = "cat <<< $variable"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/simple_commands.rs b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/simple_commands.rs new file mode 100644 index 0000000000..caeb494617 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/simple_commands.rs @@ -0,0 +1,137 @@ +//! Tests for simple command parsing. + +use super::{ParseResult, test_with_snapshot}; +use crate::assert_snapshot_redacted; +use anyhow::Result; + +#[test] +fn parse_echo_hello() -> Result<()> { + let input = "echo hello"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_ls() -> Result<()> { + let input = "ls"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_colon() -> Result<()> { + let input = ":"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_true() -> Result<()> { + let input = "true"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_echo_hello_world() -> Result<()> { + let input = "echo hello world"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_ls_la_home() -> Result<()> { + let input = "ls -la /home"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_command_with_quoted_args() -> Result<()> { + let input = r#"echo "hello world""#; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_command_with_single_quotes() -> Result<()> { + let input = "echo 'hello world'"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_command_with_backslash_escape() -> Result<()> { + let input = r"echo hello\ world"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_command_with_variable() -> Result<()> { + let input = "echo $HOME"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_command_with_command_substitution() -> Result<()> { + let input = "echo $(whoami)"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} + +#[test] +fn parse_command_with_backtick_substitution() -> Result<()> { + let input = "echo `whoami`"; + let result = test_with_snapshot(input)?; + assert_snapshot_redacted!(ParseResult { + input, + result: &result + }); + Ok(()) +} diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__and_or_lists__parse_and_or_in_sequence.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__and_or_lists__parse_and_or_in_sequence.snap new file mode 100644 index 0000000000..7bfa09ab77 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__and_or_lists__parse_and_or_in_sequence.snap @@ -0,0 +1,61 @@ +--- +source: brush-parser/src/parser/tests/and_or_lists.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "cmd1 && cmd2; cmd3 || cmd4", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cmd1", + loc: "[location]", + )), + )), + ], + ), + additional: [ + And(Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cmd2", + loc: "[location]", + )), + )), + ], + )), + ], + ), Sequence), + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cmd3", + loc: "[location]", + )), + )), + ], + ), + additional: [ + Or(Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cmd4", + loc: "[location]", + )), + )), + ], + )), + ], + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__and_or_lists__parse_and_or_with_pipes.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__and_or_lists__parse_and_or_with_pipes.snap new file mode 100644 index 0000000000..97c601c9b7 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__and_or_lists__parse_and_or_with_pipes.snap @@ -0,0 +1,49 @@ +--- +source: brush-parser/src/parser/tests/and_or_lists.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "cmd1 | cmd2 && cmd3 | cmd4", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cmd1", + loc: "[location]", + )), + )), + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cmd2", + loc: "[location]", + )), + )), + ], + ), + additional: [ + And(Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cmd3", + loc: "[location]", + )), + )), + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cmd4", + loc: "[location]", + )), + )), + ], + )), + ], + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__and_or_lists__parse_chained_and.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__and_or_lists__parse_chained_and.snap new file mode 100644 index 0000000000..c3890b1c21 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__and_or_lists__parse_chained_and.snap @@ -0,0 +1,47 @@ +--- +source: brush-parser/src/parser/tests/and_or_lists.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "cmd1 && cmd2 && cmd3", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cmd1", + loc: "[location]", + )), + )), + ], + ), + additional: [ + And(Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cmd2", + loc: "[location]", + )), + )), + ], + )), + And(Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cmd3", + loc: "[location]", + )), + )), + ], + )), + ], + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__and_or_lists__parse_chained_or.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__and_or_lists__parse_chained_or.snap new file mode 100644 index 0000000000..44ae9672a4 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__and_or_lists__parse_chained_or.snap @@ -0,0 +1,47 @@ +--- +source: brush-parser/src/parser/tests/and_or_lists.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "cmd1 || cmd2 || cmd3", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cmd1", + loc: "[location]", + )), + )), + ], + ), + additional: [ + Or(Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cmd2", + loc: "[location]", + )), + )), + ], + )), + Or(Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cmd3", + loc: "[location]", + )), + )), + ], + )), + ], + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__and_or_lists__parse_mixed_and_or.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__and_or_lists__parse_mixed_and_or.snap new file mode 100644 index 0000000000..ed4abe2402 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__and_or_lists__parse_mixed_and_or.snap @@ -0,0 +1,47 @@ +--- +source: brush-parser/src/parser/tests/and_or_lists.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "cmd1 && cmd2 || cmd3", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cmd1", + loc: "[location]", + )), + )), + ], + ), + additional: [ + And(Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cmd2", + loc: "[location]", + )), + )), + ], + )), + Or(Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cmd3", + loc: "[location]", + )), + )), + ], + )), + ], + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__and_or_lists__parse_simple_and.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__and_or_lists__parse_simple_and.snap new file mode 100644 index 0000000000..26650e55cc --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__and_or_lists__parse_simple_and.snap @@ -0,0 +1,43 @@ +--- +source: brush-parser/src/parser/tests/and_or_lists.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "true && echo yes", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "true", + loc: "[location]", + )), + )), + ], + ), + additional: [ + And(Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "yes", + loc: "[location]", + )), + ])), + )), + ], + )), + ], + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__and_or_lists__parse_simple_or.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__and_or_lists__parse_simple_or.snap new file mode 100644 index 0000000000..1dcdfbd939 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__and_or_lists__parse_simple_or.snap @@ -0,0 +1,43 @@ +--- +source: brush-parser/src/parser/tests/and_or_lists.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "false || echo no", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "false", + loc: "[location]", + )), + )), + ], + ), + additional: [ + Or(Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "no", + loc: "[location]", + )), + ])), + )), + ], + )), + ], + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_append.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_append.snap new file mode 100644 index 0000000000..78cd3684b7 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_append.snap @@ -0,0 +1,35 @@ +--- +source: brush-parser/src/parser/tests/assignments.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "x+=more", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + prefix: Some(CommandPrefix([ + AssignmentWord(Assignment( + name: VariableName("x"), + value: Scalar(Word( + value: "more", + loc: "[location]", + )), + append: true, + loc: "[location]", + ), Word( + value: "x+=more", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_append_quoted.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_append_quoted.snap new file mode 100644 index 0000000000..8b89ae7a75 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_append_quoted.snap @@ -0,0 +1,35 @@ +--- +source: brush-parser/src/parser/tests/assignments.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "x+=\" more text\"", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + prefix: Some(CommandPrefix([ + AssignmentWord(Assignment( + name: VariableName("x"), + value: Scalar(Word( + value: "\" more text\"", + loc: "[location]", + )), + append: true, + loc: "[location]", + ), Word( + value: "x+=\" more text\"", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_array.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_array.snap new file mode 100644 index 0000000000..cf044112b9 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_array.snap @@ -0,0 +1,44 @@ +--- +source: brush-parser/src/parser/tests/assignments.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "arr=(a b c)", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + prefix: Some(CommandPrefix([ + AssignmentWord(Assignment( + name: VariableName("arr"), + value: Array([ + (None, Word( + value: "a", + loc: "[location]", + )), + (None, Word( + value: "b", + loc: "[location]", + )), + (None, Word( + value: "c", + loc: "[location]", + )), + ]), + loc: "[location]", + ), Word( + value: "arr=(a b c)", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_array_element.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_array_element.snap new file mode 100644 index 0000000000..7cef324fed --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_array_element.snap @@ -0,0 +1,34 @@ +--- +source: brush-parser/src/parser/tests/assignments.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "arr[0]=value", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + prefix: Some(CommandPrefix([ + AssignmentWord(Assignment( + name: ArrayElementName("arr", "0"), + value: Scalar(Word( + value: "value", + loc: "[location]", + )), + loc: "[location]", + ), Word( + value: "arr[0]=value", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_array_element_expression.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_array_element_expression.snap new file mode 100644 index 0000000000..8e4777a68b --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_array_element_expression.snap @@ -0,0 +1,34 @@ +--- +source: brush-parser/src/parser/tests/assignments.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "arr[i+1]=value", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + prefix: Some(CommandPrefix([ + AssignmentWord(Assignment( + name: ArrayElementName("arr", "i+1"), + value: Scalar(Word( + value: "value", + loc: "[location]", + )), + loc: "[location]", + ), Word( + value: "arr[i+1]=value", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_array_empty.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_array_empty.snap new file mode 100644 index 0000000000..037b554135 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_array_empty.snap @@ -0,0 +1,31 @@ +--- +source: brush-parser/src/parser/tests/assignments.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "arr=()", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + prefix: Some(CommandPrefix([ + AssignmentWord(Assignment( + name: VariableName("arr"), + value: Array([]), + loc: "[location]", + ), Word( + value: "arr=()", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_array_mixed.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_array_mixed.snap new file mode 100644 index 0000000000..0237f8b8a3 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_array_mixed.snap @@ -0,0 +1,47 @@ +--- +source: brush-parser/src/parser/tests/assignments.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "arr=(a [5]=b c)", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + prefix: Some(CommandPrefix([ + AssignmentWord(Assignment( + name: VariableName("arr"), + value: Array([ + (None, Word( + value: "a", + loc: "[location]", + )), + (Some(Word( + value: "5", + loc: "[location]", + )), Word( + value: "b", + loc: "[location]", + )), + (None, Word( + value: "c", + loc: "[location]", + )), + ]), + loc: "[location]", + ), Word( + value: "arr=(a [5]=b c)", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_array_quoted_elements.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_array_quoted_elements.snap new file mode 100644 index 0000000000..555578a665 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_array_quoted_elements.snap @@ -0,0 +1,40 @@ +--- +source: brush-parser/src/parser/tests/assignments.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "arr=(\"hello world\" \"foo bar\")", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + prefix: Some(CommandPrefix([ + AssignmentWord(Assignment( + name: VariableName("arr"), + value: Array([ + (None, Word( + value: "\"hello world\"", + loc: "[location]", + )), + (None, Word( + value: "\"foo bar\"", + loc: "[location]", + )), + ]), + loc: "[location]", + ), Word( + value: "arr=(\"hello world\" \"foo bar\")", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_array_with_indices.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_array_with_indices.snap new file mode 100644 index 0000000000..54b5c2d55f --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_array_with_indices.snap @@ -0,0 +1,53 @@ +--- +source: brush-parser/src/parser/tests/assignments.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "arr=([0]=a [1]=b [2]=c)", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + prefix: Some(CommandPrefix([ + AssignmentWord(Assignment( + name: VariableName("arr"), + value: Array([ + (Some(Word( + value: "0", + loc: "[location]", + )), Word( + value: "a", + loc: "[location]", + )), + (Some(Word( + value: "1", + loc: "[location]", + )), Word( + value: "b", + loc: "[location]", + )), + (Some(Word( + value: "2", + loc: "[location]", + )), Word( + value: "c", + loc: "[location]", + )), + ]), + loc: "[location]", + ), Word( + value: "arr=([0]=a [1]=b [2]=c)", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_empty.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_empty.snap new file mode 100644 index 0000000000..4449a8d32e --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_empty.snap @@ -0,0 +1,34 @@ +--- +source: brush-parser/src/parser/tests/assignments.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "x=", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + prefix: Some(CommandPrefix([ + AssignmentWord(Assignment( + name: VariableName("x"), + value: Scalar(Word( + value: "", + loc: "[location]", + )), + loc: "[location]", + ), Word( + value: "x=", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_quoted.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_quoted.snap new file mode 100644 index 0000000000..53f51d3511 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_quoted.snap @@ -0,0 +1,34 @@ +--- +source: brush-parser/src/parser/tests/assignments.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "x=\"hello world\"", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + prefix: Some(CommandPrefix([ + AssignmentWord(Assignment( + name: VariableName("x"), + value: Scalar(Word( + value: "\"hello world\"", + loc: "[location]", + )), + loc: "[location]", + ), Word( + value: "x=\"hello world\"", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_simple.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_simple.snap new file mode 100644 index 0000000000..ca21727899 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_simple.snap @@ -0,0 +1,34 @@ +--- +source: brush-parser/src/parser/tests/assignments.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "x=value", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + prefix: Some(CommandPrefix([ + AssignmentWord(Assignment( + name: VariableName("x"), + value: Scalar(Word( + value: "value", + loc: "[location]", + )), + loc: "[location]", + ), Word( + value: "x=value", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_single_quoted.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_single_quoted.snap new file mode 100644 index 0000000000..92c811caed --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_single_quoted.snap @@ -0,0 +1,34 @@ +--- +source: brush-parser/src/parser/tests/assignments.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "x=\'hello world\'", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + prefix: Some(CommandPrefix([ + AssignmentWord(Assignment( + name: VariableName("x"), + value: Scalar(Word( + value: "\'hello world\'", + loc: "[location]", + )), + loc: "[location]", + ), Word( + value: "x=\'hello world\'", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_with_command.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_with_command.snap new file mode 100644 index 0000000000..645129d84b --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_with_command.snap @@ -0,0 +1,48 @@ +--- +source: brush-parser/src/parser/tests/assignments.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "VAR=value command arg1 arg2", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + prefix: Some(CommandPrefix([ + AssignmentWord(Assignment( + name: VariableName("VAR"), + value: Scalar(Word( + value: "value", + loc: "[location]", + )), + loc: "[location]", + ), Word( + value: "VAR=value", + loc: "[location]", + )), + ])), + word_or_name: Some(Word( + value: "command", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "arg1", + loc: "[location]", + )), + Word(Word( + value: "arg2", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_with_command_substitution.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_with_command_substitution.snap new file mode 100644 index 0000000000..23bc3246b6 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_with_command_substitution.snap @@ -0,0 +1,34 @@ +--- +source: brush-parser/src/parser/tests/assignments.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "x=$(pwd)", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + prefix: Some(CommandPrefix([ + AssignmentWord(Assignment( + name: VariableName("x"), + value: Scalar(Word( + value: "$(pwd)", + loc: "[location]", + )), + loc: "[location]", + ), Word( + value: "x=$(pwd)", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_with_expansion.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_with_expansion.snap new file mode 100644 index 0000000000..e89f102f35 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_assignment_with_expansion.snap @@ -0,0 +1,34 @@ +--- +source: brush-parser/src/parser/tests/assignments.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "x=$HOME/bin", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + prefix: Some(CommandPrefix([ + AssignmentWord(Assignment( + name: VariableName("x"), + value: Scalar(Word( + value: "$HOME/bin", + loc: "[location]", + )), + loc: "[location]", + ), Word( + value: "x=$HOME/bin", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_declare_assignment.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_declare_assignment.snap new file mode 100644 index 0000000000..830f15468d --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_declare_assignment.snap @@ -0,0 +1,42 @@ +--- +source: brush-parser/src/parser/tests/assignments.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "declare -i x=5", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "declare", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "-i", + loc: "[location]", + )), + AssignmentWord(Assignment( + name: VariableName("x"), + value: Scalar(Word( + value: "5", + loc: "[location]", + )), + loc: "[location]", + ), Word( + value: "x=5", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_export_assignment.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_export_assignment.snap new file mode 100644 index 0000000000..bfc4ba564b --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_export_assignment.snap @@ -0,0 +1,38 @@ +--- +source: brush-parser/src/parser/tests/assignments.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "export VAR=value", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "export", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + AssignmentWord(Assignment( + name: VariableName("VAR"), + value: Scalar(Word( + value: "value", + loc: "[location]", + )), + loc: "[location]", + ), Word( + value: "VAR=value", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_local_assignment.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_local_assignment.snap new file mode 100644 index 0000000000..02bd0dd1ad --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_local_assignment.snap @@ -0,0 +1,38 @@ +--- +source: brush-parser/src/parser/tests/assignments.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "local x=5", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "local", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + AssignmentWord(Assignment( + name: VariableName("x"), + value: Scalar(Word( + value: "5", + loc: "[location]", + )), + loc: "[location]", + ), Word( + value: "x=5", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_multiple_assignments.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_multiple_assignments.snap new file mode 100644 index 0000000000..5890d61b7b --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_multiple_assignments.snap @@ -0,0 +1,56 @@ +--- +source: brush-parser/src/parser/tests/assignments.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "x=1 y=2 z=3", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + prefix: Some(CommandPrefix([ + AssignmentWord(Assignment( + name: VariableName("x"), + value: Scalar(Word( + value: "1", + loc: "[location]", + )), + loc: "[location]", + ), Word( + value: "x=1", + loc: "[location]", + )), + AssignmentWord(Assignment( + name: VariableName("y"), + value: Scalar(Word( + value: "2", + loc: "[location]", + )), + loc: "[location]", + ), Word( + value: "y=2", + loc: "[location]", + )), + AssignmentWord(Assignment( + name: VariableName("z"), + value: Scalar(Word( + value: "3", + loc: "[location]", + )), + loc: "[location]", + ), Word( + value: "z=3", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_multiple_assignments_with_command.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_multiple_assignments_with_command.snap new file mode 100644 index 0000000000..b3c1ed7480 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__assignments__parse_multiple_assignments_with_command.snap @@ -0,0 +1,49 @@ +--- +source: brush-parser/src/parser/tests/assignments.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "A=1 B=2 command", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + prefix: Some(CommandPrefix([ + AssignmentWord(Assignment( + name: VariableName("A"), + value: Scalar(Word( + value: "1", + loc: "[location]", + )), + loc: "[location]", + ), Word( + value: "A=1", + loc: "[location]", + )), + AssignmentWord(Assignment( + name: VariableName("B"), + value: Scalar(Word( + value: "2", + loc: "[location]", + )), + loc: "[location]", + ), Word( + value: "B=2", + loc: "[location]", + )), + ])), + word_or_name: Some(Word( + value: "command", + loc: "[location]", + )), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_arithmetic_in_condition.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_arithmetic_in_condition.snap new file mode 100644 index 0000000000..d305e579c8 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_arithmetic_in_condition.snap @@ -0,0 +1,80 @@ +--- +source: brush-parser/src/parser/tests/complex.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "if (( x > 5 )); then echo big; else echo small; fi", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(IfClause(IfClauseCommand( + condition: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(Arithmetic(ArithmeticCommand( + expr: UnexpandedArithmeticExpr( + value: "x > 5", + ), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + then: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "big", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + elses: Some([ + ElseClause( + body: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "small", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ), + ]), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_backgrounded_commands.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_backgrounded_commands.snap new file mode 100644 index 0000000000..4ea1e11758 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_backgrounded_commands.snap @@ -0,0 +1,49 @@ +--- +source: brush-parser/src/parser/tests/complex.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "cmd1 & cmd2 & cmd3", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cmd1", + loc: "[location]", + )), + )), + ], + ), + ), Async), + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cmd2", + loc: "[location]", + )), + )), + ], + ), + ), Async), + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cmd3", + loc: "[location]", + )), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_brace_expansion_context.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_brace_expansion_context.snap new file mode 100644 index 0000000000..1da85a7fe4 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_brace_expansion_context.snap @@ -0,0 +1,31 @@ +--- +source: brush-parser/src/parser/tests/complex.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "echo {a,b,c}", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "{a,b,c}", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_case_no_semicolon.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_case_no_semicolon.snap new file mode 100644 index 0000000000..0a40856c13 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_case_no_semicolon.snap @@ -0,0 +1,58 @@ +--- +source: brush-parser/src/parser/tests/complex.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "case x in\nx)\n echo y\nesac", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(CaseClause(CaseClauseCommand( + value: Word( + value: "x", + loc: "[location]", + ), + cases: [ + CaseItem( + patterns: [ + Word( + value: "x", + loc: "[location]", + ), + ], + cmd: Some(CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "y", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ])), + post_action: ExitCase, + loc: "[location]", + ), + ], + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_case_with_newlines.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_case_with_newlines.snap new file mode 100644 index 0000000000..c2326d0300 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_case_with_newlines.snap @@ -0,0 +1,58 @@ +--- +source: brush-parser/src/parser/tests/complex.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "case x in\nx)\n echo y;;\nesac", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(CaseClause(CaseClauseCommand( + value: Word( + value: "x", + loc: "[location]", + ), + cases: [ + CaseItem( + patterns: [ + Word( + value: "x", + loc: "[location]", + ), + ], + cmd: Some(CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "y", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ])), + post_action: ExitCase, + loc: "[location]", + ), + ], + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_command_substitution_nested.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_command_substitution_nested.snap new file mode 100644 index 0000000000..e5c7e4720a --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_command_substitution_nested.snap @@ -0,0 +1,31 @@ +--- +source: brush-parser/src/parser/tests/complex.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "echo \"$(echo $(pwd))\"", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "\"$(echo $(pwd))\"", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_complex_array_operations.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_complex_array_operations.snap new file mode 100644 index 0000000000..12825dbffa --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_complex_array_operations.snap @@ -0,0 +1,72 @@ +--- +source: brush-parser/src/parser/tests/complex.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "arr=($(seq 1 10)); echo \"${arr[@]}\"; echo \"${#arr[@]}\"", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + prefix: Some(CommandPrefix([ + AssignmentWord(Assignment( + name: VariableName("arr"), + value: Array([ + (None, Word( + value: "$(seq 1 10)", + loc: "[location]", + )), + ]), + loc: "[location]", + ), Word( + value: "arr=($(seq 1 10))", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "\"${arr[@]}\"", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "\"${#arr[@]}\"", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_coprocess.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_coprocess.snap new file mode 100644 index 0000000000..b1f242cfcb --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_coprocess.snap @@ -0,0 +1,27 @@ +--- +source: brush-parser/src/parser/tests/complex.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "coproc cat", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(Coprocess(CoprocessCommand( + body: Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cat", + loc: "[location]", + )), + )), + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_extglob_patterns.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_extglob_patterns.snap new file mode 100644 index 0000000000..df53a89ded --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_extglob_patterns.snap @@ -0,0 +1,39 @@ +--- +source: brush-parser/src/parser/tests/complex.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "ls !(*.txt) +(foo|bar) ?(a|b)", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "ls", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "!(*.txt)", + loc: "[location]", + )), + Word(Word( + value: "+(foo|bar)", + loc: "[location]", + )), + Word(Word( + value: "?(a|b)", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_for_with_command_substitution.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_for_with_command_substitution.snap new file mode 100644 index 0000000000..84782e04fb --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_for_with_command_substitution.snap @@ -0,0 +1,52 @@ +--- +source: brush-parser/src/parser/tests/complex.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "for f in $(ls *.txt); do cat \"$f\"; done", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(ForClause(ForClauseCommand( + variable_name: "f", + values: Some([ + Word( + value: "$(ls *.txt)", + loc: "[location]", + ), + ]), + body: DoGroupCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cat", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "\"$f\"", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + ), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_function_with_if.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_function_with_if.snap new file mode 100644 index 0000000000..f7c715ff33 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_function_with_if.snap @@ -0,0 +1,110 @@ +--- +source: brush-parser/src/parser/tests/complex.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "myfunc() {\n if [[ -z \"$1\" ]]; then\n echo \"No argument\"\n return 1\n fi\n echo \"Got: $1\"\n}", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Function(FunctionDefinition( + fname: Word( + value: "myfunc", + loc: "[location]", + ), + body: FunctionBody(BraceGroup(BraceGroupCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(IfClause(IfClauseCommand( + condition: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + ExtendedTest(ExtendedTestExprCommand( + expr: UnaryTest(StringHasZeroLength, Word( + value: "\"$1\"", + loc: "[location]", + )), + loc: "[location]", + ), None), + ], + ), + ), Sequence), + ]), + then: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "\"No argument\"", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "return", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "1", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "\"Got: $1\"", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + )), None), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_glob_patterns.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_glob_patterns.snap new file mode 100644 index 0000000000..b78eb0fdbe --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_glob_patterns.snap @@ -0,0 +1,39 @@ +--- +source: brush-parser/src/parser/tests/complex.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "ls *.txt **/foo.* file?.txt", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "ls", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "*.txt", + loc: "[location]", + )), + Word(Word( + value: "**/foo.*", + loc: "[location]", + )), + Word(Word( + value: "file?.txt", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_mixed_sequences.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_mixed_sequences.snap new file mode 100644 index 0000000000..472be4b282 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_mixed_sequences.snap @@ -0,0 +1,71 @@ +--- +source: brush-parser/src/parser/tests/complex.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "cmd1; cmd2 && cmd3 || cmd4; cmd5", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cmd1", + loc: "[location]", + )), + )), + ], + ), + ), Sequence), + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cmd2", + loc: "[location]", + )), + )), + ], + ), + additional: [ + And(Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cmd3", + loc: "[location]", + )), + )), + ], + )), + Or(Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cmd4", + loc: "[location]", + )), + )), + ], + )), + ], + ), Sequence), + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cmd5", + loc: "[location]", + )), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_multiple_here_docs.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_multiple_here_docs.snap new file mode 100644 index 0000000000..6f9a45a79b --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_multiple_here_docs.snap @@ -0,0 +1,49 @@ +--- +source: brush-parser/src/parser/tests/complex.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "cmd < /dev/null 2>&1", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cat", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + IoRedirect(File(None, Read, Filename(Word( + value: "input.txt", + loc: "[location]", + )))), + ])), + )), + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "grep", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "pattern", + loc: "[location]", + )), + ])), + )), + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "tee", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "output.txt", + loc: "[location]", + )), + IoRedirect(File(None, Write, Filename(Word( + value: "/dev/null", + loc: "[location]", + )))), + IoRedirect(File(Some(2), DuplicateOutput, Duplicate(Word( + value: "1", + loc: "[location]", + )))), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_shebang_and_program.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_shebang_and_program.snap new file mode 100644 index 0000000000..f9cc814dbf --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_shebang_and_program.snap @@ -0,0 +1,64 @@ +--- +source: brush-parser/src/parser/tests/complex.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "#!/usr/bin/env bash\n\nfor f in A B C; do\n\n # sdfsdf\n echo \"${f@L}\" >&2\n\n done\n", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(ForClause(ForClauseCommand( + variable_name: "f", + values: Some([ + Word( + value: "A", + loc: "[location]", + ), + Word( + value: "B", + loc: "[location]", + ), + Word( + value: "C", + loc: "[location]", + ), + ]), + body: DoGroupCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "\"${f@L}\"", + loc: "[location]", + )), + IoRedirect(File(None, DuplicateOutput, Duplicate(Word( + value: "2", + loc: "[location]", + )))), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + ), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_subshell_with_assignments.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_subshell_with_assignments.snap new file mode 100644 index 0000000000..a2a662ef81 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_subshell_with_assignments.snap @@ -0,0 +1,84 @@ +--- +source: brush-parser/src/parser/tests/complex.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "( x=1; y=2; echo $((x + y)) )", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(Subshell(SubshellCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + prefix: Some(CommandPrefix([ + AssignmentWord(Assignment( + name: VariableName("x"), + value: Scalar(Word( + value: "1", + loc: "[location]", + )), + loc: "[location]", + ), Word( + value: "x=1", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + prefix: Some(CommandPrefix([ + AssignmentWord(Assignment( + name: VariableName("y"), + value: Scalar(Word( + value: "2", + loc: "[location]", + )), + loc: "[location]", + ), Word( + value: "y=2", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "$((x + y))", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_tilde_expansion.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_tilde_expansion.snap new file mode 100644 index 0000000000..a8dc8eab4a --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__complex__parse_tilde_expansion.snap @@ -0,0 +1,49 @@ +--- +source: brush-parser/src/parser/tests/complex.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "cd ~/projects; ls ~user/home", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cd", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "~/projects", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "ls", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "~user/home", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_arith_and_non_arith_parens.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_arith_and_non_arith_parens.snap new file mode 100644 index 0000000000..f1c7bd5650 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_arith_and_non_arith_parens.snap @@ -0,0 +1,71 @@ +--- +source: brush-parser/src/parser/tests/compound_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "( : && ( (( 0 )) || : ) )", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(Subshell(SubshellCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: ":", + loc: "[location]", + )), + )), + ], + ), + additional: [ + And(Pipeline( + seq: [ + Compound(Subshell(SubshellCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(Arithmetic(ArithmeticCommand( + expr: UnexpandedArithmeticExpr( + value: "0", + ), + loc: "[location]", + )), None), + ], + ), + additional: [ + Or(Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: ":", + loc: "[location]", + )), + )), + ], + )), + ], + ), Sequence), + ]), + loc: "[location]", + )), None), + ], + )), + ], + ), Sequence), + ]), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_arithmetic_complex.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_arithmetic_complex.snap new file mode 100644 index 0000000000..36edfcaad9 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_arithmetic_complex.snap @@ -0,0 +1,25 @@ +--- +source: brush-parser/src/parser/tests/compound_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "(( x = 5 + 3 * 2 ))", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(Arithmetic(ArithmeticCommand( + expr: UnexpandedArithmeticExpr( + value: "x = 5 + 3 * 2", + ), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_arithmetic_for.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_arithmetic_for.snap new file mode 100644 index 0000000000..1a1ac1422a --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_arithmetic_for.snap @@ -0,0 +1,54 @@ +--- +source: brush-parser/src/parser/tests/compound_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "for (( i = 0; i < 10; i++ )); do echo $i; done", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(ArithmeticForClause(ArithmeticForClauseCommand( + initializer: Some(UnexpandedArithmeticExpr( + value: "i = 0", + )), + condition: Some(UnexpandedArithmeticExpr( + value: "i < 10", + )), + updater: Some(UnexpandedArithmeticExpr( + value: "i++", + )), + body: DoGroupCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "$i", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + ), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_arithmetic_for_empty_parts.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_arithmetic_for_empty_parts.snap new file mode 100644 index 0000000000..a6b38cae75 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_arithmetic_for_empty_parts.snap @@ -0,0 +1,54 @@ +--- +source: brush-parser/src/parser/tests/compound_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "for (( ; ; )); do echo loop; done", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(ArithmeticForClause(ArithmeticForClauseCommand( + initializer: Some(UnexpandedArithmeticExpr( + value: "", + )), + condition: Some(UnexpandedArithmeticExpr( + value: "", + )), + updater: Some(UnexpandedArithmeticExpr( + value: "", + )), + body: DoGroupCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "loop", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + ), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_arithmetic_increment.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_arithmetic_increment.snap new file mode 100644 index 0000000000..e8a049654d --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_arithmetic_increment.snap @@ -0,0 +1,25 @@ +--- +source: brush-parser/src/parser/tests/compound_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "(( x++ ))", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(Arithmetic(ArithmeticCommand( + expr: UnexpandedArithmeticExpr( + value: "x++", + ), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_arithmetic_simple.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_arithmetic_simple.snap new file mode 100644 index 0000000000..2daebf1272 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_arithmetic_simple.snap @@ -0,0 +1,25 @@ +--- +source: brush-parser/src/parser/tests/compound_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "(( 1 + 2 ))", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(Arithmetic(ArithmeticCommand( + expr: UnexpandedArithmeticExpr( + value: "1 + 2", + ), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_brace_group.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_brace_group.snap new file mode 100644 index 0000000000..61b35c1d5b --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_brace_group.snap @@ -0,0 +1,42 @@ +--- +source: brush-parser/src/parser/tests/compound_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "{ echo hello; }", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(BraceGroup(BraceGroupCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "hello", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_brace_group_multiline.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_brace_group_multiline.snap new file mode 100644 index 0000000000..1bcd08ff37 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_brace_group_multiline.snap @@ -0,0 +1,60 @@ +--- +source: brush-parser/src/parser/tests/compound_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "{\n echo hello\n echo world\n}", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(BraceGroup(BraceGroupCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "hello", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "world", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_case_continue.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_case_continue.snap new file mode 100644 index 0000000000..9692d49989 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_case_continue.snap @@ -0,0 +1,88 @@ +--- +source: brush-parser/src/parser/tests/compound_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "case x in a) echo a;;& b) echo b;; esac", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(CaseClause(CaseClauseCommand( + value: Word( + value: "x", + loc: "[location]", + ), + cases: [ + CaseItem( + patterns: [ + Word( + value: "a", + loc: "[location]", + ), + ], + cmd: Some(CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "a", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ])), + post_action: ContinueEvaluatingCases, + loc: "[location]", + ), + CaseItem( + patterns: [ + Word( + value: "b", + loc: "[location]", + ), + ], + cmd: Some(CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "b", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ])), + post_action: ExitCase, + loc: "[location]", + ), + ], + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_case_fallthrough.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_case_fallthrough.snap new file mode 100644 index 0000000000..4d783ff45b --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_case_fallthrough.snap @@ -0,0 +1,88 @@ +--- +source: brush-parser/src/parser/tests/compound_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "case x in a) echo a;& b) echo b;; esac", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(CaseClause(CaseClauseCommand( + value: Word( + value: "x", + loc: "[location]", + ), + cases: [ + CaseItem( + patterns: [ + Word( + value: "a", + loc: "[location]", + ), + ], + cmd: Some(CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "a", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ])), + post_action: UnconditionallyExecuteNextCaseItem, + loc: "[location]", + ), + CaseItem( + patterns: [ + Word( + value: "b", + loc: "[location]", + ), + ], + cmd: Some(CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "b", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ])), + post_action: ExitCase, + loc: "[location]", + ), + ], + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_case_multiple_patterns.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_case_multiple_patterns.snap new file mode 100644 index 0000000000..b90c58c700 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_case_multiple_patterns.snap @@ -0,0 +1,122 @@ +--- +source: brush-parser/src/parser/tests/compound_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "case x in\n a|b) echo ab;;\n c) echo c;;\n *) echo default;;\nesac", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(CaseClause(CaseClauseCommand( + value: Word( + value: "x", + loc: "[location]", + ), + cases: [ + CaseItem( + patterns: [ + Word( + value: "a", + loc: "[location]", + ), + Word( + value: "b", + loc: "[location]", + ), + ], + cmd: Some(CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "ab", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ])), + post_action: ExitCase, + loc: "[location]", + ), + CaseItem( + patterns: [ + Word( + value: "c", + loc: "[location]", + ), + ], + cmd: Some(CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "c", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ])), + post_action: ExitCase, + loc: "[location]", + ), + CaseItem( + patterns: [ + Word( + value: "*", + loc: "[location]", + ), + ], + cmd: Some(CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "default", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ])), + post_action: ExitCase, + loc: "[location]", + ), + ], + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_case_simple.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_case_simple.snap new file mode 100644 index 0000000000..2684840a73 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_case_simple.snap @@ -0,0 +1,58 @@ +--- +source: brush-parser/src/parser/tests/compound_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "case x in a) echo a;; esac", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(CaseClause(CaseClauseCommand( + value: Word( + value: "x", + loc: "[location]", + ), + cases: [ + CaseItem( + patterns: [ + Word( + value: "a", + loc: "[location]", + ), + ], + cmd: Some(CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "a", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ])), + post_action: ExitCase, + loc: "[location]", + ), + ], + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_for_in.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_for_in.snap new file mode 100644 index 0000000000..778d9d612a --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_for_in.snap @@ -0,0 +1,60 @@ +--- +source: brush-parser/src/parser/tests/compound_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "for x in a b c; do echo $x; done", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(ForClause(ForClauseCommand( + variable_name: "x", + values: Some([ + Word( + value: "a", + loc: "[location]", + ), + Word( + value: "b", + loc: "[location]", + ), + Word( + value: "c", + loc: "[location]", + ), + ]), + body: DoGroupCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "$x", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + ), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_for_in_multiline.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_for_in_multiline.snap new file mode 100644 index 0000000000..e45166f671 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_for_in_multiline.snap @@ -0,0 +1,60 @@ +--- +source: brush-parser/src/parser/tests/compound_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "for x in a b c\ndo\n echo $x\ndone", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(ForClause(ForClauseCommand( + variable_name: "x", + values: Some([ + Word( + value: "a", + loc: "[location]", + ), + Word( + value: "b", + loc: "[location]", + ), + Word( + value: "c", + loc: "[location]", + ), + ]), + body: DoGroupCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "$x", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + ), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_for_no_in.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_for_no_in.snap new file mode 100644 index 0000000000..8e46717fcc --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_for_no_in.snap @@ -0,0 +1,47 @@ +--- +source: brush-parser/src/parser/tests/compound_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "for x; do echo $x; done", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(ForClause(ForClauseCommand( + variable_name: "x", + values: None, + body: DoGroupCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "$x", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + ), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_if_elif.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_if_elif.snap new file mode 100644 index 0000000000..c4bc077316 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_if_elif.snap @@ -0,0 +1,116 @@ +--- +source: brush-parser/src/parser/tests/compound_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "if false; then echo one; elif true; then echo two; else echo three; fi", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(IfClause(IfClauseCommand( + condition: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "false", + loc: "[location]", + )), + )), + ], + ), + ), Sequence), + ]), + then: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "one", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + elses: Some([ + ElseClause( + condition: Some(CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "true", + loc: "[location]", + )), + )), + ], + ), + ), Sequence), + ])), + body: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "two", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ), + ElseClause( + body: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "three", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ), + ]), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_if_else.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_if_else.snap new file mode 100644 index 0000000000..985e206214 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_if_else.snap @@ -0,0 +1,80 @@ +--- +source: brush-parser/src/parser/tests/compound_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "if true; then echo yes; else echo no; fi", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(IfClause(IfClauseCommand( + condition: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "true", + loc: "[location]", + )), + )), + ], + ), + ), Sequence), + ]), + then: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "yes", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + elses: Some([ + ElseClause( + body: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "no", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ), + ]), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_if_multiline.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_if_multiline.snap new file mode 100644 index 0000000000..7dd0e5bf94 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_if_multiline.snap @@ -0,0 +1,80 @@ +--- +source: brush-parser/src/parser/tests/compound_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "if true\nthen\n echo yes\nelse\n echo no\nfi", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(IfClause(IfClauseCommand( + condition: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "true", + loc: "[location]", + )), + )), + ], + ), + ), Sequence), + ]), + then: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "yes", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + elses: Some([ + ElseClause( + body: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "no", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ), + ]), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_if_simple.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_if_simple.snap new file mode 100644 index 0000000000..34d3f95bd0 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_if_simple.snap @@ -0,0 +1,56 @@ +--- +source: brush-parser/src/parser/tests/compound_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "if true; then echo yes; fi", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(IfClause(IfClauseCommand( + condition: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "true", + loc: "[location]", + )), + )), + ], + ), + ), Sequence), + ]), + then: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "yes", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_nested_subshell.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_nested_subshell.snap new file mode 100644 index 0000000000..ca31725ae7 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_nested_subshell.snap @@ -0,0 +1,25 @@ +--- +source: brush-parser/src/parser/tests/compound_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "( ( echo nested ) )", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(Arithmetic(ArithmeticCommand( + expr: UnexpandedArithmeticExpr( + value: "echo nested", + ), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_subshell.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_subshell.snap new file mode 100644 index 0000000000..3ac2f12cf6 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_subshell.snap @@ -0,0 +1,42 @@ +--- +source: brush-parser/src/parser/tests/compound_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "( echo hello )", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(Subshell(SubshellCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "hello", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_subshell_multiple_commands.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_subshell_multiple_commands.snap new file mode 100644 index 0000000000..676650680b --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_subshell_multiple_commands.snap @@ -0,0 +1,60 @@ +--- +source: brush-parser/src/parser/tests/compound_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "( echo hello; echo world )", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(Subshell(SubshellCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "hello", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "world", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_until.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_until.snap new file mode 100644 index 0000000000..5f3e052852 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_until.snap @@ -0,0 +1,66 @@ +--- +source: brush-parser/src/parser/tests/compound_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "until false; do echo loop; done", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(UntilClause(WhileOrUntilClauseCommand(CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "false", + loc: "[location]", + )), + )), + ], + ), + ), Sequence), + ]), DoGroupCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "loop", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + ), SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 31, + line: 1, + column: 32, + ), + ))), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_while.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_while.snap new file mode 100644 index 0000000000..e3e7b96756 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_while.snap @@ -0,0 +1,66 @@ +--- +source: brush-parser/src/parser/tests/compound_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "while true; do echo loop; done", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(WhileClause(WhileOrUntilClauseCommand(CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "true", + loc: "[location]", + )), + )), + ], + ), + ), Sequence), + ]), DoGroupCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "loop", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + ), SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 30, + line: 1, + column: 31, + ), + ))), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_while_multiline.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_while_multiline.snap new file mode 100644 index 0000000000..df570c7d5f --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__compound_commands__parse_while_multiline.snap @@ -0,0 +1,78 @@ +--- +source: brush-parser/src/parser/tests/compound_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "while true\ndo\n echo loop\n break\ndone", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(WhileClause(WhileOrUntilClauseCommand(CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "true", + loc: "[location]", + )), + )), + ], + ), + ), Sequence), + ]), DoGroupCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "loop", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "break", + loc: "[location]", + )), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + ), SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 42, + line: 5, + column: 5, + ), + ))), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_and.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_and.snap new file mode 100644 index 0000000000..c122cb377c --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_and.snap @@ -0,0 +1,29 @@ +--- +source: brush-parser/src/parser/tests/extended_test.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "[[ -f file && -r file ]]", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + ExtendedTest(ExtendedTestExprCommand( + expr: And(UnaryTest(FileExistsAndIsRegularFile, Word( + value: "file", + loc: "[location]", + )), UnaryTest(FileExistsAndIsReadable, Word( + value: "file", + loc: "[location]", + ))), + loc: "[location]", + ), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_arith_equal.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_arith_equal.snap new file mode 100644 index 0000000000..aace2f5752 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_arith_equal.snap @@ -0,0 +1,29 @@ +--- +source: brush-parser/src/parser/tests/extended_test.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "[[ 5 -eq 5 ]]", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + ExtendedTest(ExtendedTestExprCommand( + expr: BinaryTest(ArithmeticEqualTo, Word( + value: "5", + loc: "[location]", + ), Word( + value: "5", + loc: "[location]", + )), + loc: "[location]", + ), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_arith_greater_than.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_arith_greater_than.snap new file mode 100644 index 0000000000..3a72d231a4 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_arith_greater_than.snap @@ -0,0 +1,29 @@ +--- +source: brush-parser/src/parser/tests/extended_test.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "[[ 5 -gt 3 ]]", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + ExtendedTest(ExtendedTestExprCommand( + expr: BinaryTest(ArithmeticGreaterThan, Word( + value: "5", + loc: "[location]", + ), Word( + value: "3", + loc: "[location]", + )), + loc: "[location]", + ), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_arith_less_than.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_arith_less_than.snap new file mode 100644 index 0000000000..6536b42b14 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_arith_less_than.snap @@ -0,0 +1,29 @@ +--- +source: brush-parser/src/parser/tests/extended_test.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "[[ 3 -lt 5 ]]", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + ExtendedTest(ExtendedTestExprCommand( + expr: BinaryTest(ArithmeticLessThan, Word( + value: "3", + loc: "[location]", + ), Word( + value: "5", + loc: "[location]", + )), + loc: "[location]", + ), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_arith_not_equal.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_arith_not_equal.snap new file mode 100644 index 0000000000..5e6aa1e8f1 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_arith_not_equal.snap @@ -0,0 +1,29 @@ +--- +source: brush-parser/src/parser/tests/extended_test.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "[[ 5 -ne 3 ]]", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + ExtendedTest(ExtendedTestExprCommand( + expr: BinaryTest(ArithmeticNotEqualTo, Word( + value: "5", + loc: "[location]", + ), Word( + value: "3", + loc: "[location]", + )), + loc: "[location]", + ), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_complex.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_complex.snap new file mode 100644 index 0000000000..6b70d874b5 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_complex.snap @@ -0,0 +1,32 @@ +--- +source: brush-parser/src/parser/tests/extended_test.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "[[ ( -f file && -r file ) || -d file ]]", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + ExtendedTest(ExtendedTestExprCommand( + expr: Or(Parenthesized(And(UnaryTest(FileExistsAndIsRegularFile, Word( + value: "file", + loc: "[location]", + )), UnaryTest(FileExistsAndIsReadable, Word( + value: "file", + loc: "[location]", + )))), UnaryTest(FileExistsAndIsDir, Word( + value: "file", + loc: "[location]", + ))), + loc: "[location]", + ), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_directory.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_directory.snap new file mode 100644 index 0000000000..0fb4d3edbc --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_directory.snap @@ -0,0 +1,26 @@ +--- +source: brush-parser/src/parser/tests/extended_test.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "[[ -d /path/to/dir ]]", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + ExtendedTest(ExtendedTestExprCommand( + expr: UnaryTest(FileExistsAndIsDir, Word( + value: "/path/to/dir", + loc: "[location]", + )), + loc: "[location]", + ), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_executable.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_executable.snap new file mode 100644 index 0000000000..acec9a057d --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_executable.snap @@ -0,0 +1,26 @@ +--- +source: brush-parser/src/parser/tests/extended_test.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "[[ -x file ]]", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + ExtendedTest(ExtendedTestExprCommand( + expr: UnaryTest(FileExistsAndIsExecutable, Word( + value: "file", + loc: "[location]", + )), + loc: "[location]", + ), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_file_exists.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_file_exists.snap new file mode 100644 index 0000000000..762b5fda75 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_file_exists.snap @@ -0,0 +1,26 @@ +--- +source: brush-parser/src/parser/tests/extended_test.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "[[ -f file ]]", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + ExtendedTest(ExtendedTestExprCommand( + expr: UnaryTest(FileExistsAndIsRegularFile, Word( + value: "file", + loc: "[location]", + )), + loc: "[location]", + ), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_greater_than.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_greater_than.snap new file mode 100644 index 0000000000..fd2886d5b0 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_greater_than.snap @@ -0,0 +1,29 @@ +--- +source: brush-parser/src/parser/tests/extended_test.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "[[ \"$a\" > \"$b\" ]]", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + ExtendedTest(ExtendedTestExprCommand( + expr: BinaryTest(LeftSortsAfterRight, Word( + value: "\"$a\"", + loc: "[location]", + ), Word( + value: "\"$b\"", + loc: "[location]", + )), + loc: "[location]", + ), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_less_than.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_less_than.snap new file mode 100644 index 0000000000..208d301872 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_less_than.snap @@ -0,0 +1,29 @@ +--- +source: brush-parser/src/parser/tests/extended_test.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "[[ \"$a\" < \"$b\" ]]", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + ExtendedTest(ExtendedTestExprCommand( + expr: BinaryTest(LeftSortsBeforeRight, Word( + value: "\"$a\"", + loc: "[location]", + ), Word( + value: "\"$b\"", + loc: "[location]", + )), + loc: "[location]", + ), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_not.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_not.snap new file mode 100644 index 0000000000..779ffd849c --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_not.snap @@ -0,0 +1,26 @@ +--- +source: brush-parser/src/parser/tests/extended_test.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "[[ ! -f file ]]", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + ExtendedTest(ExtendedTestExprCommand( + expr: Not(UnaryTest(FileExistsAndIsRegularFile, Word( + value: "file", + loc: "[location]", + ))), + loc: "[location]", + ), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_or.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_or.snap new file mode 100644 index 0000000000..10216f48a3 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_or.snap @@ -0,0 +1,29 @@ +--- +source: brush-parser/src/parser/tests/extended_test.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "[[ -f file || -d file ]]", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + ExtendedTest(ExtendedTestExprCommand( + expr: Or(UnaryTest(FileExistsAndIsRegularFile, Word( + value: "file", + loc: "[location]", + )), UnaryTest(FileExistsAndIsDir, Word( + value: "file", + loc: "[location]", + ))), + loc: "[location]", + ), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_parenthesized.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_parenthesized.snap new file mode 100644 index 0000000000..14006f7f4c --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_parenthesized.snap @@ -0,0 +1,26 @@ +--- +source: brush-parser/src/parser/tests/extended_test.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "[[ ( -f file ) ]]", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + ExtendedTest(ExtendedTestExprCommand( + expr: Parenthesized(UnaryTest(FileExistsAndIsRegularFile, Word( + value: "file", + loc: "[location]", + ))), + loc: "[location]", + ), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_readable.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_readable.snap new file mode 100644 index 0000000000..03ac30adf1 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_readable.snap @@ -0,0 +1,26 @@ +--- +source: brush-parser/src/parser/tests/extended_test.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "[[ -r file ]]", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + ExtendedTest(ExtendedTestExprCommand( + expr: UnaryTest(FileExistsAndIsReadable, Word( + value: "file", + loc: "[location]", + )), + loc: "[location]", + ), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_regex.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_regex.snap new file mode 100644 index 0000000000..807319ab84 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_regex.snap @@ -0,0 +1,29 @@ +--- +source: brush-parser/src/parser/tests/extended_test.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "[[ \"$str\" =~ ^[0-9]+$ ]]", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + ExtendedTest(ExtendedTestExprCommand( + expr: BinaryTest(StringMatchesRegex, Word( + value: "\"$str\"", + loc: "[location]", + ), Word( + value: "^[0-9]+$", + loc: "[location]", + )), + loc: "[location]", + ), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_regex_with_spaces.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_regex_with_spaces.snap new file mode 100644 index 0000000000..5fa7318ca0 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_regex_with_spaces.snap @@ -0,0 +1,29 @@ +--- +source: brush-parser/src/parser/tests/extended_test.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "[[ \"x\" =~ (a| *) ]]", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + ExtendedTest(ExtendedTestExprCommand( + expr: BinaryTest(StringMatchesRegex, Word( + value: "\"x\"", + loc: "[location]", + ), Word( + value: "(a| *)", + loc: "[location]", + )), + loc: "[location]", + ), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_string_equal.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_string_equal.snap new file mode 100644 index 0000000000..94cf0e4296 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_string_equal.snap @@ -0,0 +1,29 @@ +--- +source: brush-parser/src/parser/tests/extended_test.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "[[ \"$a\" == \"$b\" ]]", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + ExtendedTest(ExtendedTestExprCommand( + expr: BinaryTest(StringExactlyMatchesPattern, Word( + value: "\"$a\"", + loc: "[location]", + ), Word( + value: "\"$b\"", + loc: "[location]", + )), + loc: "[location]", + ), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_string_non_zero.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_string_non_zero.snap new file mode 100644 index 0000000000..ca7a805747 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_string_non_zero.snap @@ -0,0 +1,26 @@ +--- +source: brush-parser/src/parser/tests/extended_test.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "[[ -n \"$var\" ]]", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + ExtendedTest(ExtendedTestExprCommand( + expr: UnaryTest(StringHasNonZeroLength, Word( + value: "\"$var\"", + loc: "[location]", + )), + loc: "[location]", + ), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_string_not_equal.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_string_not_equal.snap new file mode 100644 index 0000000000..b2207555e1 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_string_not_equal.snap @@ -0,0 +1,29 @@ +--- +source: brush-parser/src/parser/tests/extended_test.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "[[ \"$a\" != \"$b\" ]]", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + ExtendedTest(ExtendedTestExprCommand( + expr: BinaryTest(StringDoesNotExactlyMatchPattern, Word( + value: "\"$a\"", + loc: "[location]", + ), Word( + value: "\"$b\"", + loc: "[location]", + )), + loc: "[location]", + ), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_string_pattern.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_string_pattern.snap new file mode 100644 index 0000000000..0c334f09d7 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_string_pattern.snap @@ -0,0 +1,29 @@ +--- +source: brush-parser/src/parser/tests/extended_test.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "[[ \"$str\" == *pattern* ]]", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + ExtendedTest(ExtendedTestExprCommand( + expr: BinaryTest(StringExactlyMatchesPattern, Word( + value: "\"$str\"", + loc: "[location]", + ), Word( + value: "*pattern*", + loc: "[location]", + )), + loc: "[location]", + ), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_string_zero_length.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_string_zero_length.snap new file mode 100644 index 0000000000..65ba3b1100 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_string_zero_length.snap @@ -0,0 +1,26 @@ +--- +source: brush-parser/src/parser/tests/extended_test.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "[[ -z \"$var\" ]]", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + ExtendedTest(ExtendedTestExprCommand( + expr: UnaryTest(StringHasZeroLength, Word( + value: "\"$var\"", + loc: "[location]", + )), + loc: "[location]", + ), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_writable.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_writable.snap new file mode 100644 index 0000000000..0a8d2331a3 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__extended_test__parse_extended_test_writable.snap @@ -0,0 +1,26 @@ +--- +source: brush-parser/src/parser/tests/extended_test.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "[[ -w file ]]", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + ExtendedTest(ExtendedTestExprCommand( + expr: UnaryTest(FileExistsAndIsWritable, Word( + value: "file", + loc: "[location]", + )), + loc: "[location]", + ), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_basic.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_basic.snap new file mode 100644 index 0000000000..1325a1a248 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_basic.snap @@ -0,0 +1,48 @@ +--- +source: brush-parser/src/parser/tests/functions.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "foo() { echo hello; }", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Function(FunctionDefinition( + fname: Word( + value: "foo", + loc: "[location]", + ), + body: FunctionBody(BraceGroup(BraceGroupCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "hello", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + )), None), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_keyword.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_keyword.snap new file mode 100644 index 0000000000..0d11367d9e --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_keyword.snap @@ -0,0 +1,48 @@ +--- +source: brush-parser/src/parser/tests/functions.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "function foo { echo hello; }", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Function(FunctionDefinition( + fname: Word( + value: "foo", + loc: "[location]", + ), + body: FunctionBody(BraceGroup(BraceGroupCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "hello", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + )), None), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_keyword_with_parens.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_keyword_with_parens.snap new file mode 100644 index 0000000000..688d979a57 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_keyword_with_parens.snap @@ -0,0 +1,48 @@ +--- +source: brush-parser/src/parser/tests/functions.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "function foo() { echo hello; }", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Function(FunctionDefinition( + fname: Word( + value: "foo", + loc: "[location]", + ), + body: FunctionBody(BraceGroup(BraceGroupCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "hello", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + )), None), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_multiline.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_multiline.snap new file mode 100644 index 0000000000..d158fc3aee --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_multiline.snap @@ -0,0 +1,66 @@ +--- +source: brush-parser/src/parser/tests/functions.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "foo() {\n echo hello\n echo world\n}", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Function(FunctionDefinition( + fname: Word( + value: "foo", + loc: "[location]", + ), + body: FunctionBody(BraceGroup(BraceGroupCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "hello", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "world", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + )), None), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_with_local_vars.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_with_local_vars.snap new file mode 100644 index 0000000000..373d85df7d --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_with_local_vars.snap @@ -0,0 +1,73 @@ +--- +source: brush-parser/src/parser/tests/functions.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "foo() {\n local x=1\n echo $x\n}", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Function(FunctionDefinition( + fname: Word( + value: "foo", + loc: "[location]", + ), + body: FunctionBody(BraceGroup(BraceGroupCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "local", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + AssignmentWord(Assignment( + name: VariableName("x"), + value: Scalar(Word( + value: "1", + loc: "[location]", + )), + loc: "[location]", + ), Word( + value: "x=1", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "$x", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + )), None), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_with_redirect.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_with_redirect.snap new file mode 100644 index 0000000000..8286009a5a --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_with_redirect.snap @@ -0,0 +1,59 @@ +--- +source: brush-parser/src/parser/tests/functions.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "foo() { echo 1; } 2>&1 | cat", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Function(FunctionDefinition( + fname: Word( + value: "foo", + loc: "[location]", + ), + body: FunctionBody(BraceGroup(BraceGroupCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "1", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + )), Some(RedirectList([ + File(Some(2), DuplicateOutput, Duplicate(Word( + value: "1", + loc: "[location]", + ))), + ]))), + )), + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cat", + loc: "[location]", + )), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_with_stderr_redirect.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_with_stderr_redirect.snap new file mode 100644 index 0000000000..4cc77f823b --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_with_stderr_redirect.snap @@ -0,0 +1,56 @@ +--- +source: brush-parser/src/parser/tests/functions.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "foo() { echo 1; } |& cat", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Function(FunctionDefinition( + fname: Word( + value: "foo", + loc: "[location]", + ), + body: FunctionBody(BraceGroup(BraceGroupCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "1", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + )), Some(RedirectList([ + File(Some(2), DuplicateOutput, Fd(1)), + ]))), + )), + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cat", + loc: "[location]", + )), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_with_subshell_body.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_with_subshell_body.snap new file mode 100644 index 0000000000..7ad87c3c27 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__functions__parse_function_with_subshell_body.snap @@ -0,0 +1,48 @@ +--- +source: brush-parser/src/parser/tests/functions.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "foo() ( echo subshell )", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Function(FunctionDefinition( + fname: Word( + value: "foo", + loc: "[location]", + ), + body: FunctionBody(Subshell(SubshellCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "subshell", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + )), None), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__here_docs__parse_here_doc_basic.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__here_docs__parse_here_doc_basic.snap new file mode 100644 index 0000000000..0f9cf17f6e --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__here_docs__parse_here_doc_basic.snap @@ -0,0 +1,38 @@ +--- +source: brush-parser/src/parser/tests/here_docs.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "cat <&2\n\n done\n\n", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Compound(ForClause(ForClauseCommand( + variable_name: "f", + values: Some([ + Word( + value: "A", + loc: Some(SourceSpan( + start: SourcePosition( + index: 32, + line: 5, + column: 10, + ), + end: SourcePosition( + index: 33, + line: 5, + column: 11, + ), + )), + ), + Word( + value: "B", + loc: Some(SourceSpan( + start: SourcePosition( + index: 34, + line: 5, + column: 12, + ), + end: SourcePosition( + index: 35, + line: 5, + column: 13, + ), + )), + ), + Word( + value: "C", + loc: Some(SourceSpan( + start: SourcePosition( + index: 36, + line: 5, + column: 14, + ), + end: SourcePosition( + index: 37, + line: 5, + column: 15, + ), + )), + ), + ]), + body: DoGroupCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: Some(SourceSpan( + start: SourcePosition( + index: 60, + line: 8, + column: 5, + ), + end: SourcePosition( + index: 64, + line: 8, + column: 9, + ), + )), + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "\"${f@L}\"", + loc: Some(SourceSpan( + start: SourcePosition( + index: 65, + line: 8, + column: 10, + ), + end: SourcePosition( + index: 73, + line: 8, + column: 18, + ), + )), + )), + IoRedirect(File(None, DuplicateOutput, Duplicate(Word( + value: "2", + loc: Some(SourceSpan( + start: SourcePosition( + index: 76, + line: 8, + column: 21, + ), + end: SourcePosition( + index: 77, + line: 8, + column: 22, + ), + )), + )))), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: SourceSpan( + start: SourcePosition( + index: 39, + line: 5, + column: 17, + ), + end: SourcePosition( + index: 86, + line: 10, + column: 8, + ), + ), + ), + loc: SourceSpan( + start: SourcePosition( + index: 23, + line: 5, + column: 1, + ), + end: SourcePosition( + index: 86, + line: 10, + column: 8, + ), + ), + )), None), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__pipelines__parse_multi_stage_pipe.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__pipelines__parse_multi_stage_pipe.snap new file mode 100644 index 0000000000..021e2676ea --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__pipelines__parse_multi_stage_pipe.snap @@ -0,0 +1,55 @@ +--- +source: brush-parser/src/parser/tests/pipelines.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "cat file | grep pattern | wc -l", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cat", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "file", + loc: "[location]", + )), + ])), + )), + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "grep", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "pattern", + loc: "[location]", + )), + ])), + )), + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "wc", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "-l", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__pipelines__parse_negated_pipeline.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__pipelines__parse_negated_pipeline.snap new file mode 100644 index 0000000000..7c79806ffa --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__pipelines__parse_negated_pipeline.snap @@ -0,0 +1,32 @@ +--- +source: brush-parser/src/parser/tests/pipelines.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "! echo hello", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + bang: true, + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "hello", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__pipelines__parse_negated_timed_pipeline.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__pipelines__parse_negated_timed_pipeline.snap new file mode 100644 index 0000000000..bb592be774 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__pipelines__parse_negated_timed_pipeline.snap @@ -0,0 +1,44 @@ +--- +source: brush-parser/src/parser/tests/pipelines.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "time ! echo hello", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + timed: Some(Timed(SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 4, + line: 1, + column: 5, + ), + ))), + bang: true, + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "hello", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__pipelines__parse_pipe_with_multiple_commands.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__pipelines__parse_pipe_with_multiple_commands.snap new file mode 100644 index 0000000000..667b54d002 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__pipelines__parse_pipe_with_multiple_commands.snap @@ -0,0 +1,55 @@ +--- +source: brush-parser/src/parser/tests/pipelines.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "ls -la | head -10 | tail -5", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "ls", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "-la", + loc: "[location]", + )), + ])), + )), + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "head", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "-10", + loc: "[location]", + )), + ])), + )), + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "tail", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "-5", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__pipelines__parse_pipe_with_stderr.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__pipelines__parse_pipe_with_stderr.snap new file mode 100644 index 0000000000..8b4fb0e113 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__pipelines__parse_pipe_with_stderr.snap @@ -0,0 +1,34 @@ +--- +source: brush-parser/src/parser/tests/pipelines.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "echo |& wc", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + IoRedirect(File(Some(2), DuplicateOutput, Fd(1))), + ])), + )), + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "wc", + loc: "[location]", + )), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__pipelines__parse_simple_pipe.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__pipelines__parse_simple_pipe.snap new file mode 100644 index 0000000000..4aa6fa8811 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__pipelines__parse_simple_pipe.snap @@ -0,0 +1,43 @@ +--- +source: brush-parser/src/parser/tests/pipelines.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "echo hello | grep world", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "hello", + loc: "[location]", + )), + ])), + )), + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "grep", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "world", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__pipelines__parse_timed_pipeline.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__pipelines__parse_timed_pipeline.snap new file mode 100644 index 0000000000..b8d248d26d --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__pipelines__parse_timed_pipeline.snap @@ -0,0 +1,43 @@ +--- +source: brush-parser/src/parser/tests/pipelines.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "time echo hello", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + timed: Some(Timed(SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 4, + line: 1, + column: 5, + ), + ))), + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "hello", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__pipelines__parse_timed_pipeline_posix.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__pipelines__parse_timed_pipeline_posix.snap new file mode 100644 index 0000000000..a4c0c91056 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__pipelines__parse_timed_pipeline_posix.snap @@ -0,0 +1,43 @@ +--- +source: brush-parser/src/parser/tests/pipelines.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "time -p echo hello", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + timed: Some(TimedWithPosixOutput(SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 7, + line: 1, + column: 8, + ), + ))), + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "hello", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_here_string.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_here_string.snap new file mode 100644 index 0000000000..07b4689cdf --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_here_string.snap @@ -0,0 +1,31 @@ +--- +source: brush-parser/src/parser/tests/redirections.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "cat <<< \'hello world\'", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cat", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + IoRedirect(HereString(None, Word( + value: "\'hello world\'", + loc: "[location]", + ))), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_here_string_with_variable.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_here_string_with_variable.snap new file mode 100644 index 0000000000..5a97f36f22 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_here_string_with_variable.snap @@ -0,0 +1,31 @@ +--- +source: brush-parser/src/parser/tests/redirections.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "cat <<< $variable", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cat", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + IoRedirect(HereString(None, Word( + value: "$variable", + loc: "[location]", + ))), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_process_substitution_read.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_process_substitution_read.snap new file mode 100644 index 0000000000..57188e3989 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_process_substitution_read.snap @@ -0,0 +1,73 @@ +--- +source: brush-parser/src/parser/tests/redirections.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "diff <(sort file1) <(sort file2)", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "diff", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + ProcessSubstitution(Read, SubshellCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "sort", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "file1", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + )), + ProcessSubstitution(Read, SubshellCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "sort", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "file2", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_process_substitution_write.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_process_substitution_write.snap new file mode 100644 index 0000000000..33d14c3d1b --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_process_substitution_write.snap @@ -0,0 +1,54 @@ +--- +source: brush-parser/src/parser/tests/redirections.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "tee >(grep error > errors.txt)", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "tee", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + ProcessSubstitution(Write, SubshellCommand( + list: CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "grep", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "error", + loc: "[location]", + )), + IoRedirect(File(None, Write, Filename(Word( + value: "errors.txt", + loc: "[location]", + )))), + ])), + )), + ], + ), + ), Sequence), + ]), + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_append.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_append.snap new file mode 100644 index 0000000000..032f591cd0 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_append.snap @@ -0,0 +1,35 @@ +--- +source: brush-parser/src/parser/tests/redirections.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "echo hello >> file.txt", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "hello", + loc: "[location]", + )), + IoRedirect(File(None, Append, Filename(Word( + value: "file.txt", + loc: "[location]", + )))), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_clobber.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_clobber.snap new file mode 100644 index 0000000000..cf63f389cc --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_clobber.snap @@ -0,0 +1,35 @@ +--- +source: brush-parser/src/parser/tests/redirections.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "echo hello >| file.txt", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "hello", + loc: "[location]", + )), + IoRedirect(File(None, Clobber, Filename(Word( + value: "file.txt", + loc: "[location]", + )))), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_fd_close.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_fd_close.snap new file mode 100644 index 0000000000..93bb442494 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_fd_close.snap @@ -0,0 +1,31 @@ +--- +source: brush-parser/src/parser/tests/redirections.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "command 2>&-", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "command", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + IoRedirect(File(Some(2), DuplicateOutput, Duplicate(Word( + value: "-", + loc: "[location]", + )))), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_input.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_input.snap new file mode 100644 index 0000000000..0f66aabb17 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_input.snap @@ -0,0 +1,31 @@ +--- +source: brush-parser/src/parser/tests/redirections.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "cat < input.txt", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cat", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + IoRedirect(File(None, Read, Filename(Word( + value: "input.txt", + loc: "[location]", + )))), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_multiple.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_multiple.snap new file mode 100644 index 0000000000..65b36739e7 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_multiple.snap @@ -0,0 +1,39 @@ +--- +source: brush-parser/src/parser/tests/redirections.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "command < input.txt > output.txt 2>&1", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "command", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + IoRedirect(File(None, Read, Filename(Word( + value: "input.txt", + loc: "[location]", + )))), + IoRedirect(File(None, Write, Filename(Word( + value: "output.txt", + loc: "[location]", + )))), + IoRedirect(File(Some(2), DuplicateOutput, Duplicate(Word( + value: "1", + loc: "[location]", + )))), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_output.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_output.snap new file mode 100644 index 0000000000..c6c1fec110 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_output.snap @@ -0,0 +1,35 @@ +--- +source: brush-parser/src/parser/tests/redirections.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "echo hello > file.txt", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "hello", + loc: "[location]", + )), + IoRedirect(File(None, Write, Filename(Word( + value: "file.txt", + loc: "[location]", + )))), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_output_and_error.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_output_and_error.snap new file mode 100644 index 0000000000..b832fd5983 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_output_and_error.snap @@ -0,0 +1,31 @@ +--- +source: brush-parser/src/parser/tests/redirections.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "command &> file.txt", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "command", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + IoRedirect(OutputAndError(Word( + value: "file.txt", + loc: "[location]", + ), false)), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_output_and_error_append.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_output_and_error_append.snap new file mode 100644 index 0000000000..5cce283bec --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_output_and_error_append.snap @@ -0,0 +1,31 @@ +--- +source: brush-parser/src/parser/tests/redirections.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "command &>> file.txt", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "command", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + IoRedirect(OutputAndError(Word( + value: "file.txt", + loc: "[location]", + ), true)), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_read_write.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_read_write.snap new file mode 100644 index 0000000000..1cfffa8fcd --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_read_write.snap @@ -0,0 +1,31 @@ +--- +source: brush-parser/src/parser/tests/redirections.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "cat <> file.txt", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "cat", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + IoRedirect(File(None, ReadAndWrite, Filename(Word( + value: "file.txt", + loc: "[location]", + )))), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_stderr_to_stdout.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_stderr_to_stdout.snap new file mode 100644 index 0000000000..d879d2527c --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_stderr_to_stdout.snap @@ -0,0 +1,31 @@ +--- +source: brush-parser/src/parser/tests/redirections.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "command 2>&1", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "command", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + IoRedirect(File(Some(2), DuplicateOutput, Duplicate(Word( + value: "1", + loc: "[location]", + )))), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_stdin_dup.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_stdin_dup.snap new file mode 100644 index 0000000000..c4d4c85942 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_stdin_dup.snap @@ -0,0 +1,31 @@ +--- +source: brush-parser/src/parser/tests/redirections.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "command <&3", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "command", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + IoRedirect(File(None, DuplicateInput, Duplicate(Word( + value: "3", + loc: "[location]", + )))), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_stdout_to_stderr.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_stdout_to_stderr.snap new file mode 100644 index 0000000000..ecae6b46ee --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__redirections__parse_redirect_stdout_to_stderr.snap @@ -0,0 +1,31 @@ +--- +source: brush-parser/src/parser/tests/redirections.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "command 1>&2", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "command", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + IoRedirect(File(Some(1), DuplicateOutput, Duplicate(Word( + value: "2", + loc: "[location]", + )))), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_colon.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_colon.snap new file mode 100644 index 0000000000..2a03efbaed --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_colon.snap @@ -0,0 +1,25 @@ +--- +source: brush-parser/src/parser/tests/simple_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: ":", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: ":", + loc: "[location]", + )), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_command_with_backslash_escape.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_command_with_backslash_escape.snap new file mode 100644 index 0000000000..7b869b1e03 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_command_with_backslash_escape.snap @@ -0,0 +1,31 @@ +--- +source: brush-parser/src/parser/tests/simple_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "echo hello\\ world", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "hello\\ world", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_command_with_backtick_substitution.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_command_with_backtick_substitution.snap new file mode 100644 index 0000000000..75c07654b0 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_command_with_backtick_substitution.snap @@ -0,0 +1,31 @@ +--- +source: brush-parser/src/parser/tests/simple_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "echo `whoami`", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "`whoami`", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_command_with_command_substitution.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_command_with_command_substitution.snap new file mode 100644 index 0000000000..a0bcdada39 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_command_with_command_substitution.snap @@ -0,0 +1,31 @@ +--- +source: brush-parser/src/parser/tests/simple_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "echo $(whoami)", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "$(whoami)", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_command_with_quoted_args.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_command_with_quoted_args.snap new file mode 100644 index 0000000000..52879a0500 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_command_with_quoted_args.snap @@ -0,0 +1,31 @@ +--- +source: brush-parser/src/parser/tests/simple_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "echo \"hello world\"", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "\"hello world\"", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_command_with_single_quotes.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_command_with_single_quotes.snap new file mode 100644 index 0000000000..f661e62aec --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_command_with_single_quotes.snap @@ -0,0 +1,31 @@ +--- +source: brush-parser/src/parser/tests/simple_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "echo \'hello world\'", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "\'hello world\'", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_command_with_variable.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_command_with_variable.snap new file mode 100644 index 0000000000..2ecad8f3e3 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_command_with_variable.snap @@ -0,0 +1,31 @@ +--- +source: brush-parser/src/parser/tests/simple_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "echo $HOME", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "$HOME", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_echo_hello.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_echo_hello.snap new file mode 100644 index 0000000000..5fcbcee762 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_echo_hello.snap @@ -0,0 +1,31 @@ +--- +source: brush-parser/src/parser/tests/simple_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "echo hello", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "hello", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_echo_hello_world.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_echo_hello_world.snap new file mode 100644 index 0000000000..955ff913fe --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_echo_hello_world.snap @@ -0,0 +1,35 @@ +--- +source: brush-parser/src/parser/tests/simple_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "echo hello world", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "echo", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "hello", + loc: "[location]", + )), + Word(Word( + value: "world", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_ls.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_ls.snap new file mode 100644 index 0000000000..3165c93b77 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_ls.snap @@ -0,0 +1,25 @@ +--- +source: brush-parser/src/parser/tests/simple_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "ls", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "ls", + loc: "[location]", + )), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_ls_la_home.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_ls_la_home.snap new file mode 100644 index 0000000000..c624bee39d --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_ls_la_home.snap @@ -0,0 +1,35 @@ +--- +source: brush-parser/src/parser/tests/simple_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "ls -la /home", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "ls", + loc: "[location]", + )), + suffix: Some(CommandSuffix([ + Word(Word( + value: "-la", + loc: "[location]", + )), + Word(Word( + value: "/home", + loc: "[location]", + )), + ])), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_true.snap b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_true.snap new file mode 100644 index 0000000000..7415d3bd91 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/tests/snapshots/brush_parser__parser__tests__simple_commands__parse_true.snap @@ -0,0 +1,25 @@ +--- +source: brush-parser/src/parser/tests/simple_commands.rs +expression: "ParseResult { input, result: &result }" +--- +ParseResult( + input: "true", + result: Program( + complete_commands: [ + CompoundList([ + CompoundListItem(AndOrList( + first: Pipeline( + seq: [ + Simple(SimpleCommand( + word_or_name: Some(Word( + value: "true", + loc: "[location]", + )), + )), + ], + ), + ), Sequence), + ]), + ], + ), +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/parser/winnow_str.rs b/local/recipes/shells/brush/source/brush-parser/src/parser/winnow_str.rs new file mode 100644 index 0000000000..bae0b9f7ef --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/parser/winnow_str.rs @@ -0,0 +1,20 @@ +//! String-based winnow parser + +use winnow::error::ContextError; + +use crate::ast; +use crate::parser::{ParserOptions, SourceInfo}; + +/// Type alias for parser error +type PError = winnow::error::ErrMode; + +/// Parse a shell program from a string with full source location tracking +/// +/// This is not yet implemented. +pub fn parse_program( + _input: &str, + _options: &ParserOptions, + _source_info: &SourceInfo, +) -> Result { + unimplemented!("winnow string parser is not yet implemented") +} diff --git a/local/recipes/shells/brush/source/brush-parser/src/pattern.rs b/local/recipes/shells/brush/source/brush-parser/src/pattern.rs new file mode 100644 index 0000000000..ffb85f1037 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/pattern.rs @@ -0,0 +1,325 @@ +//! Implements parsing for shell glob and extglob patterns. + +use crate::error; + +/// Represents the kind of an extended glob. +pub enum ExtendedGlobKind { + /// The `+` extended glob; matches one or more occurrences of the inner pattern. + Plus, + /// The `@` extended glob; allows matching an alternation of inner patterns. + At, + /// The `!` extended glob; matches the negation of the inner pattern. + Exclamation, + /// The `?` extended glob; matches zero or one occurrence of the inner pattern. + Question, + /// The `*` extended glob; matches zero or more occurrences of the inner pattern. + Star, +} + +/// Converts a shell pattern to a regular expression string. +/// +/// # Arguments +/// +/// * `pattern` - The shell pattern to convert. +/// * `enable_extended_globbing` - Whether to enable extended globbing (extglob). +pub fn pattern_to_regex_str( + pattern: &str, + enable_extended_globbing: bool, +) -> Result { + let regex_str = pattern_to_regex_translator::pattern(pattern, enable_extended_globbing) + .map_err(|e| error::WordParseError::Pattern(e.into()))?; + Ok(regex_str) +} + +peg::parser! { + grammar pattern_to_regex_translator(enable_extended_globbing: bool) for str { + pub(crate) rule pattern() -> String = + pieces:(pattern_piece()*) { + pieces.join("") + } + + rule pattern_piece() -> String = + escape_sequence() / + bracket_expression() / + extglob_enabled() s:extended_glob_pattern() { s } / + wildcard() / + [c if regex_char_needs_escaping(c)] { + let mut s = '\\'.to_string(); + s.push(c); + s + } / + [c] { c.to_string() } + + rule escape_sequence() -> String = + sequence:$(['\\'] [c if regex_char_needs_escaping(c)]) { sequence.to_owned() } / + ['\\'] [c] { c.to_string() } + + rule bracket_expression() -> String = + "[" invert:(invert_char()?) members:bracket_member()+ "]" { + let mut members = members.into_iter().flatten().collect::>(); + + // If we completed the parse but ended up with no valid members + // of the bracket expression, then return a regex that matches nothing. + // (Or in the inverted case, matches everything.) + if members.is_empty() { + if invert.is_some() { + String::from(".") + } else { + String::from("(?!)") + } + } else { + if invert.is_some() { + members.insert(0, String::from("^")); + } + + std::format!("[{}]", members.join("")) + } + } + + rule invert_char() -> bool = + ['!' | '^'] { true } + + rule bracket_member() -> Option = + e:char_class_expression() { Some(e) } / + r:char_range() { r } / + m:single_char_bracket_member() { + let (char_str, _) = m; + Some(char_str) + } + + rule char_class_expression() -> String = + e:$("[:" char_class() ":]") { e.to_owned() } + + rule char_class() = + "alnum" / "alpha" / "blank" / "cntrl" / "digit" / "graph" / "lower" / "print" / "punct" / "space" / "upper"/ "xdigit" + + rule char_range() -> Option = + from:single_char_bracket_member() "-" to:single_char_bracket_member() { + let (from_str, from_c) = from; + let (to_str, to_c) = to; + + // Evaluate if the range is valid. + if from_c <= to_c { + Some(std::format!("{from_str}-{to_str}")) + } else { + None + } + } + + rule single_char_bracket_member() -> (String, char) = + // Preserve escaped characters as-is. + ['\\'] [c] { (std::format!("\\{c}"), c) } / + // Escape opening bracket. + ['['] { (String::from(r"\["), '[') } / + // Any other character except closing bracket gets added as-is. + [c if c != ']'] { (c.to_string(), c) } + + rule wildcard() -> String = + "?" { String::from(".") } / + "*" { String::from(".*") } + + rule extglob_enabled() -> () = + &[_] {? if enable_extended_globbing { Ok(()) } else { Err("extglob disabled") } } + + pub(crate) rule extended_glob_pattern() -> String = + kind:extended_glob_prefix() "(" branches:extended_glob_body() ")" { + let mut s = String::new(); + + // fancy_regex uses ?! to indicate a negative lookahead. + if matches!(kind, ExtendedGlobKind::Exclamation) { + if !branches.is_empty() { + s.push_str("(?:(?!"); + s.push_str(&branches.join("|")); + s.push_str(").*|(?>"); + s.push_str(&branches.join("|")); + s.push_str(").+?|)"); + } else { + s.push_str("(?:.+)"); + } + } else { + s.push('('); + s.push_str(&branches.join("|")); + s.push(')'); + + match kind { + ExtendedGlobKind::Plus => s.push('+'), + ExtendedGlobKind::Question => s.push('?'), + ExtendedGlobKind::Star => s.push('*'), + ExtendedGlobKind::At | ExtendedGlobKind::Exclamation => (), + } + } + + s + } + + rule extended_glob_prefix() -> ExtendedGlobKind = + "+" { ExtendedGlobKind::Plus } / + "@" { ExtendedGlobKind::At } / + "!" { ExtendedGlobKind::Exclamation } / + "?" { ExtendedGlobKind::Question } / + "*" { ExtendedGlobKind::Star } + + pub(crate) rule extended_glob_body() -> Vec = + // Cover case with *no* branches. + &[')'] { vec![] } / + // Otherwise, look for branches separated by '|'. + extended_glob_branch() ** "|" + + rule extended_glob_branch() -> String = + // Cover case of empty branch. + &['|' | ')'] { String::new() } / + pieces:(!['|' | ')'] piece:pattern_piece() { piece })+ { + pieces.join("") + } + + // A glob metacharacter construct: wildcard, bracket expression, or extglob. + rule glob_piece() = + bracket_expression() / + extglob_enabled() extended_glob_pattern() / + wildcard() + + // A non-glob piece: an escape sequence or any character not starting a glob. + rule non_glob_piece() = + escape_sequence() / + !glob_piece() [_] + + // Succeeds (returning true) if the pattern contains at least one glob + // metacharacter. The same bracket_expression, wildcard, and + // extended_glob_pattern rules used for regex conversion are reused here + // via negative lookaheads, keeping a single source of truth. + pub(crate) rule has_glob_metacharacters() -> bool = + non_glob_piece()* glob_piece() [_]* { true } + } +} + +/// Returns whether a pattern string contains any glob metacharacters. +/// +/// Uses the same PEG grammar rules that `pattern_to_regex_str` uses, keeping +/// a single source of truth for what constitutes a glob metacharacter. +/// +/// # Arguments +/// +/// * `pattern` - The shell pattern to check. +/// * `enable_extended_globbing` - Whether to enable extended globbing (extglob). +pub fn pattern_has_glob_metacharacters(pattern: &str, enable_extended_globbing: bool) -> bool { + pattern_to_regex_translator::has_glob_metacharacters(pattern, enable_extended_globbing) + .unwrap_or(false) +} + +/// Returns whether or not a given character needs to be escaped in a regular expression. +/// +/// # Arguments +/// +/// * `c` - The character to check. +pub const fn regex_char_needs_escaping(c: char) -> bool { + matches!( + c, + '[' | ']' | '(' | ')' | '{' | '}' | '*' | '?' | '.' | '+' | '^' | '$' | '|' | '\\' | '-' + ) +} + +#[cfg(test)] +#[expect(clippy::panic_in_result_fn)] +mod tests { + use super::*; + use anyhow::Result; + + #[test] + fn test_bracket_exprs() -> Result<()> { + assert_eq!(pattern_to_regex_str("[a-z]", true)?, "[a-z]"); + assert_eq!(pattern_to_regex_str("[z-a]", true)?, "(?!)"); + assert_eq!(pattern_to_regex_str("[+-/]", true)?, "[+-/]"); + assert_eq!(pattern_to_regex_str(r"[\*-/]", true)?, r"[\*-/]"); + assert_eq!(pattern_to_regex_str("[abc]", true)?, "[abc]"); + assert_eq!(pattern_to_regex_str(r"[\(]", true)?, r"[\(]"); + assert_eq!(pattern_to_regex_str(r"[(]", true)?, "[(]"); + assert_eq!(pattern_to_regex_str("[[:digit:]]", true)?, "[[:digit:]]"); + assert_eq!(pattern_to_regex_str(r"[-(),!]*", true)?, r"[-(),!].*"); + assert_eq!(pattern_to_regex_str(r"[-\(\),\!]*", true)?, r"[-\(\),\!].*"); + assert_eq!(pattern_to_regex_str(r"[a\-b]", true)?, r"[a\-b]"); + assert_eq!(pattern_to_regex_str(r"[a\-\*]", true)?, r"[a\-\*]"); + Ok(()) + } + + #[test] + fn test_extended_glob() -> Result<()> { + assert_eq!( + pattern_to_regex_translator::extended_glob_pattern("@(a|b)", true)?, + "(a|b)" + ); + + assert_eq!( + pattern_to_regex_translator::extended_glob_pattern("@(|a)", true)?, + "(|a)" + ); + + assert_eq!( + pattern_to_regex_translator::extended_glob_pattern("@(|)", true)?, + "(|)" + ); + + assert_eq!( + pattern_to_regex_translator::extended_glob_body("ab|ac", true)?, + vec!["ab", "ac"], + ); + + assert_eq!( + pattern_to_regex_translator::extended_glob_pattern("*(ab|ac)", true)?, + "(ab|ac)*" + ); + + assert_eq!( + pattern_to_regex_translator::extended_glob_body("", true)?, + Vec::::new(), + ); + + Ok(()) + } + + #[test] + fn test_has_glob_metacharacters() { + // Basic metacharacters. + assert!(pattern_has_glob_metacharacters("*", false)); + assert!(pattern_has_glob_metacharacters("?", false)); + assert!(pattern_has_glob_metacharacters("a*b", false)); + assert!(pattern_has_glob_metacharacters("a?b", false)); + + // Valid bracket expressions. + assert!(pattern_has_glob_metacharacters("[abc]", false)); + assert!(pattern_has_glob_metacharacters("[a-z]", false)); + assert!(pattern_has_glob_metacharacters("[!a]", false)); + + // Lone `]` is NOT a glob metacharacter. + assert!(!pattern_has_glob_metacharacters("]", false)); + assert!(!pattern_has_glob_metacharacters("foo]", false)); + assert!(!pattern_has_glob_metacharacters("a]b", false)); + + // Lone `[` without matching `]` is NOT a glob metacharacter. + assert!(!pattern_has_glob_metacharacters("[", false)); + assert!(!pattern_has_glob_metacharacters("[abc", false)); + assert!(!pattern_has_glob_metacharacters("a[b", false)); + + // Plain text — no glob chars. + assert!(!pattern_has_glob_metacharacters("hello", false)); + assert!(!pattern_has_glob_metacharacters("", false)); + + // Backslash-escaped metacharacters are not globs. + assert!(!pattern_has_glob_metacharacters(r"\*", false)); + assert!(!pattern_has_glob_metacharacters(r"\?", false)); + assert!(!pattern_has_glob_metacharacters(r"\[abc]", false)); + + // Extglob patterns — not detected without extended globbing. + assert!(!pattern_has_glob_metacharacters("@(a)", false)); + assert!(!pattern_has_glob_metacharacters("!(a)", false)); + assert!(!pattern_has_glob_metacharacters("+(a)", false)); + + // Extglob patterns — detected with extended globbing. + assert!(pattern_has_glob_metacharacters("@(a)", true)); + assert!(pattern_has_glob_metacharacters("!(a)", true)); + assert!(pattern_has_glob_metacharacters("+(a)", true)); + + // *( and ?( are already caught by * and ? checks. + assert!(pattern_has_glob_metacharacters("*(a)", false)); + assert!(pattern_has_glob_metacharacters("?(a)", false)); + } +} diff --git a/local/recipes/shells/brush/source/brush-parser/src/prompt.rs b/local/recipes/shells/brush/source/brush-parser/src/prompt.rs new file mode 100644 index 0000000000..53b2b82190 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/prompt.rs @@ -0,0 +1,204 @@ +//! Parser for shell prompt syntax (e.g., `PS1`). + +use crate::error; + +/// A piece of a prompt string. +#[derive(Clone, Debug)] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum PromptPiece { + /// An ASCII character. + AsciiCharacter(u32), + /// A backslash character. + Backslash, + /// The bell character. + BellCharacter, + /// A carriage return character. + CarriageReturn, + /// The current command number. + CurrentCommandNumber, + /// The current history number. + CurrentHistoryNumber, + /// The name of the current user. + CurrentUser, + /// Path to the current working directory. + CurrentWorkingDirectory { + /// Whether or not to apply tilde-replacement before expanding. + tilde_replaced: bool, + /// Whether or not to only expand to the basename of the directory. + basename: bool, + }, + /// The current date, using the given format. + Date(PromptDateFormat), + /// The dollar or pound character. + DollarOrPound, + /// Special marker indicating the end of a non-printing sequence of characters. + EndNonPrintingSequence, + /// The escape character. + EscapeCharacter, + /// An escaped sequence not otherwise recognized. + EscapedSequence(String), + /// The hostname of the system. + Hostname { + /// Whether or not to include only up to the first dot of the name. + only_up_to_first_dot: bool, + }, + /// A literal string. + Literal(String), + /// A newline character. + Newline, + /// The number of actively managed jobs. + NumberOfManagedJobs, + /// The base name of the shell. + ShellBaseName, + /// The release of the shell. + ShellRelease, + /// The version of the shell. + ShellVersion, + /// Special marker indicating the start of a non-printing sequence of characters. + StartNonPrintingSequence, + /// The base name of the terminal device. + TerminalDeviceBaseName, + /// The current time, using the given format. + Time(PromptTimeFormat), +} + +/// Format for a date in a prompt. +#[derive(Clone, Debug)] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum PromptDateFormat { + /// A format including weekday, month, and date. + WeekdayMonthDate, + /// A customer string format. + Custom(String), +} + +/// Format for a time in a prompt. +#[derive(Clone, Debug)] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum PromptTimeFormat { + /// A twelve-hour time format with AM/PM. + TwelveHourAM, + /// A twelve-hour time format (HHMMSS). + TwelveHourHHMMSS, + /// A twenty-four-hour time format (HHMM). + TwentyFourHourHHMM, + /// A twenty-four-hour time format (HHMMSS). + TwentyFourHourHHMMSS, +} + +peg::parser! { + grammar prompt_parser() for str { + pub(crate) rule prompt() -> Vec = + pieces:prompt_piece()* + + rule prompt_piece() -> PromptPiece = + special_sequence() / + literal_sequence() + + // + // Reference: https://www.gnu.org/software/bash/manual/bash.html#Controlling-the-Prompt + // + rule special_sequence() -> PromptPiece = + "\\a" { PromptPiece::BellCharacter } / + "\\A" { PromptPiece::Time(PromptTimeFormat::TwentyFourHourHHMM) } / + "\\d" { PromptPiece::Date(PromptDateFormat::WeekdayMonthDate) } / + "\\D{" f:date_format() "}" { PromptPiece::Date(PromptDateFormat::Custom(f)) } / + "\\e" { PromptPiece::EscapeCharacter } / + "\\h" { PromptPiece::Hostname { only_up_to_first_dot: true } } / + "\\H" { PromptPiece::Hostname { only_up_to_first_dot: false } } / + "\\j" { PromptPiece::NumberOfManagedJobs } / + "\\l" { PromptPiece::TerminalDeviceBaseName } / + "\\n" { PromptPiece::Newline } / + "\\r" { PromptPiece::CarriageReturn } / + "\\s" { PromptPiece::ShellBaseName } / + "\\t" { PromptPiece::Time(PromptTimeFormat::TwentyFourHourHHMMSS ) } / + "\\T" { PromptPiece::Time(PromptTimeFormat::TwelveHourHHMMSS ) } / + "\\@" { PromptPiece::Time(PromptTimeFormat::TwelveHourAM ) } / + "\\u" { PromptPiece::CurrentUser } / + "\\v" { PromptPiece::ShellVersion } / + "\\V" { PromptPiece::ShellRelease } / + "\\w" { PromptPiece::CurrentWorkingDirectory { tilde_replaced: true, basename: false, } } / + "\\W" { PromptPiece::CurrentWorkingDirectory { tilde_replaced: true, basename: true, } } / + "\\!" { PromptPiece::CurrentHistoryNumber } / + "\\#" { PromptPiece::CurrentCommandNumber } / + "\\$" { PromptPiece::DollarOrPound } / + "\\" n:octal_number() { PromptPiece::AsciiCharacter(n) } / + "\\\\" { PromptPiece::Backslash } / + "\\[" { PromptPiece::StartNonPrintingSequence } / + "\\]" { PromptPiece::EndNonPrintingSequence } / + s:$("\\" [_]) { PromptPiece::EscapedSequence(s.to_owned()) } + + rule literal_sequence() -> PromptPiece = + s:$((!special_sequence() [c])+) { PromptPiece::Literal(s.to_owned()) } + + rule date_format() -> String = + s:$([c if c != '}']*) { s.to_owned() } + + rule octal_number() -> u32 = + s:$(['0'..='7']*<1,3>) {? u32::from_str_radix(s, 8).or(Err("invalid octal number")) } + } +} + +/// Parses a shell prompt string. +/// +/// # Arguments +/// +/// * `s` - The prompt string to parse. +pub fn parse(s: &str) -> Result, error::WordParseError> { + let result = prompt_parser::prompt(s).map_err(|e| error::WordParseError::Prompt(e.into()))?; + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + use anyhow::Result; + use pretty_assertions::assert_eq; + + #[test] + fn basic_prompt() -> Result<()> { + assert_eq!( + parse(r"\u@\h:\w$ ")?, + &[ + PromptPiece::CurrentUser, + PromptPiece::Literal("@".to_owned()), + PromptPiece::Hostname { + only_up_to_first_dot: true + }, + PromptPiece::Literal(":".to_owned()), + PromptPiece::CurrentWorkingDirectory { + tilde_replaced: true, + basename: false + }, + PromptPiece::Literal("$ ".to_owned()), + ] + ); + + Ok(()) + } + + #[test] + fn brackets_and_vars() -> Result<()> { + assert_eq!( + parse(r"\[$foo\]\u > ")?, + &[ + PromptPiece::StartNonPrintingSequence, + PromptPiece::Literal("$foo".to_owned()), + PromptPiece::EndNonPrintingSequence, + PromptPiece::CurrentUser, + PromptPiece::Literal(" > ".to_owned()), + ] + ); + + Ok(()) + } +} diff --git a/local/recipes/shells/brush/source/brush-parser/src/readline_binding.rs b/local/recipes/shells/brush/source/brush-parser/src/readline_binding.rs new file mode 100644 index 0000000000..57f6658ca1 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/readline_binding.rs @@ -0,0 +1,241 @@ +//! Implements a parser for readline binding syntax. + +use crate::error; + +/// Represents a key-sequence-to-shell-command binding. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub struct KeySequenceShellCommandBinding { + /// Key sequence to bind + pub seq: KeySequence, + /// Shell command to bind to the sequence + pub shell_cmd: String, +} + +/// Represents a key-sequence-to-readline-command binding. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub struct KeySequenceReadlineBinding { + /// Key sequence to bind + pub seq: KeySequence, + /// Readline target to bind to the sequence + pub target: ReadlineTarget, +} + +/// Represents a readline target. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub enum ReadlineTarget { + /// A named readline function. + Function(String), + /// A readline command macro. + Macro(String), +} + +/// Represents a key sequence. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub struct KeySequence(pub Vec); + +/// Represents an element of a key sequence. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub enum KeySequenceItem { + /// Control + Control, + /// Meta + Meta, + /// Regular character + Byte(u8), +} + +/// Represents a single key stroke. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +pub struct KeyStroke { + /// Meta key is held down + pub meta: bool, + /// Control key is held down + pub control: bool, + /// Primary key code + pub key_code: Vec, +} + +/// Parses a key sequence. +/// +/// # Arguments +/// +/// * `input` - The input string to parse +pub fn parse_key_sequence(input: &str) -> Result { + readline_binding::key_sequence(input) + .map_err(|_err| error::BindingParseError::Unknown(input.to_owned())) +} + +/// Parses a binding specification that maps a key sequence +/// to a shell command. +/// +/// # Arguments +/// +/// * `input` - The input string to parse +pub fn parse_key_sequence_shell_cmd_binding( + input: &str, +) -> Result { + readline_binding::key_sequence_shell_cmd_binding(input) + .map_err(|_err| error::BindingParseError::Unknown(input.to_owned())) +} + +/// Parses a binding specification that maps a key sequence +/// to a readline target. +/// +/// # Arguments +/// +/// * `input` - The input string to parse +pub fn parse_key_sequence_readline_binding( + input: &str, +) -> Result { + readline_binding::key_sequence_readline_binding(input) + .map_err(|_err| error::BindingParseError::Unknown(input.to_owned())) +} + +/// Converts a `KeySequence` to a vector of `KeyStroke`. +/// +/// # Arguments +/// +/// * `seq` - The key sequence to convert +pub fn key_sequence_to_strokes( + seq: &KeySequence, +) -> Result, error::BindingParseError> { + let mut strokes = vec![]; + let mut current_stroke = KeyStroke::default(); + + for item in &seq.0 { + if matches!( + item, + KeySequenceItem::Control | KeySequenceItem::Meta | KeySequenceItem::Byte(b'\x1b') + ) && !current_stroke.key_code.is_empty() + { + strokes.push(current_stroke); + current_stroke = KeyStroke::default(); + } + + match item { + KeySequenceItem::Control => current_stroke.control = true, + KeySequenceItem::Meta => current_stroke.meta = true, + KeySequenceItem::Byte(b) => { + current_stroke.key_code.push(*b); + // If this is a control or meta stroke, the modifier only applies to this one byte, + // so we need to push the stroke and start fresh for subsequent bytes. + if current_stroke.control || current_stroke.meta { + strokes.push(current_stroke); + current_stroke = KeyStroke::default(); + } + } + } + } + + if current_stroke.key_code.is_empty() { + if current_stroke.control || current_stroke.meta { + return Err(error::BindingParseError::MissingKeyCode); + } + } else { + strokes.push(current_stroke); + } + + Ok(strokes) +} + +peg::parser! { + grammar readline_binding() for str { + rule _() = [' ' | '\t' | '\n']* + + pub rule key_sequence_shell_cmd_binding() -> KeySequenceShellCommandBinding = + _ "\"" seq:key_sequence() "\"" _ ":" _ cmd:shell_cmd() _ { KeySequenceShellCommandBinding { seq, shell_cmd: cmd } } + + pub rule key_sequence_readline_binding() -> KeySequenceReadlineBinding = + _ "\"" seq:key_sequence() "\"" _ ":" _ "\"" cmd:readline_cmd() "\"" _ { + KeySequenceReadlineBinding { seq, target: ReadlineTarget::Macro(cmd) } + } / + _ "\"" seq:key_sequence() "\"" _ ":" _ func:readline_function() _ { + KeySequenceReadlineBinding { seq, target: ReadlineTarget::Function(func) } + } + + rule readline_cmd() -> String = s:$([^'"']*) { s.to_string() } + rule shell_cmd() -> String = s:$([_]*) { s.to_string() } + rule readline_function() -> String = s:$([_]*) { s.to_string() } + + // Main rule for parsing a key sequence + pub rule key_sequence() -> KeySequence = + items:key_sequence_item()* { KeySequence(items) } + + rule key_sequence_item() -> KeySequenceItem = + "\\C-" { KeySequenceItem::Control } / + "\\M-" { KeySequenceItem::Meta } / + "\\e" { KeySequenceItem::Byte(b'\x1b') } / + "\\\\" { KeySequenceItem::Byte(b'\\') } / + "\\\"" { KeySequenceItem::Byte(b'"') } / + "\\'" { KeySequenceItem::Byte(b'\'') } / + "\\a" { KeySequenceItem::Byte(b'\x07') } / + "\\b" { KeySequenceItem::Byte(b'\x08') } / + "\\d" { KeySequenceItem::Byte(b'\x7f') } / + "\\f" { KeySequenceItem::Byte(b'\x0c') } / + "\\n" { KeySequenceItem::Byte(b'\n') } / + "\\r" { KeySequenceItem::Byte(b'\r') } / + "\\t" { KeySequenceItem::Byte(b'\t') } / + "\\v" { KeySequenceItem::Byte(b'\x0b') } / + "\\" n:octal_number() { KeySequenceItem::Byte(n) } / + "\\" n:hex_number() { KeySequenceItem::Byte(n) } / + [c if c != '"'] { KeySequenceItem::Byte(c as u8) } + + rule octal_number() -> u8 = + s:$(['0'..='7']*<1,3>) {? u8::from_str_radix(s, 8).or(Err("invalid octal number")) } + + rule hex_number() -> u8 = + s:$(['0'..='9' | 'a'..='f' | 'A'..='F']*<1,2>) {? u8::from_str_radix(s, 16).or(Err("invalid hex number")) } + } +} + +#[cfg(test)] +#[expect(clippy::panic_in_result_fn)] +mod tests { + use super::*; + use anyhow::Result; + + #[test] + fn test_basic_shell_cmd_binding_parse() -> Result<()> { + let binding = parse_key_sequence_shell_cmd_binding(r#""\C-k": xyz"#)?; + assert_eq!( + binding.seq.0, + [KeySequenceItem::Control, KeySequenceItem::Byte(b'k')] + ); + assert_eq!(binding.shell_cmd, "xyz"); + + Ok(()) + } + + #[test] + fn test_basic_readline_func_binding_parse() -> Result<()> { + let binding = parse_key_sequence_readline_binding(r#""\M-x": some-function"#)?; + assert_eq!( + binding.seq.0, + [KeySequenceItem::Meta, KeySequenceItem::Byte(b'x')] + ); + assert_eq!( + binding.target, + ReadlineTarget::Function("some-function".to_string()) + ); + + Ok(()) + } + + #[test] + fn test_basic_readline_cmd_binding_parse() -> Result<()> { + let binding = parse_key_sequence_readline_binding(r#""\C-k": "xyz""#)?; + assert_eq!( + binding.seq.0, + [KeySequenceItem::Control, KeySequenceItem::Byte(b'k')] + ); + assert_eq!(binding.target, ReadlineTarget::Macro(String::from("xyz"))); + + Ok(()) + } +} diff --git a/local/recipes/shells/brush/source/brush-parser/src/snapshot_tests.rs b/local/recipes/shells/brush/source/brush-parser/src/snapshot_tests.rs new file mode 100644 index 0000000000..f8d76201c8 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/snapshot_tests.rs @@ -0,0 +1,124 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::path::Path; + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct TestCaseSet { + /// Name of the test case set + pub name: Option, + /// Set of test cases + pub cases: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct TestCase { + /// Name of the test case + pub name: Option, + #[serde(default)] + pub stdin: Option, +} + +#[test] +#[ignore = "not yet ready for default-enablement"] +fn test_parser_using_yaml_test_cases() { + insta::glob!("../../brush-shell", "tests/cases/**/*.yaml", |path| { + test_parser_using_yaml(path).unwrap(); + }); +} + +fn test_parser_using_yaml(path: &Path) -> Result<()> { + let yaml_file = std::fs::File::open(path)?; + let test_case_set: TestCaseSet = serde_yaml::from_reader(yaml_file) + .context(format!("parsing {}", path.to_string_lossy()))?; + + test_parser_using_test_case_set(&test_case_set); + + Ok(()) +} + +fn test_parser_using_test_case_set(test_case_set: &TestCaseSet) { + let name = test_case_set.name.as_deref().unwrap_or_default(); + + for test_case in &test_case_set.cases { + parse(name, test_case); + } +} + +// NOTE: The name of this function affects the name of the snapshot generated. +fn parse(test_case_set_name: &str, test_case: &TestCase) { + #[derive(serde::Serialize, serde::Deserialize)] + struct TestCaseInfo { + test_case_set: String, + test_case: String, + } + + let name = test_case.name.as_deref().unwrap_or_default(); + let script_content = test_case.stdin.as_deref().unwrap_or_default(); + + if script_content.is_empty() { + return; + } + + let summary = parse_script_content(script_content); + + let info = TestCaseInfo { + test_case_set: test_case_set_name.to_string(), + test_case: name.to_string(), + }; + + // Generate a cleaned-up name. + let snapshot_suffix = std::format!("{test_case_set_name}-{name}") + .to_lowercase() + .replace(' ', "_") + .replace(|c: char| !c.is_ascii_alphanumeric(), "_"); + + insta::with_settings!({ + info => &info, + prepend_module_to_snapshot => false, + omit_expression => true, + snapshot_suffix => snapshot_suffix, + }, { + insta::assert_ron_snapshot!(summary); + }); +} + +#[cfg_attr(test, derive(serde::Serialize))] +struct ParseSummary<'a> { + input: Vec<&'a str>, + result: ParseResult, +} + +#[cfg_attr(test, derive(serde::Serialize, serde::Deserialize))] +enum ParseResult { + Success(crate::ast::Program), + Failure(String), +} + +fn parse_script_content(s: &str) -> ParseSummary<'_> { + let input_lines: Vec<_> = s.lines().collect(); + + let tokens = match crate::tokenize_str_with_options(s, &crate::TokenizerOptions::default()) { + Ok(tokens) => tokens, + Err(err) => { + return ParseSummary { + input: input_lines, + result: ParseResult::Failure(err.to_string()), + }; + } + }; + + let parsed_program = match crate::parse_tokens(&tokens, &crate::ParserOptions::default()) { + Ok(parsed_program) => parsed_program, + Err(err) => { + return ParseSummary { + input: input_lines, + result: ParseResult::Failure(err.to_string()), + }; + } + }; + + ParseSummary { + input: input_lines, + result: ParseResult::Success(parsed_program), + } +} diff --git a/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_arithmetic_expression.snap b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_arithmetic_expression.snap new file mode 100644 index 0000000000..0d82351983 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_arithmetic_expression.snap @@ -0,0 +1,33 @@ +--- +source: brush-parser/src/tokenizer.rs +expression: "test_tokenizer(\"a$((1+2))b c\")?" +--- +TokenizerResult( + input: "a$((1+2))b c", + result: [ + Word("a$((1+2))b", SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 10, + line: 1, + column: 11, + ), + )), + Word("c", SourceSpan( + start: SourcePosition( + index: 11, + line: 1, + column: 12, + ), + end: SourcePosition( + index: 12, + line: 1, + column: 13, + ), + )), + ], +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_arithmetic_expression_with_parens.snap b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_arithmetic_expression_with_parens.snap new file mode 100644 index 0000000000..e19b70bf03 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_arithmetic_expression_with_parens.snap @@ -0,0 +1,21 @@ +--- +source: brush-parser/src/tokenizer.rs +expression: "test_tokenizer(\"$(( (0) ))\")?" +--- +TokenizerResult( + input: "$(( (0) ))", + result: [ + Word("$(( (0) ))", SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 10, + line: 1, + column: 11, + ), + )), + ], +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_arithmetic_expression_with_space.snap b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_arithmetic_expression_with_space.snap new file mode 100644 index 0000000000..686294cb9f --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_arithmetic_expression_with_space.snap @@ -0,0 +1,21 @@ +--- +source: brush-parser/src/tokenizer.rs +expression: "test_tokenizer(\"$(( 1 ))\")?" +--- +TokenizerResult( + input: "$(( 1 ))", + result: [ + Word("$(( 1 ))", SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 8, + line: 1, + column: 9, + ), + )), + ], +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_backquote_with_escape.snap b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_backquote_with_escape.snap new file mode 100644 index 0000000000..64e861c8a5 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_backquote_with_escape.snap @@ -0,0 +1,33 @@ +--- +source: brush-parser/src/tokenizer.rs +expression: "test_tokenizer(r\"echo `echo\\`hi`\")?" +--- +TokenizerResult( + input: "echo `echo\\`hi`", + result: [ + Word("echo", SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 4, + line: 1, + column: 5, + ), + )), + Word("`echo\\`hi`", SourceSpan( + start: SourcePosition( + index: 5, + line: 1, + column: 6, + ), + end: SourcePosition( + index: 15, + line: 1, + column: 16, + ), + )), + ], +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_braced_parameter_expansion-2.snap b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_braced_parameter_expansion-2.snap new file mode 100644 index 0000000000..4d5765eaec --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_braced_parameter_expansion-2.snap @@ -0,0 +1,21 @@ +--- +source: brush-parser/src/tokenizer.rs +expression: "test_tokenizer(\"a${x}b\")?" +--- +TokenizerResult( + input: "a${x}b", + result: [ + Word("a${x}b", SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 6, + line: 1, + column: 7, + ), + )), + ], +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_braced_parameter_expansion.snap b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_braced_parameter_expansion.snap new file mode 100644 index 0000000000..d351246d47 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_braced_parameter_expansion.snap @@ -0,0 +1,21 @@ +--- +source: brush-parser/src/tokenizer.rs +expression: "test_tokenizer(\"${x}\")?" +--- +TokenizerResult( + input: "${x}", + result: [ + Word("${x}", SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 4, + line: 1, + column: 5, + ), + )), + ], +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_braced_parameter_expansion_with_escaping.snap b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_braced_parameter_expansion_with_escaping.snap new file mode 100644 index 0000000000..b02f5a710e --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_braced_parameter_expansion_with_escaping.snap @@ -0,0 +1,21 @@ +--- +source: brush-parser/src/tokenizer.rs +expression: "test_tokenizer(r\"a${x\\}}b\")?" +--- +TokenizerResult( + input: "a${x\\}}b", + result: [ + Word("a${x\\}}b", SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 8, + line: 1, + column: 9, + ), + )), + ], +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_command_substitution.snap b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_command_substitution.snap new file mode 100644 index 0000000000..cccd129c2f --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_command_substitution.snap @@ -0,0 +1,33 @@ +--- +source: brush-parser/src/tokenizer.rs +expression: "test_tokenizer(\"a$(echo hi)b c\")?" +--- +TokenizerResult( + input: "a$(echo hi)b c", + result: [ + Word("a$(echo hi)b", SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 12, + line: 1, + column: 13, + ), + )), + Word("c", SourceSpan( + start: SourcePosition( + index: 13, + line: 1, + column: 14, + ), + end: SourcePosition( + index: 14, + line: 1, + column: 15, + ), + )), + ], +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_command_substitution_containing_extglob.snap b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_command_substitution_containing_extglob.snap new file mode 100644 index 0000000000..df02257c0b --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_command_substitution_containing_extglob.snap @@ -0,0 +1,33 @@ +--- +source: brush-parser/src/tokenizer.rs +expression: "test_tokenizer(\"echo $(echo !(x))\")?" +--- +TokenizerResult( + input: "echo $(echo !(x))", + result: [ + Word("echo", SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 4, + line: 1, + column: 5, + ), + )), + Word("$(echo !(x))", SourceSpan( + start: SourcePosition( + index: 5, + line: 1, + column: 6, + ), + end: SourcePosition( + index: 17, + line: 1, + column: 18, + ), + )), + ], +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_command_substitution_with_subshell.snap b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_command_substitution_with_subshell.snap new file mode 100644 index 0000000000..26f495ab78 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_command_substitution_with_subshell.snap @@ -0,0 +1,21 @@ +--- +source: brush-parser/src/tokenizer.rs +expression: "test_tokenizer(\"$( (:) )\")?" +--- +TokenizerResult( + input: "$( (:) )", + result: [ + Word("$( (:) )", SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 8, + line: 1, + column: 9, + ), + )), + ], +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_comment.snap b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_comment.snap new file mode 100644 index 0000000000..a2ade98d18 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_comment.snap @@ -0,0 +1,33 @@ +--- +source: brush-parser/src/tokenizer.rs +expression: "test_tokenizer(r\"a #comment\n\")?" +--- +TokenizerResult( + input: "a #comment\n", + result: [ + Word("a", SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 1, + line: 1, + column: 2, + ), + )), + Operator("\n", SourceSpan( + start: SourcePosition( + index: 2, + line: 1, + column: 3, + ), + end: SourcePosition( + index: 11, + line: 2, + column: 1, + ), + )), + ], +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_comment_at_eof.snap b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_comment_at_eof.snap new file mode 100644 index 0000000000..82f249d2f3 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_comment_at_eof.snap @@ -0,0 +1,21 @@ +--- +source: brush-parser/src/tokenizer.rs +expression: "test_tokenizer(r\"a #comment\")?" +--- +TokenizerResult( + input: "a #comment", + result: [ + Word("a", SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 1, + line: 1, + column: 2, + ), + )), + ], +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_complex_here_docs_in_command_substitution.snap b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_complex_here_docs_in_command_substitution.snap new file mode 100644 index 0000000000..5507f7f515 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_complex_here_docs_in_command_substitution.snap @@ -0,0 +1,33 @@ +--- +source: brush-parser/src/tokenizer.rs +expression: "test_tokenizer(r\"echo $(cat <>b\")?" +--- +TokenizerResult( + input: "a>>b", + result: [ + Word("a", SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 1, + line: 1, + column: 2, + ), + )), + Operator(">>", SourceSpan( + start: SourcePosition( + index: 1, + line: 1, + column: 2, + ), + end: SourcePosition( + index: 3, + line: 1, + column: 4, + ), + )), + Word("b", SourceSpan( + start: SourcePosition( + index: 3, + line: 1, + column: 4, + ), + end: SourcePosition( + index: 4, + line: 1, + column: 5, + ), + )), + ], +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_simple_backquote.snap b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_simple_backquote.snap new file mode 100644 index 0000000000..f29c9ef648 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_simple_backquote.snap @@ -0,0 +1,33 @@ +--- +source: brush-parser/src/tokenizer.rs +expression: "test_tokenizer(r\"echo `echo hi`\")?" +--- +TokenizerResult( + input: "echo `echo hi`", + result: [ + Word("echo", SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 4, + line: 1, + column: 5, + ), + )), + Word("`echo hi`", SourceSpan( + start: SourcePosition( + index: 5, + line: 1, + column: 6, + ), + end: SourcePosition( + index: 14, + line: 1, + column: 15, + ), + )), + ], +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_single_quote.snap b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_single_quote.snap new file mode 100644 index 0000000000..e9c397ceb7 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_single_quote.snap @@ -0,0 +1,21 @@ +--- +source: brush-parser/src/tokenizer.rs +expression: "test_tokenizer(r\"x'a b'y\")?" +--- +TokenizerResult( + input: "x\'a b\'y", + result: [ + Word("x\'a b\'y", SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 7, + line: 1, + column: 8, + ), + )), + ], +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_special_parameters-2.snap b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_special_parameters-2.snap new file mode 100644 index 0000000000..0fc4868e56 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_special_parameters-2.snap @@ -0,0 +1,21 @@ +--- +source: brush-parser/src/tokenizer.rs +expression: "test_tokenizer(\"$@\")?" +--- +TokenizerResult( + input: "$@", + result: [ + Word("$@", SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 2, + line: 1, + column: 3, + ), + )), + ], +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_special_parameters-3.snap b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_special_parameters-3.snap new file mode 100644 index 0000000000..d2b636b018 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_special_parameters-3.snap @@ -0,0 +1,21 @@ +--- +source: brush-parser/src/tokenizer.rs +expression: "test_tokenizer(\"$!\")?" +--- +TokenizerResult( + input: "$!", + result: [ + Word("$!", SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 2, + line: 1, + column: 3, + ), + )), + ], +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_special_parameters-4.snap b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_special_parameters-4.snap new file mode 100644 index 0000000000..01d525cfb6 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_special_parameters-4.snap @@ -0,0 +1,21 @@ +--- +source: brush-parser/src/tokenizer.rs +expression: "test_tokenizer(\"$?\")?" +--- +TokenizerResult( + input: "$?", + result: [ + Word("$?", SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 2, + line: 1, + column: 3, + ), + )), + ], +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_special_parameters-5.snap b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_special_parameters-5.snap new file mode 100644 index 0000000000..f2d7e2b3d8 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_special_parameters-5.snap @@ -0,0 +1,21 @@ +--- +source: brush-parser/src/tokenizer.rs +expression: "test_tokenizer(\"$*\")?" +--- +TokenizerResult( + input: "$*", + result: [ + Word("$*", SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 2, + line: 1, + column: 3, + ), + )), + ], +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_special_parameters.snap b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_special_parameters.snap new file mode 100644 index 0000000000..7849617623 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_special_parameters.snap @@ -0,0 +1,21 @@ +--- +source: brush-parser/src/tokenizer.rs +expression: "test_tokenizer(\"$$\")?" +--- +TokenizerResult( + input: "$$", + result: [ + Word("$$", SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 2, + line: 1, + column: 3, + ), + )), + ], +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_unbraced_parameter_expansion-2.snap b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_unbraced_parameter_expansion-2.snap new file mode 100644 index 0000000000..41e88816a4 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_unbraced_parameter_expansion-2.snap @@ -0,0 +1,21 @@ +--- +source: brush-parser/src/tokenizer.rs +expression: "test_tokenizer(\"a$x\")?" +--- +TokenizerResult( + input: "a$x", + result: [ + Word("a$x", SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 3, + line: 1, + column: 4, + ), + )), + ], +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_unbraced_parameter_expansion.snap b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_unbraced_parameter_expansion.snap new file mode 100644 index 0000000000..d70450824a --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_unbraced_parameter_expansion.snap @@ -0,0 +1,21 @@ +--- +source: brush-parser/src/tokenizer.rs +expression: "test_tokenizer(\"$x\")?" +--- +TokenizerResult( + input: "$x", + result: [ + Word("$x", SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 2, + line: 1, + column: 3, + ), + )), + ], +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_whitespace.snap b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_whitespace.snap new file mode 100644 index 0000000000..5a39c2ecdc --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__tokenizer__tests__tokenize_whitespace.snap @@ -0,0 +1,45 @@ +--- +source: brush-parser/src/tokenizer.rs +expression: "test_tokenizer(\"1 2 3\")?" +--- +TokenizerResult( + input: "1 2 3", + result: [ + Word("1", SourceSpan( + start: SourcePosition( + index: 0, + line: 1, + column: 1, + ), + end: SourcePosition( + index: 1, + line: 1, + column: 2, + ), + )), + Word("2", SourceSpan( + start: SourcePosition( + index: 2, + line: 1, + column: 3, + ), + end: SourcePosition( + index: 3, + line: 1, + column: 4, + ), + )), + Word("3", SourceSpan( + start: SourcePosition( + index: 4, + line: 1, + column: 5, + ), + end: SourcePosition( + index: 5, + line: 1, + column: 6, + ), + )), + ], +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__brace_expansion_parsing-2.snap b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__brace_expansion_parsing-2.snap new file mode 100644 index 0000000000..04e682b6f6 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__brace_expansion_parsing-2.snap @@ -0,0 +1,22 @@ +--- +source: brush-parser/src/word.rs +expression: "super::parse_brace_expansions(input,\n&options)?.ok_or_else(||\nanyhow::anyhow!(\"Expected brace expansion to be parsed successfully\"))?" +--- +[ + Expr([ + Child([ + Text("a"), + ]), + Child([ + Text("b"), + Expr([ + Child([ + Text("1"), + ]), + Child([ + Text("2"), + ]), + ]), + ]), + ]), +] diff --git a/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__brace_expansion_parsing.snap b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__brace_expansion_parsing.snap new file mode 100644 index 0000000000..7da13fa1ca --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__brace_expansion_parsing.snap @@ -0,0 +1,16 @@ +--- +source: brush-parser/src/word.rs +expression: "super::parse_brace_expansions(input,\n&options)?.ok_or_else(||\nanyhow::anyhow!(\"Expected brace expansion to be parsed successfully\"))?" +--- +[ + Text("x"), + Expr([ + Child([ + Text("a"), + ]), + Child([ + Text("b"), + ]), + ]), + Text("y"), +] diff --git a/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_ansi_c_quoted_escape_seq.snap b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_ansi_c_quoted_escape_seq.snap new file mode 100644 index 0000000000..00ccbf94ad --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_ansi_c_quoted_escape_seq.snap @@ -0,0 +1,14 @@ +--- +source: brush-parser/src/word.rs +expression: "test_parse(r\"$'\\\\'\")?" +--- +ParseTestResults( + input: "$\'\\\\\'", + result: [ + WordPieceWithSource( + piece: AnsiCQuotedText("\\\\"), + start_index: 0, + end_index: 5, + ), + ], +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_ansi_c_quoted_text.snap b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_ansi_c_quoted_text.snap new file mode 100644 index 0000000000..0082b3170c --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_ansi_c_quoted_text.snap @@ -0,0 +1,14 @@ +--- +source: brush-parser/src/word.rs +expression: "test_parse(r\"$'hi\\nthere\\t'\")?" +--- +ParseTestResults( + input: "$\'hi\\nthere\\t\'", + result: [ + WordPieceWithSource( + piece: AnsiCQuotedText("hi\\nthere\\t"), + start_index: 0, + end_index: 14, + ), + ], +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_arithmetic_expansion.snap b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_arithmetic_expansion.snap new file mode 100644 index 0000000000..89f7bfc9dc --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_arithmetic_expansion.snap @@ -0,0 +1,16 @@ +--- +source: brush-parser/src/word.rs +expression: "test_parse(\"$((0))\")?" +--- +ParseTestResults( + input: "$((0))", + result: [ + WordPieceWithSource( + piece: ArithmeticExpression(UnexpandedArithmeticExpr( + value: "0", + )), + start_index: 0, + end_index: 6, + ), + ], +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_arithmetic_expansion_with_parens.snap b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_arithmetic_expansion_with_parens.snap new file mode 100644 index 0000000000..dbc2077f46 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_arithmetic_expansion_with_parens.snap @@ -0,0 +1,16 @@ +--- +source: brush-parser/src/word.rs +expression: "test_parse(\"$((((1+2)*3)))\")?" +--- +ParseTestResults( + input: "$((((1+2)*3)))", + result: [ + WordPieceWithSource( + piece: ArithmeticExpression(UnexpandedArithmeticExpr( + value: "((1+2)*3)", + )), + start_index: 0, + end_index: 14, + ), + ], +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_backquoted_command.snap b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_backquoted_command.snap new file mode 100644 index 0000000000..7efb821b57 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_backquoted_command.snap @@ -0,0 +1,14 @@ +--- +source: brush-parser/src/word.rs +expression: "test_parse(\"`echo hi`\")?" +--- +ParseTestResults( + input: "`echo hi`", + result: [ + WordPieceWithSource( + piece: BackquotedCommandSubstitution("echo hi"), + start_index: 0, + end_index: 9, + ), + ], +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_backquoted_command_in_double_quotes.snap b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_backquoted_command_in_double_quotes.snap new file mode 100644 index 0000000000..24152e9890 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_backquoted_command_in_double_quotes.snap @@ -0,0 +1,20 @@ +--- +source: brush-parser/src/word.rs +expression: "test_parse(r#\"\"`echo hi`\"\"#)?" +--- +ParseTestResults( + input: "\"`echo hi`\"", + result: [ + WordPieceWithSource( + piece: DoubleQuotedSequence([ + WordPieceWithSource( + piece: BackquotedCommandSubstitution("echo hi"), + start_index: 1, + end_index: 10, + ), + ]), + start_index: 0, + end_index: 11, + ), + ], +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_sub_with_balanced_backticks.snap b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_sub_with_balanced_backticks.snap new file mode 100644 index 0000000000..bf70e03492 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_sub_with_balanced_backticks.snap @@ -0,0 +1,20 @@ +--- +source: brush-parser/src/word.rs +expression: "test_parse(\"\\\"$(cat <<'EOF'\\n`hello`\\nEOF\\n)\\\"\")?" +--- +ParseTestResults( + input: "\"$(cat <<\'EOF\'\n`hello`\nEOF\n)\"", + result: [ + WordPieceWithSource( + piece: DoubleQuotedSequence([ + WordPieceWithSource( + piece: CommandSubstitution("cat <<\'EOF\'\n`hello`\nEOF\n"), + start_index: 1, + end_index: 28, + ), + ]), + start_index: 0, + end_index: 29, + ), + ], +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_sub_with_balanced_double_quotes.snap b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_sub_with_balanced_double_quotes.snap new file mode 100644 index 0000000000..b80d92581b --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_sub_with_balanced_double_quotes.snap @@ -0,0 +1,20 @@ +--- +source: brush-parser/src/word.rs +expression: "test_parse(\"\\\"$(cat <<'EOF'\\n\\\"hello\\\"\\nEOF\\n)\\\"\")?" +--- +ParseTestResults( + input: "\"$(cat <<\'EOF\'\n\"hello\"\nEOF\n)\"", + result: [ + WordPieceWithSource( + piece: DoubleQuotedSequence([ + WordPieceWithSource( + piece: CommandSubstitution("cat <<\'EOF\'\n\"hello\"\nEOF\n"), + start_index: 1, + end_index: 28, + ), + ]), + start_index: 0, + end_index: 29, + ), + ], +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_sub_with_balanced_single_quotes.snap b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_sub_with_balanced_single_quotes.snap new file mode 100644 index 0000000000..aa5cb5e48c --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_sub_with_balanced_single_quotes.snap @@ -0,0 +1,20 @@ +--- +source: brush-parser/src/word.rs +expression: "test_parse(\"\\\"$(cat <<'EOF'\\n'hello'\\nEOF\\n)\\\"\")?" +--- +ParseTestResults( + input: "\"$(cat <<\'EOF\'\n\'hello\'\nEOF\n)\"", + result: [ + WordPieceWithSource( + piece: DoubleQuotedSequence([ + WordPieceWithSource( + piece: CommandSubstitution("cat <<\'EOF\'\n\'hello\'\nEOF\n"), + start_index: 1, + end_index: 28, + ), + ]), + start_index: 0, + end_index: 29, + ), + ], +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_sub_with_unbalanced_backtick.snap b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_sub_with_unbalanced_backtick.snap new file mode 100644 index 0000000000..2228ee983c --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_sub_with_unbalanced_backtick.snap @@ -0,0 +1,20 @@ +--- +source: brush-parser/src/word.rs +expression: "test_parse(\"\\\"$(cat <<'EOF'\\na ` b\\nEOF\\n)\\\"\")?" +--- +ParseTestResults( + input: "\"$(cat <<\'EOF\'\na ` b\nEOF\n)\"", + result: [ + WordPieceWithSource( + piece: DoubleQuotedSequence([ + WordPieceWithSource( + piece: CommandSubstitution("cat <<\'EOF\'\na ` b\nEOF\n"), + start_index: 1, + end_index: 26, + ), + ]), + start_index: 0, + end_index: 27, + ), + ], +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_sub_with_unbalanced_single_quote.snap b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_sub_with_unbalanced_single_quote.snap new file mode 100644 index 0000000000..621b5998f9 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_sub_with_unbalanced_single_quote.snap @@ -0,0 +1,20 @@ +--- +source: brush-parser/src/word.rs +expression: "test_parse(\"\\\"$(cat <<'EOF'\\nit's here\\nEOF\\n)\\\"\")?" +--- +ParseTestResults( + input: "\"$(cat <<\'EOF\'\nit\'s here\nEOF\n)\"", + result: [ + WordPieceWithSource( + piece: DoubleQuotedSequence([ + WordPieceWithSource( + piece: CommandSubstitution("cat <<\'EOF\'\nit\'s here\nEOF\n"), + start_index: 1, + end_index: 30, + ), + ]), + start_index: 0, + end_index: 31, + ), + ], +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_substitution.snap b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_substitution.snap new file mode 100644 index 0000000000..6b66ed7aa9 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_substitution.snap @@ -0,0 +1,14 @@ +--- +source: brush-parser/src/word.rs +expression: "test_parse(\"$(echo hi)\")?" +--- +ParseTestResults( + input: "$(echo hi)", + result: [ + WordPieceWithSource( + piece: CommandSubstitution("echo hi"), + start_index: 0, + end_index: 10, + ), + ], +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_substitution_with_embedded_extglob.snap b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_substitution_with_embedded_extglob.snap new file mode 100644 index 0000000000..2f52d2ff8a --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_substitution_with_embedded_extglob.snap @@ -0,0 +1,14 @@ +--- +source: brush-parser/src/word.rs +expression: "test_parse(\"$(echo !(x))\")?" +--- +ParseTestResults( + input: "$(echo !(x))", + result: [ + WordPieceWithSource( + piece: CommandSubstitution("echo !(x)"), + start_index: 0, + end_index: 12, + ), + ], +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_substitution_with_embedded_quotes.snap b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_substitution_with_embedded_quotes.snap new file mode 100644 index 0000000000..4601f7a593 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_command_substitution_with_embedded_quotes.snap @@ -0,0 +1,14 @@ +--- +source: brush-parser/src/word.rs +expression: "test_parse(r#\"$(echo \"hi\")\"#)?" +--- +ParseTestResults( + input: "$(echo \"hi\")", + result: [ + WordPieceWithSource( + piece: CommandSubstitution("echo \"hi\""), + start_index: 0, + end_index: 12, + ), + ], +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_double_quoted_text.snap b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_double_quoted_text.snap new file mode 100644 index 0000000000..e9a846b1cd --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_double_quoted_text.snap @@ -0,0 +1,33 @@ +--- +source: brush-parser/src/word.rs +expression: "test_parse(r#\"\"a ${b} c\"\"#)?" +--- +ParseTestResults( + input: "\"a ${b} c\"", + result: [ + WordPieceWithSource( + piece: DoubleQuotedSequence([ + WordPieceWithSource( + piece: Text("a "), + start_index: 1, + end_index: 3, + ), + WordPieceWithSource( + piece: ParameterExpansion(Parameter( + parameter: Named("b"), + indirect: false, + )), + start_index: 3, + end_index: 7, + ), + WordPieceWithSource( + piece: Text(" c"), + start_index: 7, + end_index: 9, + ), + ]), + start_index: 0, + end_index: 10, + ), + ], +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_extglob_with_embedded_parameter.snap b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_extglob_with_embedded_parameter.snap new file mode 100644 index 0000000000..d7c271d359 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_extglob_with_embedded_parameter.snap @@ -0,0 +1,27 @@ +--- +source: brush-parser/src/word.rs +expression: "test_parse(\"+([$var])\")?" +--- +ParseTestResults( + input: "+([$var])", + result: [ + WordPieceWithSource( + piece: Text("+(["), + start_index: 0, + end_index: 3, + ), + WordPieceWithSource( + piece: ParameterExpansion(Parameter( + parameter: Named("var"), + indirect: false, + )), + start_index: 3, + end_index: 7, + ), + WordPieceWithSource( + piece: Text("])"), + start_index: 7, + end_index: 9, + ), + ], +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_gettext_double_quoted_text.snap b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_gettext_double_quoted_text.snap new file mode 100644 index 0000000000..0d3f6ce0e4 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/snapshots/brush_parser__word__tests__parse_gettext_double_quoted_text.snap @@ -0,0 +1,33 @@ +--- +source: brush-parser/src/word.rs +expression: "test_parse(r#\"$\"a ${b} c\"\"#)?" +--- +ParseTestResults( + input: "$\"a ${b} c\"", + result: [ + WordPieceWithSource( + piece: GettextDoubleQuotedSequence([ + WordPieceWithSource( + piece: Text("a "), + start_index: 2, + end_index: 4, + ), + WordPieceWithSource( + piece: ParameterExpansion(Parameter( + parameter: Named("b"), + indirect: false, + )), + start_index: 4, + end_index: 8, + ), + WordPieceWithSource( + piece: Text(" c"), + start_index: 8, + end_index: 10, + ), + ]), + start_index: 0, + end_index: 11, + ), + ], +) diff --git a/local/recipes/shells/brush/source/brush-parser/src/source.rs b/local/recipes/shells/brush/source/brush-parser/src/source.rs new file mode 100644 index 0000000000..f6279f43e4 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/source.rs @@ -0,0 +1,94 @@ +use std::{fmt::Display, sync::Arc}; + +/// Represents a position in source text. +#[derive(Clone, Default, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct SourcePosition { + /// The 0-based index of the character in the input stream. + pub index: usize, + /// The 1-based line number. + pub line: usize, + /// The 1-based column number. + pub column: usize, +} + +impl Display for SourcePosition { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_fmt(format_args!("{},{}", self.line, self.column)) + } +} + +impl SourcePosition { + /// Returns a new `SourcePosition` offset by the given `SourcePositionOffset`. + /// + /// # Arguments + /// + /// * `offset` - The offset to apply. + #[must_use] + pub const fn offset(&self, offset: &SourcePositionOffset) -> Self { + Self { + index: self.index + offset.index, + line: self.line + offset.line, + column: if offset.line == 0 { + self.column + offset.column + } else { + offset.column + 1 + }, + } + } +} + +#[cfg(feature = "diagnostics")] +impl From<&SourcePosition> for miette::SourceOffset { + #[allow(clippy::cast_sign_loss)] + fn from(position: &SourcePosition) -> Self { + position.index.into() + } +} + +/// Represents an offset in source text. +#[derive(Clone, Default, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct SourcePositionOffset { + /// The 0-based character offset. + pub index: usize, + /// The 0-based line offset. + pub line: usize, + /// The 0-based column offset. + pub column: usize, +} + +/// Represents a span within source text. +#[derive(Clone, Default, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct SourceSpan { + /// The start position. + pub start: Arc, + /// The end position of the span (exclusive). + pub end: Arc, +} + +impl SourceSpan { + /// Returns the length of the token in characters. + pub fn length(&self) -> usize { + self.end.index - self.start.index + } + pub(crate) fn within(start: &Self, end: &Self) -> Self { + Self { + start: start.start.clone(), + end: end.end.clone(), + } + } +} diff --git a/local/recipes/shells/brush/source/brush-parser/src/test_command.rs b/local/recipes/shells/brush/source/brush-parser/src/test_command.rs new file mode 100644 index 0000000000..fdffc4c45e --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/test_command.rs @@ -0,0 +1,105 @@ +//! Parser for shell test commands. + +use crate::{ast, error}; + +/// Parses a test command expression. +/// +/// # Arguments +/// +/// * `input` - The test command expression to parse, in string form. +pub fn parse>(input: &[S]) -> Result { + let expr = + test_command::full_expression(&input.iter().map(AsRef::as_ref).collect::>())?; + + Ok(expr) +} + +peg::parser! { + grammar test_command<'a>() for [&'a str] { + pub(crate) rule full_expression() -> ast::TestExpr = + end() { ast::TestExpr::False } / + e:one_arg_expr() end() { e } / + e:two_arg_expr() end() { e } / + e:three_arg_expr() end() { e } / + e:four_arg_expr() end() { e } / + expression() + + rule one_arg_expr() -> ast::TestExpr = + [s] { ast::TestExpr::Literal(s.to_owned()) } + + rule two_arg_expr() -> ast::TestExpr = + ["!"] e:one_arg_expr() { ast::TestExpr::Not(Box::from(e)) } / + op:unary_op() [s] { ast::TestExpr::UnaryTest(op, s.to_owned()) } + + rule three_arg_expr() -> ast::TestExpr = + [left] ["-a"] [right] { ast::TestExpr::And(Box::from(ast::TestExpr::Literal(left.to_owned())), Box::from(ast::TestExpr::Literal(right.to_owned()))) } / + [left] ["-o"] [right] { ast::TestExpr::Or(Box::from(ast::TestExpr::Literal(left.to_owned())), Box::from(ast::TestExpr::Literal(right.to_owned()))) } / + [left] op:binary_op() [right] { ast::TestExpr::BinaryTest(op, left.to_owned(), right.to_owned()) } / + ["!"] e:two_arg_expr() { ast::TestExpr::Not(Box::from(e)) } / + ["("] e:one_arg_expr() [")"] { e } + + rule four_arg_expr() -> ast::TestExpr = + ["!"] e:three_arg_expr() { ast::TestExpr::Not(Box::from(e)) } + + rule expression() -> ast::TestExpr = precedence! { + left:(@) ["-a"] right:@ { ast::TestExpr::And(Box::from(left), Box::from(right)) } + left:(@) ["-o"] right:@ { ast::TestExpr::Or(Box::from(left), Box::from(right)) } + -- + ["("] e:expression() [")"] { ast::TestExpr::Parenthesized(Box::from(e)) } + -- + ["!"] e:@ { ast::TestExpr::Not(Box::from(e)) } + -- + [left] op:binary_op() [right] { ast::TestExpr::BinaryTest(op, left.to_owned(), right.to_owned()) } + -- + op:unary_op() [operand] { ast::TestExpr::UnaryTest(op, operand.to_owned()) } + -- + [s] { ast::TestExpr::Literal(s.to_owned()) } + } + + rule unary_op() -> ast::UnaryPredicate = + ["-a"] { ast::UnaryPredicate::FileExists } / + ["-b"] { ast::UnaryPredicate::FileExistsAndIsBlockSpecialFile } / + ["-c"] { ast::UnaryPredicate::FileExistsAndIsCharSpecialFile } / + ["-d"] { ast::UnaryPredicate::FileExistsAndIsDir } / + ["-e"] { ast::UnaryPredicate::FileExists } / + ["-f"] { ast::UnaryPredicate::FileExistsAndIsRegularFile } / + ["-g"] { ast::UnaryPredicate::FileExistsAndIsSetgid } / + ["-h"] { ast::UnaryPredicate::FileExistsAndIsSymlink } / + ["-k"] { ast::UnaryPredicate::FileExistsAndHasStickyBit } / + ["-n"] { ast::UnaryPredicate::StringHasNonZeroLength } / + ["-o"] { ast::UnaryPredicate::ShellOptionEnabled } / + ["-p"] { ast::UnaryPredicate::FileExistsAndIsFifo } / + ["-r"] { ast::UnaryPredicate::FileExistsAndIsReadable } / + ["-s"] { ast::UnaryPredicate::FileExistsAndIsNotZeroLength } / + ["-t"] { ast::UnaryPredicate::FdIsOpenTerminal } / + ["-u"] { ast::UnaryPredicate::FileExistsAndIsSetuid } / + ["-v"] { ast::UnaryPredicate::ShellVariableIsSetAndAssigned } / + ["-w"] { ast::UnaryPredicate::FileExistsAndIsWritable } / + ["-x"] { ast::UnaryPredicate::FileExistsAndIsExecutable } / + ["-z"] { ast::UnaryPredicate::StringHasZeroLength } / + ["-G"] { ast::UnaryPredicate::FileExistsAndOwnedByEffectiveGroupId } / + ["-L"] { ast::UnaryPredicate::FileExistsAndIsSymlink } / + ["-N"] { ast::UnaryPredicate::FileExistsAndModifiedSinceLastRead } / + ["-O"] { ast::UnaryPredicate::FileExistsAndOwnedByEffectiveUserId } / + ["-R"] { ast::UnaryPredicate::ShellVariableIsSetAndNameRef } / + ["-S"] { ast::UnaryPredicate::FileExistsAndIsSocket } + + rule binary_op() -> ast::BinaryPredicate = + ["=="] { ast::BinaryPredicate::StringExactlyMatchesString } / + ["-ef"] { ast::BinaryPredicate::FilesReferToSameDeviceAndInodeNumbers } / + ["-eq"] { ast::BinaryPredicate::ArithmeticEqualTo } / + ["-ge"] { ast::BinaryPredicate::ArithmeticGreaterThanOrEqualTo } / + ["-gt"] { ast::BinaryPredicate::ArithmeticGreaterThan } / + ["-le"] { ast::BinaryPredicate::ArithmeticLessThanOrEqualTo } / + ["-lt"] { ast::BinaryPredicate::ArithmeticLessThan } / + ["-ne"] { ast::BinaryPredicate::ArithmeticNotEqualTo } / + ["-nt"] { ast::BinaryPredicate::LeftFileIsNewerOrExistsWhenRightDoesNot } / + ["-ot"] { ast::BinaryPredicate::LeftFileIsOlderOrDoesNotExistWhenRightDoes } / + ["="] { ast::BinaryPredicate::StringExactlyMatchesString } / + ["!="] { ast::BinaryPredicate::StringDoesNotExactlyMatchString } / + ["<"] { ast::BinaryPredicate::LeftSortsBeforeRight } / + [">"] { ast::BinaryPredicate::LeftSortsAfterRight } + + rule end() = ![_] + } +} diff --git a/local/recipes/shells/brush/source/brush-parser/src/tokenizer.rs b/local/recipes/shells/brush/source/brush-parser/src/tokenizer.rs new file mode 100644 index 0000000000..ceee9e3642 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/tokenizer.rs @@ -0,0 +1,1706 @@ +use std::borrow::Cow; +use std::sync::Arc; +use utf8_chars::BufReadCharsExt; + +use crate::{SourcePosition, SourceSpan}; + +#[derive(Clone, Debug)] +pub(crate) enum TokenEndReason { + /// End of input was reached. + EndOfInput, + /// An unescaped newline char was reached. + UnescapedNewLine, + /// Specified terminating char. + SpecifiedTerminatingChar, + /// A non-newline blank char was reached. + NonNewLineBlank, + /// A here-document's body is starting. + HereDocumentBodyStart, + /// A here-document's body was terminated. + HereDocumentBodyEnd, + /// A here-document's end tag was reached. + HereDocumentEndTag, + /// An operator was started. + OperatorStart, + /// An operator was terminated. + OperatorEnd, + /// Some other condition was reached. + Other, +} + +/// Compatibility alias for `SourceSpan`. +pub type TokenLocation = SourceSpan; + +/// Represents a token extracted from a shell script. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum Token { + /// An operator token. + Operator(String, SourceSpan), + /// A word token. + Word(String, SourceSpan), +} + +impl Token { + /// Returns the string value of the token. + pub fn to_str(&self) -> &str { + match self { + Self::Operator(s, _) => s, + Self::Word(s, _) => s, + } + } + + /// Returns the location of the token in the source script. + pub const fn location(&self) -> &SourceSpan { + match self { + Self::Operator(_, l) => l, + Self::Word(_, l) => l, + } + } +} + +#[cfg(feature = "diagnostics")] +impl From<&Token> for miette::SourceSpan { + fn from(token: &Token) -> Self { + let start = token.location().start.as_ref(); + Self::new(start.into(), token.location().length()) + } +} + +/// Encapsulates the result of tokenizing a shell script. +#[derive(Clone, Debug)] +pub(crate) struct TokenizeResult { + /// Reason for tokenization ending. + pub reason: TokenEndReason, + /// The token that was extracted, if any. + pub token: Option, +} + +/// Represents an error that occurred during tokenization. +#[derive(thiserror::Error, Debug)] +pub enum TokenizerError { + /// An unterminated escape sequence was encountered at the end of the input stream. + #[error("unterminated escape sequence")] + UnterminatedEscapeSequence, + + /// An unterminated single-quoted substring was encountered at the end of the input stream. + #[error("unterminated single quote at {0}")] + UnterminatedSingleQuote(SourcePosition), + + /// An unterminated ANSI C-quoted substring was encountered at the end of the input stream. + #[error("unterminated ANSI C quote at {0}")] + UnterminatedAnsiCQuote(SourcePosition), + + /// An unterminated double-quoted substring was encountered at the end of the input stream. + #[error("unterminated double quote at {0}")] + UnterminatedDoubleQuote(SourcePosition), + + /// An unterminated back-quoted substring was encountered at the end of the input stream. + #[error("unterminated backquote near {0}")] + UnterminatedBackquote(SourcePosition), + + /// An unterminated extended glob (extglob) pattern was encountered at the end of the input + /// stream. + #[error("unterminated extglob near {0}")] + UnterminatedExtendedGlob(SourcePosition), + + /// An unterminated variable expression was encountered at the end of the input stream. + #[error("unterminated variable expression")] + UnterminatedVariable, + + /// An unterminated command substitiion was encountered at the end of the input stream. + #[error("unterminated command substitution")] + UnterminatedCommandSubstitution, + + /// An unterminated arithmetic or other expansion was encountered at the end of the input + /// stream. + #[error("unterminated expansion")] + UnterminatedExpansion, + + /// An error occurred decoding UTF-8 characters in the input stream. + #[error("failed to decode UTF-8 characters")] + FailedDecoding, + + /// An I/O here tag was missing. + #[error("missing here tag for here document body")] + MissingHereTagForDocumentBody, + + /// The indicated I/O here tag was missing. + #[error("missing here tag '{0}'")] + MissingHereTag(String), + + /// An unterminated here document sequence was encountered at the end of the input stream. + #[error("unterminated here document sequence; tag(s) [{0}] found at: [{1}]")] + UnterminatedHereDocuments(String, String), + + /// An I/O error occurred while reading from the input stream. + #[error("failed to read input")] + ReadError(#[from] std::io::Error), +} + +impl TokenizerError { + /// Returns true if the error represents an error that could possibly be due + /// to an incomplete input stream. + pub const fn is_incomplete(&self) -> bool { + matches!( + self, + Self::UnterminatedEscapeSequence + | Self::UnterminatedAnsiCQuote(..) + | Self::UnterminatedSingleQuote(..) + | Self::UnterminatedDoubleQuote(..) + | Self::UnterminatedBackquote(..) + | Self::UnterminatedCommandSubstitution + | Self::UnterminatedExpansion + | Self::UnterminatedVariable + | Self::UnterminatedExtendedGlob(..) + | Self::UnterminatedHereDocuments(..) + ) + } +} + +/// Encapsulates a sequence of tokens. +#[derive(Debug)] +pub(crate) struct Tokens<'a> { + /// Sequence of tokens. + pub tokens: &'a [Token], +} + +#[derive(Clone, Debug)] +enum QuoteMode { + None, + AnsiC(SourcePosition), + Single(SourcePosition), + Double(SourcePosition), +} + +#[derive(Clone, Debug, Default)] +enum HereState { + /// In this state, we are not currently tracking any here-documents. + #[default] + None, + /// In this state, we expect that the next token will be a here tag. + NextTokenIsHereTag { remove_tabs: bool }, + /// In this state, the *current* token is a here tag. + CurrentTokenIsHereTag { + remove_tabs: bool, + operator_token_result: TokenizeResult, + }, + /// In this state, we expect that the *next line* will be the body of + /// a here-document. + NextLineIsHereDoc, + /// In this state, we are in the set of lines that comprise 1 or more + /// consecutive here-document bodies. + InHereDocs, +} + +#[derive(Clone, Debug)] +struct HereTag { + tag: String, + tag_was_escaped_or_quoted: bool, + remove_tabs: bool, + position: SourcePosition, + tokens: Vec, + pending_tokens_after: Vec, +} + +#[derive(Clone, Debug)] +struct CrossTokenParseState { + /// Cursor within the overall token stream; used for error reporting. + cursor: SourcePosition, + /// Current state of parsing here-documents. + here_state: HereState, + /// Ordered queue of here tags for which we're still looking for matching here-document bodies. + current_here_tags: Vec, + /// Tokens already tokenized that should be used first to serve requests for tokens. + queued_tokens: Vec, + /// Are we in an arithmetic expansion? + arithmetic_expansion: bool, +} + +/// Options controlling how the tokenizer operates. +#[derive(Clone, Debug, Hash, Eq, PartialEq)] +pub struct TokenizerOptions { + /// Whether or not to enable extended globbing patterns (extglob). + pub enable_extended_globbing: bool, + /// Whether or not to operate in POSIX compliance mode. + pub posix_mode: bool, + /// Whether or not we're running in SH emulation mode. + pub sh_mode: bool, +} + +impl Default for TokenizerOptions { + fn default() -> Self { + Self { + enable_extended_globbing: true, + posix_mode: false, + sh_mode: false, + } + } +} + +/// A tokenizer for shell scripts. +pub(crate) struct Tokenizer<'a, R: ?Sized + std::io::BufRead> { + char_reader: std::iter::Peekable>, + cross_state: CrossTokenParseState, + options: TokenizerOptions, +} + +/// Encapsulates the current token parsing state. +#[derive(Clone, Debug)] +struct TokenParseState { + pub start_position: SourcePosition, + pub token_so_far: String, + pub token_is_operator: bool, + pub in_escape: bool, + pub quote_mode: QuoteMode, +} + +impl TokenParseState { + pub fn new(start_position: &SourcePosition) -> Self { + Self { + start_position: start_position.to_owned(), + token_so_far: String::new(), + token_is_operator: false, + in_escape: false, + quote_mode: QuoteMode::None, + } + } + + pub fn pop(&mut self, end_position: &SourcePosition) -> Token { + let end = Arc::new(end_position.to_owned()); + let token_location = SourceSpan { + start: Arc::new(std::mem::take(&mut self.start_position)), + end, + }; + + let token = if std::mem::take(&mut self.token_is_operator) { + Token::Operator(std::mem::take(&mut self.token_so_far), token_location) + } else { + Token::Word(std::mem::take(&mut self.token_so_far), token_location) + }; + + end_position.clone_into(&mut self.start_position); + self.in_escape = false; + self.quote_mode = QuoteMode::None; + + token + } + + pub const fn started_token(&self) -> bool { + !self.token_so_far.is_empty() + } + + pub fn append_char(&mut self, c: char) { + self.token_so_far.push(c); + } + + pub fn append_str(&mut self, s: &str) { + self.token_so_far.push_str(s); + } + + pub const fn unquoted(&self) -> bool { + !self.in_escape && matches!(self.quote_mode, QuoteMode::None) + } + + pub fn current_token(&self) -> &str { + &self.token_so_far + } + + pub fn is_specific_operator(&self, operator: &str) -> bool { + self.token_is_operator && self.current_token() == operator + } + + pub const fn in_operator(&self) -> bool { + self.token_is_operator + } + + fn is_newline(&self) -> bool { + self.token_so_far == "\n" + } + + fn replace_with_here_doc(&mut self, s: String) { + self.token_so_far = s; + } + + #[allow(clippy::too_many_lines)] + pub fn delimit_current_token( + &mut self, + reason: TokenEndReason, + cross_token_state: &mut CrossTokenParseState, + ) -> Result, TokenizerError> { + // If we don't have anything in the token, then don't yield an empty string token + // *unless* it's the body of a here document. + if !self.started_token() && !matches!(reason, TokenEndReason::HereDocumentBodyEnd) { + return Ok(Some(TokenizeResult { + reason, + token: None, + })); + } + + // TODO(tokenizer): Make sure the here-tag meets criteria (and isn't a newline). + let current_here_state = std::mem::take(&mut cross_token_state.here_state); + match current_here_state { + HereState::NextTokenIsHereTag { remove_tabs } => { + // Don't yield the operator as a token yet. We need to make sure we collect + // up everything we need for all the here-documents with tags on this line. + let operator_token_result = TokenizeResult { + reason, + token: Some(self.pop(&cross_token_state.cursor)), + }; + + cross_token_state.here_state = HereState::CurrentTokenIsHereTag { + remove_tabs, + operator_token_result, + }; + + return Ok(None); + } + HereState::CurrentTokenIsHereTag { + remove_tabs, + operator_token_result, + } => { + if self.is_newline() { + return Err(TokenizerError::MissingHereTag( + self.current_token().to_owned(), + )); + } + + cross_token_state.here_state = HereState::NextLineIsHereDoc; + + // Include the trailing \n in the here tag so it's easier to check against. + let tag = std::format!("{}\n", self.current_token().trim_ascii_start()); + let tag_was_escaped_or_quoted = tag.contains(is_quoting_char); + + let tag_token_result = TokenizeResult { + reason, + token: Some(self.pop(&cross_token_state.cursor)), + }; + + cross_token_state.current_here_tags.push(HereTag { + tag, + tag_was_escaped_or_quoted, + remove_tabs, + position: cross_token_state.cursor.clone(), + tokens: vec![operator_token_result, tag_token_result], + pending_tokens_after: vec![], + }); + + return Ok(None); + } + HereState::NextLineIsHereDoc => { + if self.is_newline() { + cross_token_state.here_state = HereState::InHereDocs; + } else { + cross_token_state.here_state = HereState::NextLineIsHereDoc; + } + + if let Some(last_here_tag) = cross_token_state.current_here_tags.last_mut() { + let token = self.pop(&cross_token_state.cursor); + let result = TokenizeResult { + reason, + token: Some(token), + }; + + last_here_tag.pending_tokens_after.push(result); + } else { + return Err(TokenizerError::MissingHereTagForDocumentBody); + } + + return Ok(None); + } + HereState::InHereDocs => { + // We hit the end of the current here-document. + let completed_here_tag = cross_token_state.current_here_tags.remove(0); + + // First queue the redirection operator and (start) here-tag. + for here_token in completed_here_tag.tokens { + cross_token_state.queued_tokens.push(here_token); + } + + // Leave a hint that we are about to start a here-document. + cross_token_state.queued_tokens.push(TokenizeResult { + reason: TokenEndReason::HereDocumentBodyStart, + token: None, + }); + + // Then queue the body document we just finished. + cross_token_state.queued_tokens.push(TokenizeResult { + reason, + token: Some(self.pop(&cross_token_state.cursor)), + }); + + // Then queue up the (end) here-tag. + let end_tag = if completed_here_tag.tag_was_escaped_or_quoted { + unquote_str(&completed_here_tag.tag) + } else { + completed_here_tag.tag + }; + self.append_str(end_tag.trim_end_matches('\n')); + cross_token_state.queued_tokens.push(TokenizeResult { + reason: TokenEndReason::HereDocumentEndTag, + token: Some(self.pop(&cross_token_state.cursor)), + }); + + // Now we're ready to queue up any tokens that came between the completed + // here tag and the next here tag (or newline after it if it was the last). + for pending_token in completed_here_tag.pending_tokens_after { + cross_token_state.queued_tokens.push(pending_token); + } + + if cross_token_state.current_here_tags.is_empty() { + cross_token_state.here_state = HereState::None; + } else { + cross_token_state.here_state = HereState::InHereDocs; + } + + return Ok(None); + } + HereState::None => (), + } + + let token = self.pop(&cross_token_state.cursor); + let result = TokenizeResult { + reason, + token: Some(token), + }; + + Ok(Some(result)) + } +} + +/// Break the given input shell script string into tokens, returning the tokens. +/// +/// # Arguments +/// +/// * `input` - The shell script to tokenize. +pub fn tokenize_str(input: &str) -> Result, TokenizerError> { + tokenize_str_with_options(input, &TokenizerOptions::default()) +} + +/// Break the given input shell script string into tokens, returning the tokens. +/// +/// # Arguments +/// +/// * `input` - The shell script to tokenize. +/// * `options` - Options controlling how the tokenizer operates. +pub fn tokenize_str_with_options( + input: &str, + options: &TokenizerOptions, +) -> Result, TokenizerError> { + uncached_tokenize_string(input.to_owned(), options.to_owned()) +} + +#[cached::proc_macro::cached(name = "TOKENIZE_CACHE", size = 64, result = true)] +fn uncached_tokenize_string( + input: String, + options: TokenizerOptions, +) -> Result, TokenizerError> { + uncached_tokenize_str(input.as_str(), &options) +} + +/// Break the given input shell script string into tokens, returning the tokens. +/// No caching is performed. +/// +/// # Arguments +/// +/// * `input` - The shell script to tokenize. +pub fn uncached_tokenize_str( + input: &str, + options: &TokenizerOptions, +) -> Result, TokenizerError> { + let mut reader = std::io::BufReader::new(input.as_bytes()); + let mut tokenizer = crate::tokenizer::Tokenizer::new(&mut reader, options); + + let mut tokens = vec![]; + loop { + match tokenizer.next_token()? { + TokenizeResult { + token: Some(token), .. + } => tokens.push(token), + TokenizeResult { + reason: TokenEndReason::EndOfInput, + .. + } => break, + _ => (), + } + } + + Ok(tokens) +} + +impl<'a, R: ?Sized + std::io::BufRead> Tokenizer<'a, R> { + pub fn new(reader: &'a mut R, options: &TokenizerOptions) -> Self { + Tokenizer { + options: options.clone(), + char_reader: reader.chars().peekable(), + cross_state: CrossTokenParseState { + cursor: SourcePosition { + index: 0, + line: 1, + column: 1, + }, + here_state: HereState::None, + current_here_tags: vec![], + queued_tokens: vec![], + arithmetic_expansion: false, + }, + } + } + + #[expect(clippy::unnecessary_wraps)] + pub fn current_location(&self) -> Option { + Some(self.cross_state.cursor.clone()) + } + + fn next_char(&mut self) -> Result, TokenizerError> { + let c = self + .char_reader + .next() + .transpose() + .map_err(TokenizerError::ReadError)?; + + if let Some(ch) = c { + if ch == '\n' { + self.cross_state.cursor.line += 1; + self.cross_state.cursor.column = 1; + } else { + self.cross_state.cursor.column += 1; + } + self.cross_state.cursor.index += 1; + } + + Ok(c) + } + + fn consume_char(&mut self) -> Result<(), TokenizerError> { + let _ = self.next_char()?; + Ok(()) + } + + fn peek_char(&mut self) -> Result, TokenizerError> { + match self.char_reader.peek() { + Some(result) => match result { + Ok(c) => Ok(Some(*c)), + Err(_) => Err(TokenizerError::FailedDecoding), + }, + None => Ok(None), + } + } + + pub fn next_token(&mut self) -> Result { + self.next_token_until(None, false /* include space? */) + } + + /// Consumes a nested construct (e.g., `$((...))` or `$[...]`), handling nested delimiters + /// and here-documents. + /// + /// # Arguments + /// + /// * `state` - The current token parse state to append characters to. + /// * `terminating_char` - The character that terminates the construct (e.g., `)` or `]`). + /// * `nesting_open` - The character that increases nesting depth when encountered (e.g., `(` or + /// `[`). + /// * `initial_nesting` - The initial nesting count (e.g., 2 for `$((`, 1 for `$[`). + fn consume_nested_construct( + &mut self, + state: &mut TokenParseState, + terminating_char: char, + nesting_open: &str, + mut nesting_count: u32, + ) -> Result<(), TokenizerError> { + let mut pending_here_doc_tokens = vec![]; + let mut drain_here_doc_tokens = false; + + loop { + let cur_token = if drain_here_doc_tokens && !pending_here_doc_tokens.is_empty() { + if pending_here_doc_tokens.len() == 1 { + drain_here_doc_tokens = false; + } + pending_here_doc_tokens.remove(0) + } else { + let cur_token = self.next_token_until(Some(terminating_char), true)?; + + if matches!( + cur_token.reason, + TokenEndReason::HereDocumentBodyStart + | TokenEndReason::HereDocumentBodyEnd + | TokenEndReason::HereDocumentEndTag + ) { + pending_here_doc_tokens.push(cur_token); + continue; + } + cur_token + }; + + if matches!(cur_token.reason, TokenEndReason::UnescapedNewLine) + && !pending_here_doc_tokens.is_empty() + { + pending_here_doc_tokens.push(cur_token); + drain_here_doc_tokens = true; + continue; + } + + if let Some(cur_token_value) = cur_token.token { + state.append_str(cur_token_value.to_str()); + + if matches!(cur_token_value, Token::Operator(o, _) if o == nesting_open) { + nesting_count += 1; + } + } + + match cur_token.reason { + TokenEndReason::HereDocumentBodyStart => { + state.append_char('\n'); + } + TokenEndReason::NonNewLineBlank => state.append_char(' '), + TokenEndReason::SpecifiedTerminatingChar => { + nesting_count -= 1; + if nesting_count == 0 { + break; + } + state.append_char(self.next_char()?.unwrap()); + } + TokenEndReason::EndOfInput => { + return Err(TokenizerError::UnterminatedExpansion); + } + _ => (), + } + } + + state.append_char(self.next_char()?.unwrap()); + Ok(()) + } + + /// Returns the next token from the input stream, optionally stopping early when a specified + /// terminating character is encountered. + /// + /// # Arguments + /// + /// * `terminating_char` - An optional character that, if encountered, will stop the + /// tokenization process and return the token up to that character. + /// * `include_space` - If true, include spaces in the tokenization process. This is not + /// typically the case, but can be helpful when needing to preserve the original source text + /// embedded within a command substitution or similar construct. + #[expect(clippy::cognitive_complexity)] + #[expect(clippy::if_same_then_else)] + #[expect(clippy::panic_in_result_fn)] + #[expect(clippy::too_many_lines)] + #[allow(clippy::unwrap_in_result)] + fn next_token_until( + &mut self, + terminating_char: Option, + include_space: bool, + ) -> Result { + let mut state = TokenParseState::new(&self.cross_state.cursor); + let mut result: Option = None; + + while result.is_none() { + // First satisfy token results from our queue. Once we exhaust the queue then + // we'll look at the input stream. + if !self.cross_state.queued_tokens.is_empty() { + return Ok(self.cross_state.queued_tokens.remove(0)); + } + + let next = self.peek_char()?; + let c = next.unwrap_or('\0'); + + // When we hit the end of the input, then we're done with the current token (if there is + // one). + if next.is_none() { + // TODO(tokenizer): Verify we're not waiting on some terminating character? + // Verify we're out of all quotes. + if state.in_escape { + return Err(TokenizerError::UnterminatedEscapeSequence); + } + match state.quote_mode { + QuoteMode::None => (), + QuoteMode::AnsiC(pos) => { + return Err(TokenizerError::UnterminatedAnsiCQuote(pos)); + } + QuoteMode::Single(pos) => { + return Err(TokenizerError::UnterminatedSingleQuote(pos)); + } + QuoteMode::Double(pos) => { + return Err(TokenizerError::UnterminatedDoubleQuote(pos)); + } + } + + // Verify we're not in a here document. + if !matches!(self.cross_state.here_state, HereState::None) { + if self.remove_here_end_tag(&mut state, &mut result, false)? { + // If we hit end tag without a trailing newline, try to get next token. + continue; + } + + let tag_names = self + .cross_state + .current_here_tags + .iter() + .map(|tag| tag.tag.trim()) + .collect::>() + .join(", "); + let tag_positions = self + .cross_state + .current_here_tags + .iter() + .map(|tag| std::format!("{}", tag.position)) + .collect::>() + .join(", "); + return Err(TokenizerError::UnterminatedHereDocuments( + tag_names, + tag_positions, + )); + } + + result = state + .delimit_current_token(TokenEndReason::EndOfInput, &mut self.cross_state)?; + // + // Handle being in a here document. + // + } else if matches!(self.cross_state.here_state, HereState::InHereDocs) { + // + // For now, just include the character in the current token. We also check + // if there are leading tabs to be removed. + // + if !self.cross_state.current_here_tags.is_empty() + && self.cross_state.current_here_tags[0].remove_tabs + && (!state.started_token() || state.current_token().ends_with('\n')) + && c == '\t' + { + // Consume it but don't include it. + self.consume_char()?; + } else { + self.consume_char()?; + state.append_char(c); + + // See if this was a newline character following the terminating here tag. + if c == '\n' { + self.remove_here_end_tag(&mut state, &mut result, true)?; + } + } + // + // Look for the specially specified terminating char. + // + } else if state.unquoted() && terminating_char == Some(c) { + result = state.delimit_current_token( + TokenEndReason::SpecifiedTerminatingChar, + &mut self.cross_state, + )?; + } else if state.in_operator() { + // + // We're in an operator. See if this character continues an operator, or if it + // must be a separate token (because it wouldn't make a prefix of an operator). + // + + let mut hypothetical_token = state.current_token().to_owned(); + hypothetical_token.push(c); + + if state.unquoted() && self.is_operator(hypothetical_token.as_ref()) { + self.consume_char()?; + state.append_char(c); + } else { + assert!(state.started_token()); + + // + // N.B. If the completed operator indicates a here-document, then keep + // track that the *next* token should be the here-tag. + // + if self.cross_state.arithmetic_expansion { + // + // We're in an arithmetic context; don't consider << and <<- + // special. They're not here-docs, they're either a left-shift + // operator or a left-shift operator followed by a unary + // minus operator. + // + + if state.is_specific_operator(")") && c == ')' { + self.cross_state.arithmetic_expansion = false; + } + } else if state.is_specific_operator("<<") { + self.cross_state.here_state = + HereState::NextTokenIsHereTag { remove_tabs: false }; + } else if state.is_specific_operator("<<-") { + self.cross_state.here_state = + HereState::NextTokenIsHereTag { remove_tabs: true }; + } else if state.is_specific_operator("(") && c == '(' { + self.cross_state.arithmetic_expansion = true; + } + + let reason = if state.current_token() == "\n" { + TokenEndReason::UnescapedNewLine + } else { + TokenEndReason::OperatorEnd + }; + + result = state.delimit_current_token(reason, &mut self.cross_state)?; + } + // + // See if this is a character that changes the current escaping/quoting state. + // + } else if does_char_newly_affect_quoting(&state, c) { + if c == '\\' { + // Consume the backslash ourselves so we can peek past it. + self.consume_char()?; + + if matches!(self.peek_char()?, Some('\n')) { + // Make sure the newline char gets consumed too. + self.consume_char()?; + + // Make sure to include neither the backslash nor the newline character. + } else { + state.in_escape = true; + state.append_char(c); + } + } else if c == '\'' { + if state.token_so_far.ends_with('$') { + state.quote_mode = QuoteMode::AnsiC(self.cross_state.cursor.clone()); + } else { + state.quote_mode = QuoteMode::Single(self.cross_state.cursor.clone()); + } + + self.consume_char()?; + state.append_char(c); + } else if c == '\"' { + state.quote_mode = QuoteMode::Double(self.cross_state.cursor.clone()); + self.consume_char()?; + state.append_char(c); + } + } + // + // Handle end of single-quote, double-quote, or ANSI-C quote. + else if !state.in_escape + && matches!( + state.quote_mode, + QuoteMode::Single(..) | QuoteMode::AnsiC(..) + ) + && c == '\'' + { + state.quote_mode = QuoteMode::None; + self.consume_char()?; + state.append_char(c); + } else if !state.in_escape + && matches!(state.quote_mode, QuoteMode::Double(..)) + && c == '\"' + { + state.quote_mode = QuoteMode::None; + self.consume_char()?; + state.append_char(c); + } + // + // Handle end of escape sequence. + // TODO(tokenizer): Handle double-quote specific escape sequences. + else if state.in_escape { + state.in_escape = false; + self.consume_char()?; + state.append_char(c); + } else if (state.unquoted() + || (matches!(state.quote_mode, QuoteMode::Double(_)) && !state.in_escape)) + && (c == '$' || c == '`') + { + // TODO(tokenizer): handle quoted $ or ` in a double quote + if c == '$' { + // Consume the '$' so we can peek beyond. + self.consume_char()?; + + // Now peek beyond to see what we have. + let char_after_dollar_sign = self.peek_char()?; + match char_after_dollar_sign { + Some('(') => { + // Add the '$' we already consumed to the token. + state.append_char('$'); + + // Consume the '(' and add it to the token. + state.append_char(self.next_char()?.unwrap()); + + // Check to see if this is possibly an arithmetic expression + // (i.e., one that starts with `$((`). + let (initial_nesting, is_arithmetic) = + if matches!(self.peek_char()?, Some('(')) { + // Consume the second '(' and add it to the token. + state.append_char(self.next_char()?.unwrap()); + (2, true) + } else { + (1, false) + }; + + if is_arithmetic { + self.cross_state.arithmetic_expansion = true; + } + + self.consume_nested_construct(&mut state, ')', "(", initial_nesting)?; + + if is_arithmetic { + self.cross_state.arithmetic_expansion = false; + } + } + + Some('[') => { + // Add the '$' we already consumed to the token. + state.append_char('$'); + + // Consume the '[' and add it to the token. + state.append_char(self.next_char()?.unwrap()); + + // Keep track that we're in an arithmetic expression, since + // some text will be interpreted differently as a result. + self.cross_state.arithmetic_expansion = true; + + self.consume_nested_construct(&mut state, ']', "[", 1)?; + + self.cross_state.arithmetic_expansion = false; + } + + Some('{') => { + // Add the '$' we already consumed to the token. + state.append_char('$'); + + // Consume the '{' and add it to the token. + state.append_char(self.next_char()?.unwrap()); + + let mut pending_here_doc_tokens = vec![]; + let mut drain_here_doc_tokens = false; + + loop { + let cur_token = if drain_here_doc_tokens + && !pending_here_doc_tokens.is_empty() + { + if pending_here_doc_tokens.len() == 1 { + drain_here_doc_tokens = false; + } + + pending_here_doc_tokens.remove(0) + } else { + let cur_token = self.next_token_until( + Some('}'), + false, /* include space? */ + )?; + + // See if this is a here-document-related token we need to hold + // onto until after we've seen all the tokens that need to show + // up before we get to the body. + if matches!( + cur_token.reason, + TokenEndReason::HereDocumentBodyStart + | TokenEndReason::HereDocumentBodyEnd + | TokenEndReason::HereDocumentEndTag + ) { + pending_here_doc_tokens.push(cur_token); + continue; + } + + cur_token + }; + + if matches!(cur_token.reason, TokenEndReason::UnescapedNewLine) + && !pending_here_doc_tokens.is_empty() + { + pending_here_doc_tokens.push(cur_token); + drain_here_doc_tokens = true; + continue; + } + + if let Some(cur_token_value) = cur_token.token { + state.append_str(cur_token_value.to_str()); + } + + match cur_token.reason { + TokenEndReason::HereDocumentBodyStart => { + state.append_char('\n'); + } + TokenEndReason::NonNewLineBlank => state.append_char(' '), + TokenEndReason::SpecifiedTerminatingChar => { + // We hit the end brace we were looking for but did not + // yet consume it. Do so now. + state.append_char(self.next_char()?.unwrap()); + break; + } + TokenEndReason::EndOfInput => { + return Err(TokenizerError::UnterminatedVariable); + } + _ => (), + } + } + } + _ => { + // This is either a different character, or else the end of the string. + // Either way, add the '$' we already consumed to the token. + state.append_char('$'); + } + } + } else { + // We look for the terminating backquote. First disable normal consumption and + // consume the starting backquote. + let backquote_pos = self.cross_state.cursor.clone(); + self.consume_char()?; + + // Add the opening backquote to the token. + state.append_char(c); + + // Now continue until we see an unescaped backquote. + let mut escaping_enabled = false; + let mut done = false; + while !done { + // Read (and consume) the next char. + let next_char_in_backquote = self.next_char()?; + if let Some(cib) = next_char_in_backquote { + // Include it in the token no matter what. + state.append_char(cib); + + // Watch out for escaping. + if !escaping_enabled && cib == '\\' { + escaping_enabled = true; + } else { + // Look for an unescaped backquote to terminate. + if !escaping_enabled && cib == '`' { + done = true; + } + escaping_enabled = false; + } + } else { + return Err(TokenizerError::UnterminatedBackquote(backquote_pos)); + } + } + } + } + // + // [Extension] + // If extended globbing is enabled, the last consumed character is an + // unquoted start of an extglob pattern, *and* if the current character + // is an open parenthesis, then this begins an extglob pattern. + else if c == '(' + && self.options.enable_extended_globbing + && state.unquoted() + && !state.in_operator() + && state + .current_token() + .ends_with(|x| Self::can_start_extglob(x)) + { + // Consume the '(' and append it. + self.consume_char()?; + state.append_char(c); + + let mut paren_depth = 1; + let mut in_escape = false; + + // Keep consuming until we see the matching end ')'. + while paren_depth > 0 { + if let Some(extglob_char) = self.next_char()? { + // Include it in the token. + state.append_char(extglob_char); + + match extglob_char { + _ if in_escape => in_escape = false, + '\\' => in_escape = true, + '(' => paren_depth += 1, + ')' => paren_depth -= 1, + _ => (), + } + } else { + return Err(TokenizerError::UnterminatedExtendedGlob( + self.cross_state.cursor.clone(), + )); + } + } + // + // If the character *can* start an operator, then it will. + // + } else if state.unquoted() && Self::can_start_operator(c) { + if state.started_token() { + result = state.delimit_current_token( + TokenEndReason::OperatorStart, + &mut self.cross_state, + )?; + } else { + state.token_is_operator = true; + self.consume_char()?; + state.append_char(c); + } + // + // Whitespace gets discarded (and delimits tokens). + // + } else if state.unquoted() && is_blank(c) { + if state.started_token() { + result = state.delimit_current_token( + TokenEndReason::NonNewLineBlank, + &mut self.cross_state, + )?; + } else if include_space { + state.append_char(c); + } else { + // Make sure we don't include this char in the token range. + state.start_position.column += 1; + state.start_position.index += 1; + } + + self.consume_char()?; + } + // + // N.B. We need to remember if we were recursively called in a variable + // expansion expression; in that case we won't think a token was started but... + // we'd be wrong. + else if !state.token_is_operator + && (state.started_token() || matches!(terminating_char, Some('}'))) + { + self.consume_char()?; + state.append_char(c); + } else if c == '#' { + // Consume the '#'. + self.consume_char()?; + + let mut done = false; + while !done { + done = match self.peek_char()? { + Some('\n') => true, + None => true, + _ => { + // Consume the peeked char; it's part of the comment. + self.consume_char()?; + false + } + }; + } + // Re-start loop as if the comment never happened. + } else if state.started_token() { + // In all other cases where we have an in-progress token, we delimit here. + result = + state.delimit_current_token(TokenEndReason::Other, &mut self.cross_state)?; + } else { + // If we got here, then we don't have a token in progress and we're not starting an + // operator. Add the character to a new token. + self.consume_char()?; + state.append_char(c); + } + } + + let result = result.unwrap(); + + Ok(result) + } + + fn remove_here_end_tag( + &mut self, + state: &mut TokenParseState, + result: &mut Option, + ends_with_newline: bool, + ) -> Result { + // Bail immediately if we don't even have a *starting* here tag. + if self.cross_state.current_here_tags.is_empty() { + return Ok(false); + } + + let next_here_tag = &self.cross_state.current_here_tags[0]; + + let tag_str: Cow<'_, str> = if next_here_tag.tag_was_escaped_or_quoted { + unquote_str(next_here_tag.tag.as_str()).into() + } else { + next_here_tag.tag.as_str().into() + }; + + let tag_str = if !ends_with_newline { + tag_str + .strip_suffix('\n') + .unwrap_or_else(|| tag_str.as_ref()) + } else { + tag_str.as_ref() + }; + + if let Some(current_token_without_here_tag) = state.current_token().strip_suffix(tag_str) { + // Make sure that was either the start of the here document, or there + // was a newline between the preceding part + // and the tag. + if current_token_without_here_tag.is_empty() + || current_token_without_here_tag.ends_with('\n') + { + state.replace_with_here_doc(current_token_without_here_tag.to_owned()); + + // Delimit the end of the here-document body. + *result = state.delimit_current_token( + TokenEndReason::HereDocumentBodyEnd, + &mut self.cross_state, + )?; + + return Ok(true); + } + } + Ok(false) + } + + const fn can_start_extglob(c: char) -> bool { + matches!(c, '@' | '!' | '?' | '+' | '*') + } + + const fn can_start_operator(c: char) -> bool { + matches!(c, '&' | '(' | ')' | ';' | '\n' | '|' | '<' | '>') + } + + fn is_operator(&self, s: &str) -> bool { + // Handle non-POSIX operators. + if !self.options.sh_mode && matches!(s, "<<<" | "&>" | "&>>" | ";;&" | ";&" | "|&") { + return true; + } + + matches!( + s, + "&" | "&&" + | "(" + | ")" + | ";" + | ";;" + | "\n" + | "|" + | "||" + | "<" + | ">" + | ">|" + | "<<" + | ">>" + | "<&" + | ">&" + | "<<-" + | "<>" + ) + } +} + +impl Iterator for Tokenizer<'_, R> { + type Item = Result; + + fn next(&mut self) -> Option { + match self.next_token() { + #[expect(clippy::manual_map)] + Ok(result) => match result.token { + Some(_) => Some(Ok(result)), + None => None, + }, + Err(e) => Some(Err(e)), + } + } +} + +const fn is_blank(c: char) -> bool { + c == ' ' || c == '\t' +} + +const fn does_char_newly_affect_quoting(state: &TokenParseState, c: char) -> bool { + // If we're currently escaped, then nothing affects quoting. + if state.in_escape { + return false; + } + + match state.quote_mode { + // When we're in a double quote or ANSI-C quote, only a subset of escape + // sequences are recognized. + QuoteMode::Double(_) | QuoteMode::AnsiC(_) => { + if c == '\\' { + // TODO(tokenizer): handle backslash in double quote + true + } else { + false + } + } + // When we're in a single quote, nothing affects quoting. + QuoteMode::Single(_) => false, + // When we're not already in a quote, then we can straightforwardly look for a + // quote mark or backslash. + QuoteMode::None => is_quoting_char(c), + } +} + +const fn is_quoting_char(c: char) -> bool { + matches!(c, '\\' | '\'' | '\"') +} + +/// Return a string with all the quoting removed. +/// +/// # Arguments +/// +/// * `s` - The string to unquote. +pub fn unquote_str(s: &str) -> String { + let mut result = String::new(); + + let mut in_escape = false; + for c in s.chars() { + match c { + c if in_escape => { + result.push(c); + in_escape = false; + } + '\\' => in_escape = true, + c if is_quoting_char(c) => (), + c => result.push(c), + } + } + + result +} + +#[cfg(test)] +mod tests { + + use super::*; + use anyhow::Result; + use insta::assert_ron_snapshot; + use pretty_assertions::{assert_eq, assert_matches}; + + #[derive(serde::Serialize, serde::Deserialize)] + struct TokenizerResult<'a> { + input: &'a str, + result: Vec, + } + + fn test_tokenizer(input: &str) -> Result> { + Ok(TokenizerResult { + input, + result: tokenize_str(input)?, + }) + } + + #[test] + fn tokenize_empty() -> Result<()> { + let tokens = tokenize_str("")?; + assert_eq!(tokens.len(), 0); + Ok(()) + } + + #[test] + fn tokenize_line_continuation() -> Result<()> { + assert_ron_snapshot!(test_tokenizer( + r"a\ +bc" + )?); + Ok(()) + } + + #[test] + fn tokenize_operators() -> Result<()> { + assert_ron_snapshot!(test_tokenizer("a>>b")?); + Ok(()) + } + + #[test] + fn tokenize_comment() -> Result<()> { + assert_ron_snapshot!(test_tokenizer( + r"a #comment +" + )?); + Ok(()) + } + + #[test] + fn tokenize_comment_at_eof() -> Result<()> { + assert_ron_snapshot!(test_tokenizer(r"a #comment")?); + Ok(()) + } + + #[test] + fn tokenize_empty_here_doc() -> Result<()> { + assert_ron_snapshot!(test_tokenizer( + r"cat < Result<()> { + assert_ron_snapshot!(test_tokenizer( + r"cat < Result<()> { + assert_ron_snapshot!(test_tokenizer( + r"cat <<-HERE + SOMETHING + HERE +" + )?); + Ok(()) + } + + #[test] + fn tokenize_here_doc_with_other_tokens() -> Result<()> { + assert_ron_snapshot!(test_tokenizer( + r"cat < Result<()> { + assert_ron_snapshot!(test_tokenizer( + r"cat < Result<()> { + assert_ron_snapshot!(test_tokenizer( + r"echo $(cat < Result<()> { + assert_ron_snapshot!(test_tokenizer( + r#"echo "$(cat < Result<()> { + assert_ron_snapshot!(test_tokenizer( + r#"echo "$(cat << HERE +TEXT +HERE +)""# + )?); + Ok(()) + } + + #[test] + fn tokenize_complex_here_docs_in_command_substitution() -> Result<()> { + assert_ron_snapshot!(test_tokenizer( + r"echo $(cat < Result<()> { + assert_ron_snapshot!(test_tokenizer(r"echo `echo hi`")?); + Ok(()) + } + + #[test] + fn tokenize_backquote_with_escape() -> Result<()> { + assert_ron_snapshot!(test_tokenizer(r"echo `echo\`hi`")?); + Ok(()) + } + + #[test] + fn tokenize_unterminated_backquote() { + assert_matches!( + tokenize_str("`"), + Err(TokenizerError::UnterminatedBackquote(_)) + ); + } + + #[test] + fn tokenize_unterminated_command_substitution() { + // $( is consumed before the tokenizer knows whether it's $( or $((, + // so it goes through consume_nested_construct and yields UnterminatedExpansion. + assert_matches!( + tokenize_str("$("), + Err(TokenizerError::UnterminatedExpansion) + ); + } + + #[test] + fn tokenize_unterminated_arithmetic_expansion() { + assert_matches!( + tokenize_str("$(("), + Err(TokenizerError::UnterminatedExpansion) + ); + } + + #[test] + fn tokenize_unterminated_legacy_arithmetic_expansion() { + assert_matches!( + tokenize_str("$["), + Err(TokenizerError::UnterminatedExpansion) + ); + } + + #[test] + fn tokenize_command_substitution() -> Result<()> { + assert_ron_snapshot!(test_tokenizer("a$(echo hi)b c")?); + Ok(()) + } + + #[test] + fn tokenize_command_substitution_with_subshell() -> Result<()> { + assert_ron_snapshot!(test_tokenizer("$( (:) )")?); + Ok(()) + } + + #[test] + fn tokenize_command_substitution_containing_extglob() -> Result<()> { + assert_ron_snapshot!(test_tokenizer("echo $(echo !(x))")?); + Ok(()) + } + + #[test] + fn tokenize_arithmetic_expression() -> Result<()> { + assert_ron_snapshot!(test_tokenizer("a$((1+2))b c")?); + Ok(()) + } + + #[test] + fn tokenize_arithmetic_expression_with_space() -> Result<()> { + // N.B. The spacing comes out a bit odd, but it gets processed okay + // by later stages. + assert_ron_snapshot!(test_tokenizer("$(( 1 ))")?); + Ok(()) + } + #[test] + fn tokenize_arithmetic_expression_with_parens() -> Result<()> { + assert_ron_snapshot!(test_tokenizer("$(( (0) ))")?); + Ok(()) + } + + #[test] + fn tokenize_special_parameters() -> Result<()> { + assert_ron_snapshot!(test_tokenizer("$$")?); + assert_ron_snapshot!(test_tokenizer("$@")?); + assert_ron_snapshot!(test_tokenizer("$!")?); + assert_ron_snapshot!(test_tokenizer("$?")?); + assert_ron_snapshot!(test_tokenizer("$*")?); + Ok(()) + } + + #[test] + fn tokenize_unbraced_parameter_expansion() -> Result<()> { + assert_ron_snapshot!(test_tokenizer("$x")?); + assert_ron_snapshot!(test_tokenizer("a$x")?); + Ok(()) + } + + #[test] + fn tokenize_unterminated_parameter_expansion() { + assert_matches!( + tokenize_str("${x"), + Err(TokenizerError::UnterminatedVariable) + ); + } + + #[test] + fn tokenize_braced_parameter_expansion() -> Result<()> { + assert_ron_snapshot!(test_tokenizer("${x}")?); + assert_ron_snapshot!(test_tokenizer("a${x}b")?); + Ok(()) + } + + #[test] + fn tokenize_braced_parameter_expansion_with_escaping() -> Result<()> { + assert_ron_snapshot!(test_tokenizer(r"a${x\}}b")?); + Ok(()) + } + + #[test] + fn tokenize_whitespace() -> Result<()> { + assert_ron_snapshot!(test_tokenizer("1 2 3")?); + Ok(()) + } + + #[test] + fn tokenize_escaped_whitespace() -> Result<()> { + assert_ron_snapshot!(test_tokenizer(r"1\ 2 3")?); + Ok(()) + } + + #[test] + fn tokenize_single_quote() -> Result<()> { + assert_ron_snapshot!(test_tokenizer(r"x'a b'y")?); + Ok(()) + } + + #[test] + fn tokenize_double_quote() -> Result<()> { + assert_ron_snapshot!(test_tokenizer(r#"x"a b"y"#)?); + Ok(()) + } + + #[test] + fn tokenize_double_quoted_command_substitution() -> Result<()> { + assert_ron_snapshot!(test_tokenizer(r#"x"$(echo hi)"y"#)?); + Ok(()) + } + + #[test] + fn tokenize_double_quoted_arithmetic_expression() -> Result<()> { + assert_ron_snapshot!(test_tokenizer(r#"x"$((1+2))"y"#)?); + Ok(()) + } + + #[test] + fn test_quote_removal() { + assert_eq!(unquote_str(r#""hello""#), "hello"); + assert_eq!(unquote_str(r"'hello'"), "hello"); + assert_eq!(unquote_str(r#""hel\"lo""#), r#"hel"lo"#); + assert_eq!(unquote_str(r"'hel\'lo'"), r"hel'lo"); + } +} diff --git a/local/recipes/shells/brush/source/brush-parser/src/word.rs b/local/recipes/shells/brush/source/brush-parser/src/word.rs new file mode 100644 index 0000000000..6413cb4a4e --- /dev/null +++ b/local/recipes/shells/brush/source/brush-parser/src/word.rs @@ -0,0 +1,1384 @@ +//! Parser for shell words, used in expansion and other contexts. +//! +//! Implements support for: +//! +//! - Text quoting (single, double, ANSI C). +//! - Escape sequences. +//! - Tilde prefixes. +//! - Parameter expansion expressions. +//! - Command substitution expressions. +//! - Arithmetic expansion expressions. + +use std::fmt::Debug; +use std::fmt::Display; + +use crate::ParserOptions; +use crate::SourceSpan; +use crate::ast; +use crate::error; + +/// Encapsulates a `WordPiece` together with its position in the string it came from. +#[derive(Clone, Debug)] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub struct WordPieceWithSource { + /// The word piece. + pub piece: WordPiece, + /// The start index of the piece in the source string. + pub start_index: usize, + /// The end index of the piece in the source string. + pub end_index: usize, +} + +/// Represents a piece of a word. +#[derive(Clone, Debug)] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum WordPiece { + /// A simple unquoted, unescaped string. + Text(String), + /// A string that is single-quoted. + SingleQuotedText(String), + /// A string that is ANSI-C quoted. + AnsiCQuotedText(String), + /// A sequence of pieces that are embedded in double quotes. + DoubleQuotedSequence(Vec), + /// Gettext enabled variant of [`WordPiece::DoubleQuotedSequence`]. + GettextDoubleQuotedSequence(Vec), + /// A tilde expansion. + TildeExpansion(TildeExpr), + /// A parameter expansion. + ParameterExpansion(ParameterExpr), + /// A command substitution. + CommandSubstitution(String), + /// A backquoted command substitution. + BackquotedCommandSubstitution(String), + /// An escape sequence. + EscapeSequence(String), + /// An arithmetic expression. + ArithmeticExpression(ast::UnexpandedArithmeticExpr), +} + +/// Represents an expandable tilde expression (e.g., ~). +#[derive(Clone, Debug)] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum TildeExpr { + /// `~` + Home, + /// `~` + UserHome(String), + /// `~+` + WorkingDir, + /// `~-` + OldWorkingDir, + /// Represents a tilde expansion of the form `~+N`, referring to the Nth directory in + /// the shell's directory stack, starting at the top of the stack. Note that the directory + /// stack is expected to contains the current working directory as its topmost entry. + NthDirFromTopOfDirStack { + /// Index into the directory stack (zero-based: 0 is the top of the stack). + n: usize, + /// Whether the '+' prefix was explicitly used. + plus_used: bool, + }, + /// Represents a tilde expansion of the form `~-N`, referring to the Nth directory in + /// the shell's directory stack, starting at the bottom of the stack. + NthDirFromBottomOfDirStack { + /// Index into the directory stack (zero-based: 0 is the bottom of the stack). + n: usize, + }, +} + +/// Type of a parameter test. +#[derive(Clone, Debug)] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum ParameterTestType { + /// Check for unset or null. + UnsetOrNull, + /// Check for unset. + Unset, +} + +/// A parameter, used in a parameter expansion. +#[derive(Clone, Debug)] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum Parameter { + /// A 0-indexed positional parameter. + Positional(u32), + /// A special parameter. + Special(SpecialParameter), + /// A named variable. + Named(String), + /// An index into a named variable. + NamedWithIndex { + /// Variable name. + name: String, + /// Index. + index: String, + }, + /// A named array variable with all indices. + NamedWithAllIndices { + /// Variable name. + name: String, + /// Whether to concatenate the values. + concatenate: bool, + }, +} + +impl Display for Parameter { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Positional(n) => write!(f, "${n}"), + Self::Special(s) => write!(f, "${s}"), + Self::Named(name) => write!(f, "${{{name}}}"), + Self::NamedWithIndex { name, index } => { + write!(f, "${{{name}[{index}]}}") + } + Self::NamedWithAllIndices { name, concatenate } => { + if *concatenate { + write!(f, "${{{name}[*]}}") + } else { + write!(f, "${{{name}[@]}}") + } + } + } + } +} + +/// A special parameter, used in a parameter expansion. +#[derive(Clone, Debug)] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum SpecialParameter { + /// All positional parameters. + AllPositionalParameters { + /// Whether to concatenate the values. + concatenate: bool, + }, + /// The count of positional parameters. + PositionalParameterCount, + /// The last exit status in the shell. + LastExitStatus, + /// The current shell option flags. + CurrentOptionFlags, + /// The current shell process ID. + ProcessId, + /// The last background process ID managed by the shell. + LastBackgroundProcessId, + /// The name of the shell. + ShellName, +} + +impl Display for SpecialParameter { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::AllPositionalParameters { concatenate } => { + if *concatenate { + write!(f, "*") + } else { + write!(f, "@") + } + } + Self::PositionalParameterCount => write!(f, "#"), + Self::LastExitStatus => write!(f, "?"), + Self::CurrentOptionFlags => write!(f, "-"), + Self::ProcessId => write!(f, "$"), + Self::LastBackgroundProcessId => write!(f, "!"), + Self::ShellName => write!(f, "0"), + } + } +} + +/// A parameter expression, used in a parameter expansion. +#[derive(Clone, Debug)] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum ParameterExpr { + /// A parameter, with optional indirection. + Parameter { + /// The parameter. + parameter: Parameter, + /// Whether to treat the expanded parameter as an indirect + /// reference, which should be subsequently dereferenced + /// for the expansion. + indirect: bool, + }, + /// Conditionally use default values. + UseDefaultValues { + /// The parameter. + parameter: Parameter, + /// Whether to treat the expanded parameter as an indirect + /// reference, which should be subsequently dereferenced + /// for the expansion. + indirect: bool, + /// The type of test to perform. + test_type: ParameterTestType, + /// Default value to conditionally use. + default_value: Option, + }, + /// Conditionally assign default values. + AssignDefaultValues { + /// The parameter. + parameter: Parameter, + /// Whether to treat the expanded parameter as an indirect + /// reference, which should be subsequently dereferenced + /// for the expansion. + indirect: bool, + /// The type of test to perform. + test_type: ParameterTestType, + /// Default value to conditionally assign. + default_value: Option, + }, + /// Indicate error if null or unset. + IndicateErrorIfNullOrUnset { + /// The parameter. + parameter: Parameter, + /// Whether to treat the expanded parameter as an indirect + /// reference, which should be subsequently dereferenced + /// for the expansion. + indirect: bool, + /// The type of test to perform. + test_type: ParameterTestType, + /// Error message to conditionally yield. + error_message: Option, + }, + /// Conditionally use an alternative value. + UseAlternativeValue { + /// The parameter. + parameter: Parameter, + /// Whether to treat the expanded parameter as an indirect + /// reference, which should be subsequently dereferenced + /// for the expansion. + indirect: bool, + /// The type of test to perform. + test_type: ParameterTestType, + /// Alternative value to conditionally use. + alternative_value: Option, + }, + /// Compute the length of the given parameter. + ParameterLength { + /// The parameter. + parameter: Parameter, + /// Whether to treat the expanded parameter as an indirect + /// reference, which should be subsequently dereferenced + /// for the expansion. + indirect: bool, + }, + /// Remove the smallest suffix from the given string matching the given pattern. + RemoveSmallestSuffixPattern { + /// The parameter. + parameter: Parameter, + /// Whether to treat the expanded parameter as an indirect + /// reference, which should be subsequently dereferenced + /// for the expansion. + indirect: bool, + /// Optionally provides a pattern to match. + pattern: Option, + }, + /// Remove the largest suffix from the given string matching the given pattern. + RemoveLargestSuffixPattern { + /// The parameter. + parameter: Parameter, + /// Whether to treat the expanded parameter as an indirect + /// reference, which should be subsequently dereferenced + /// for the expansion. + indirect: bool, + /// Optionally provides a pattern to match. + pattern: Option, + }, + /// Remove the smallest prefix from the given string matching the given pattern. + RemoveSmallestPrefixPattern { + /// The parameter. + parameter: Parameter, + /// Whether to treat the expanded parameter as an indirect + /// reference, which should be subsequently dereferenced + /// for the expansion. + indirect: bool, + /// Optionally provides a pattern to match. + pattern: Option, + }, + /// Remove the largest prefix from the given string matching the given pattern. + RemoveLargestPrefixPattern { + /// The parameter. + parameter: Parameter, + /// Whether to treat the expanded parameter as an indirect + /// reference, which should be subsequently dereferenced + /// for the expansion. + indirect: bool, + /// Optionally provides a pattern to match. + pattern: Option, + }, + /// Extract a substring from the given parameter. + Substring { + /// The parameter. + parameter: Parameter, + /// Whether to treat the expanded parameter as an indirect + /// reference, which should be subsequently dereferenced + /// for the expansion. + indirect: bool, + /// Arithmetic expression that will be expanded to compute the offset + /// at which the substring should be extracted. + offset: ast::UnexpandedArithmeticExpr, + /// Optionally provides an arithmetic expression that will be expanded + /// to compute the length of substring to be extracted; if left + /// unspecified, the remainder of the string will be extracted. + length: Option, + }, + /// Transform the given parameter. + Transform { + /// The parameter. + parameter: Parameter, + /// Whether to treat the expanded parameter as an indirect + /// reference, which should be subsequently dereferenced + /// for the expansion. + indirect: bool, + /// Type of transformation to apply. + op: ParameterTransformOp, + }, + /// Uppercase the first character of the given parameter. + UppercaseFirstChar { + /// The parameter. + parameter: Parameter, + /// Whether to treat the expanded parameter as an indirect + /// reference, which should be subsequently dereferenced + /// for the expansion. + indirect: bool, + /// Optionally provides a pattern to match. + pattern: Option, + }, + /// Uppercase the portion of the given parameter matching the given pattern. + UppercasePattern { + /// The parameter. + parameter: Parameter, + /// Whether to treat the expanded parameter as an indirect + /// reference, which should be subsequently dereferenced + /// for the expansion. + indirect: bool, + /// Optionally provides a pattern to match. + pattern: Option, + }, + /// Lowercase the first character of the given parameter. + LowercaseFirstChar { + /// The parameter. + parameter: Parameter, + /// Whether to treat the expanded parameter as an indirect + /// reference, which should be subsequently dereferenced + /// for the expansion. + indirect: bool, + /// Optionally provides a pattern to match. + pattern: Option, + }, + /// Lowercase the portion of the given parameter matching the given pattern. + LowercasePattern { + /// The parameter. + parameter: Parameter, + /// Whether to treat the expanded parameter as an indirect + /// reference, which should be subsequently dereferenced + /// for the expansion. + indirect: bool, + /// Optionally provides a pattern to match. + pattern: Option, + }, + /// Replace occurrences of the given pattern in the given parameter. + ReplaceSubstring { + /// The parameter. + parameter: Parameter, + /// Whether to treat the expanded parameter as an indirect + /// reference, which should be subsequently dereferenced + /// for the expansion. + indirect: bool, + /// Pattern to match. + pattern: String, + /// Replacement string. + replacement: Option, + /// Kind of match to perform. + match_kind: SubstringMatchKind, + }, + /// Select variable names from the environment with a given prefix. + VariableNames { + /// The prefix to match. + prefix: String, + /// Whether to concatenate the results. + concatenate: bool, + }, + /// Select member keys from the named array. + MemberKeys { + /// Name of the array variable. + variable_name: String, + /// Whether to concatenate the results. + concatenate: bool, + }, +} + +/// Kind of substring match. +#[derive(Clone, Debug)] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum SubstringMatchKind { + /// Match the prefix of the string. + Prefix, + /// Match the suffix of the string. + Suffix, + /// Match the first occurrence in the string. + FirstOccurrence, + /// Match all instances in the string. + Anywhere, +} + +/// Kind of operation to apply to a parameter. +#[derive(Clone, Debug)] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum ParameterTransformOp { + /// Capitalizate initials. + CapitalizeInitial, + /// Expand escape sequences. + ExpandEscapeSequences, + /// Possibly quote with arrays expanded. + PossiblyQuoteWithArraysExpanded { + /// Whether or not to yield separate words. + separate_words: bool, + }, + /// Apply prompt expansion. + PromptExpand, + /// Quote the parameter. + Quoted, + /// Translate to a format usable in an assignment/declaration. + ToAssignmentLogic, + /// Translate to the parameter's attribute flags. + ToAttributeFlags, + /// Translate to lowercase. + ToLowerCase, + /// Translate to uppercase. + ToUpperCase, +} + +/// Represents a sub-word that is either a brace expression or some other word text. +#[derive(Clone, Debug)] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum BraceExpressionOrText { + /// A brace expression. + Expr(BraceExpression), + /// Other word text. + Text(String), +} + +/// Represents a brace expression to be expanded. +pub type BraceExpression = Vec; + +/// Member of a brace expression. +#[derive(Clone, Debug)] +#[cfg_attr( + any(test, feature = "serde"), + derive(PartialEq, Eq, serde::Serialize, serde::Deserialize) +)] +pub enum BraceExpressionMember { + /// An inclusive numerical sequence. + NumberSequence { + /// Start of the sequence. + start: i64, + /// Inclusive end of the sequence. + end: i64, + /// Increment value. + increment: i64, + }, + /// An inclusive character sequence. + CharSequence { + /// Start of the sequence. + start: char, + /// Inclusive end of the sequence. + end: char, + /// Increment value. + increment: i64, + }, + /// Child text or expressions. + Child(Vec), +} + +/// Parse a word into its constituent pieces. +/// +/// # Arguments +/// +/// * `word` - The word to parse. +/// * `options` - The parser options to use. +pub fn parse( + word: &str, + options: &ParserOptions, +) -> Result, error::WordParseError> { + cacheable_parse(word.to_owned(), options.to_owned()) +} + +#[cached::proc_macro::cached(size = 64, result = true)] +fn cacheable_parse( + word: String, + options: ParserOptions, +) -> Result, error::WordParseError> { + tracing::debug!(target: "expansion", "Parsing word '{}'", word); + + let pieces = expansion_parser::unexpanded_word(word.as_str(), &options) + .map_err(|err| error::WordParseError::Word(word.clone(), err.into()))?; + + tracing::debug!(target: "expansion", "Parsed word '{}' => {{{:?}}}", word, pieces); + + Ok(pieces) +} + +/// Parse a heredoc body, treating `"` and `'` as literal characters. +/// +/// # Arguments +/// +/// * `word` - The heredoc body to parse. +/// * `options` - The parser options to use. +pub fn parse_heredoc( + word: &str, + options: &ParserOptions, +) -> Result, error::WordParseError> { + expansion_parser::unexpanded_heredoc_word(word, options) + .map_err(|err| error::WordParseError::Word(word.to_owned(), err.into())) +} + +/// Parse the given word into a parameter expression. +/// +/// # Arguments +/// +/// * `word` - The word to parse. +/// * `options` - The parser options to use. +pub fn parse_parameter( + word: &str, + options: &ParserOptions, +) -> Result { + expansion_parser::parameter(word, options) + .map_err(|err| error::WordParseError::Parameter(word.to_owned(), err.into())) +} + +/// Parse brace expansion from a given word . +/// +/// # Arguments +/// +/// * `word` - The word to parse. +/// * `options` - The parser options to use. +pub fn parse_brace_expansions( + word: &str, + options: &ParserOptions, +) -> Result>, error::WordParseError> { + expansion_parser::brace_expansions(word, options) + .map_err(|err| error::WordParseError::BraceExpansion(word.to_owned(), err.into())) +} + +pub(crate) fn parse_assignment_word( + word: &str, +) -> Result> { + expansion_parser::name_equals_scalar_value(word, &ParserOptions::default()) +} + +pub(crate) fn parse_array_assignment( + word: &str, + elements: &[&String], +) -> Result { + let (assignment_name, append) = expansion_parser::name_equals(word, &ParserOptions::default()) + .map_err(|_| "not array assignment word")?; + + let elements = elements + .iter() + .map(|element| expansion_parser::literal_array_element(element, &ParserOptions::default())) + .collect::, _>>() + .map_err(|_| "invalid array element in literal")?; + + let elements_as_words = elements + .into_iter() + .map(|(key, value)| { + ( + key.map(|k| ast::Word::new(k.as_str())), + ast::Word::new(value.as_str()), + ) + }) + .collect(); + + Ok(ast::Assignment { + name: assignment_name, + value: ast::AssignmentValue::Array(elements_as_words), + append, + loc: SourceSpan::default(), + }) +} + +peg::parser! { + grammar expansion_parser(parser_options: &ParserOptions) for str { + // Helper rule that enables pegviz to be used to visualize debug peg traces. + rule traced(e: rule) -> T = + &(input:$([_]*) { + #[cfg(feature = "debug-tracing")] + println!("[PEG_INPUT_START]\n{input}\n[PEG_TRACE_START]"); + }) + e:e()? {? + #[cfg(feature = "debug-tracing")] + println!("[PEG_TRACE_STOP]"); + e.ok_or("") + } + + pub(crate) rule unexpanded_word() -> Vec = traced()>) + + rule word(stop_condition: rule) -> Vec = + tilde:tilde_expr_prefix_with_source()? pieces:word_piece_with_source(, false /*in_command*/)* { + let mut all_pieces = Vec::new(); + if let Some(tilde) = tilde { + all_pieces.push(tilde); + } + all_pieces.extend(pieces); + all_pieces + } + + // Takes a word as input. + pub(crate) rule brace_expansions() -> Option> = + pieces:(brace_expansion_piece()+) { Some(pieces) } / + [_]* { None } + + // Returns either a complete brace expression (without any prefix or suffix), or a + // non-brace-expression string. + rule brace_expansion_piece(stop_condition: rule) -> BraceExpressionOrText = + expr:brace_expr() { + BraceExpressionOrText::Expr(expr) + } / + text:$(non_brace_expr_text()+) { BraceExpressionOrText::Text(text.to_owned()) } + + // Parses text that is not considered to contain a brace expression. + rule non_brace_expr_text(stop_condition: rule) -> () = + !"{" word_piece(<['{'] {} / stop_condition() {}>, false) {} / + !brace_expr() !stop_condition() "{" {} + + // Parses a complete brace expression, with no prefix or suffix. + pub(crate) rule brace_expr() -> BraceExpression = + "{" inner:brace_expr_inner() "}" { inner } + + // Parses the text inside a complete brace expression; basically the complete brace + // expression without the opening and closing brace characters. + pub(crate) rule brace_expr_inner() -> BraceExpression = + brace_text_list_expr() / + seq:brace_sequence_expr() { vec![seq] } + + // Parses a list of brace expression members, including the separating commas; does + // not include the opening and closing braces. + pub(crate) rule brace_text_list_expr() -> BraceExpression = + brace_text_list_member() **<2,> "," + + // Parses an element that can occur in a brace expression member list, not including the + // terminating comma or closing brace. + pub(crate) rule brace_text_list_member() -> BraceExpressionMember = + // Matches an empty-string member, without consuming the comma or closing brace that terminates it. + &[',' | '}'] { BraceExpressionMember::Child(vec![BraceExpressionOrText::Text(String::new())]) } / + // Matches a nested string that may include some combination of concatenated textual strings + // and brace expressions. + child_pieces:(brace_expansion_piece(<[',' | '}']>)+) { + BraceExpressionMember::Child(child_pieces) + } + + pub(crate) rule brace_sequence_expr() -> BraceExpressionMember = + start:number() ".." end:number() increment:(".." n:number() { n })? { + BraceExpressionMember::NumberSequence { start, end, increment: increment.unwrap_or(1) } + } / + start:character() ".." end:character() increment:(".." n:number() { n })? { + BraceExpressionMember::CharSequence { start, end, increment: increment.unwrap_or(1) } + } + + rule number() -> i64 = sign:number_sign()? n:$(['0'..='9']+) { + let sign = sign.unwrap_or(1); + let num: i64 = n.parse().unwrap(); + num * sign + } + + rule number_sign() -> i64 = + ['-'] { -1 } / + ['+'] { 1 } + + rule character() -> char = ['a'..='z' | 'A'..='Z'] + + pub(crate) rule is_arithmetic_word() = + arithmetic_word() + + // N.B. We don't bother returning the word pieces, as all users of this rule + // only try to extract the consumed input string and not the parse result. + rule arithmetic_word(stop_condition: rule) = + arithmetic_word_piece()* {} + + pub(crate) rule is_arithmetic_word_piece() = + arithmetic_word_piece() + + // This rule matches an individual "piece" of an arithmetic expression. It needs to handle + // matching nested parenthesized expressions as well. We stop consuming the input when + // we reach the provided stop condition, which typically denotes the end of the containing + // arithmetic expression. + rule arithmetic_word_piece(stop_condition: rule) = + // This branch matches a parenthesized piece; we consume the opening parenthesis and + // delegate the rest to a helper rule. We don't worry about the stop condition passed + // into us, because if we see an opening parenthesis then we *must* find its closing + // partner. + "(" arithmetic_word_plus_right_paren() {} / + // This branch handles the case where we have an array element name with square brackets, + // which may (legitimately) contain the stop condition. + array_element_name() {} / + // This branch matches any standard piece of a word, stopping as soon as we reach + // either the overall stop condition *OR* an opening parenthesis. We add this latter + // condition to ensure that *we* handle matching parentheses. + !"(" word_piece()>, false /*in_command*/) {} + + // This is a helper rule that matches either the provided stop condition or an opening parenthesis. + rule param_rule_or_open_paren(stop_condition: rule) -> () = + stop_condition() {} / + "(" {} + + // This rule matches an arithmetic word followed by a right parenthesis. It must consume the right parenthesis. + rule arithmetic_word_plus_right_paren() = + arithmetic_word(<[')']>) ")" + + rule word_piece_with_source(stop_condition: rule, in_command: bool) -> WordPieceWithSource = + start_index:position!() piece:word_piece(, in_command) end_index:position!() { + WordPieceWithSource { piece, start_index, end_index } + } + + rule word_piece(stop_condition: rule, in_command: bool) -> WordPiece = + // Rules that match quoted text. + s:double_quoted_sequence() { WordPiece::DoubleQuotedSequence(s) } / + s:single_quoted_literal_text() { WordPiece::SingleQuotedText(s.to_owned()) } / + s:ansi_c_quoted_text() { WordPiece::AnsiCQuotedText(s.to_owned()) } / + s:gettext_double_quoted_sequence() { WordPiece::GettextDoubleQuotedSequence(s) } / + // Rules that match pieces starting with a dollar sign ('$'). + dollar_sign_word_piece() / + // Rules that match unquoted text that doesn't start with an unescaped dollar sign. + normal_escape_sequence() / + // Allow tilde expression to be matched as a word piece (for tilde-after-colon expansion) + enabled_tilde_expr_after_colon() / + // Finally, match unquoted literal text. + unquoted_literal_text(, in_command) + + rule dollar_sign_word_piece() -> WordPiece = + arithmetic_expansion() / + legacy_arithmetic_expansion() / + command_substitution() / + parameter_expansion() + + rule double_quoted_word_piece() -> WordPiece = + arithmetic_expansion() / + legacy_arithmetic_expansion() / + command_substitution() / + parameter_expansion() / + double_quoted_escape_sequence() / + double_quoted_text() + + rule double_quoted_sequence() -> Vec = + "\"" i:double_quoted_sequence_inner()* "\"" { i } + + rule gettext_double_quoted_sequence() -> Vec = + "$\"" i:double_quoted_sequence_inner()* "\"" { i } + + rule double_quoted_sequence_inner() -> WordPieceWithSource = + start_index:position!() piece:double_quoted_word_piece() end_index:position!() { + WordPieceWithSource { + piece, + start_index, + end_index + } + } + + rule single_quoted_literal_text() -> &'input str = + "\'" inner:$([^'\'']*) "\'" { inner } + + rule ansi_c_quoted_text() -> &'input str = + r"$'" inner:$((r"\\" / r"\'" / [^'\''])*) r"'" { inner } + + rule unquoted_literal_text(stop_condition: rule, in_command: bool) -> WordPiece = + s:$(unquoted_literal_text_piece(, in_command)+) { WordPiece::Text(s.to_owned()) } + + // TODO(parser): Find a way to remove the special-case logic for extglob + subshell commands + rule unquoted_literal_text_piece(stop_condition: rule, in_command: bool) = + is_true(in_command) extglob_pattern() / + is_true(in_command) subshell_command() / + !stop_condition() !normal_escape_sequence() !enabled_tilde_expr_after_colon() [^'\'' | '\"' | '$' | '`'] {} + + rule enabled_tilde_expr_after_colon() -> WordPiece = + tilde_exprs_after_colon_enabled() last_char_is_colon() piece:tilde_expression_piece() { piece } + + rule last_char_is_colon() = #{|input, pos| { + if pos == 0 { + // No preceding character - can't be preceded by ':' + peg::RuleResult::Failed + } else { + // Check the byte directly (`:` is ASCII, single byte) + if input.as_bytes()[pos - 1] == b':' { + peg::RuleResult::Matched(pos, ()) + } else { + peg::RuleResult::Failed + } + } + }} + + rule is_true(value: bool) = &[_] {? if value { Ok(()) } else { Err("not true") } } + + rule extglob_pattern() = + ("@" / "!" / "?" / "+" / "*") "(" extglob_body_piece()* ")" {} + + rule extglob_body_piece() = + word_piece(<[')']>, true /*in_command*/) {} + + rule subshell_command() = + "(" command() ")" {} + + rule double_quoted_text() -> WordPiece = + s:double_quote_body_text() { WordPiece::Text(s.to_owned()) } + + rule double_quote_body_text() -> &'input str = + $((!double_quoted_escape_sequence() !dollar_sign_word_piece() [^'\"'])+) + + // Heredoc body parsing: like double-quoted content, but " and ' are literal characters. + pub(crate) rule unexpanded_heredoc_word() -> Vec = + traced()>) + + rule heredoc_word(stop_condition: rule) -> Vec = + pieces:heredoc_word_piece_with_source()* { pieces } + + rule heredoc_word_piece_with_source(stop_condition: rule) -> WordPieceWithSource = + !stop_condition() start_index:position!() piece:heredoc_word_piece() end_index:position!() { + WordPieceWithSource { piece, start_index, end_index } + } + + rule heredoc_word_piece() -> WordPiece = + arithmetic_expansion() / + legacy_arithmetic_expansion() / + command_substitution() / + parameter_expansion() / + heredoc_escape_sequence() / + heredoc_literal_text() + + rule heredoc_escape_sequence() -> WordPiece = + s:$("\\" ['$' | '`' | '\\']) { WordPiece::EscapeSequence(s.to_owned()) } + + rule heredoc_literal_text() -> WordPiece = + s:$((!heredoc_escape_sequence() !dollar_sign_word_piece() [^'`'])+) { + WordPiece::Text(s.to_owned()) + } + + rule normal_escape_sequence() -> WordPiece = + s:$("\\" [c]) { WordPiece::EscapeSequence(s.to_owned()) } + + rule double_quoted_escape_sequence() -> WordPiece = + s:$("\\" ['$' | '`' | '\"' | '\\']) { WordPiece::EscapeSequence(s.to_owned()) } + + rule tilde_expr_prefix_with_source() -> WordPieceWithSource = + start_index:position!() piece:tilde_expr_prefix() end_index:position!() { + WordPieceWithSource { + piece, + start_index, + end_index + } + } + + rule tilde_expr_prefix() -> WordPiece = + tilde_exprs_at_word_start_enabled() piece:tilde_expression_piece() { piece } + + rule tilde_expr_after_colon() -> WordPiece = + tilde_exprs_after_colon_enabled() piece:tilde_expression_piece() { piece } + + rule tilde_expression_piece() -> WordPiece = + "~" expr:tilde_expression() { WordPiece::TildeExpansion(expr) } + + rule tilde_expression() -> TildeExpr = + &tilde_terminator() { TildeExpr::Home } / + "+" &tilde_terminator() { TildeExpr::WorkingDir } / + plus:("+"?) n:$(['0'..='9']*) &tilde_terminator() { TildeExpr::NthDirFromTopOfDirStack { n: n.parse().unwrap(), plus_used: plus.is_some() } } / + "-" &tilde_terminator() { TildeExpr::OldWorkingDir } / + "-" n:$(['0'..='9']*) &tilde_terminator() { TildeExpr::NthDirFromBottomOfDirStack { n: n.parse().unwrap() } } / + user:$(portable_filename_char()*) &tilde_terminator() { TildeExpr::UserHome(user.to_owned()) } + + rule tilde_terminator() = ['/' | ':' | ';' | '}'] / ![_] + + rule portable_filename_char() = ['A'..='Z' | 'a'..='z' | '0'..='9' | '.' | '_' | '-'] + + // TODO(parser): Deal with fact that there may be a quoted word or escaped closing brace chars. + // TODO(parser): Improve on how we handle a '$' not followed by a valid variable name or parameter. + rule parameter_expansion() -> WordPiece = + "${" e:parameter_expression() "}" { + WordPiece::ParameterExpansion(e) + } / + "$" parameter:unbraced_parameter() { + WordPiece::ParameterExpansion(ParameterExpr::Parameter { parameter, indirect: false }) + } / + "$" !['\''] { + WordPiece::Text("$".to_owned()) + } + + rule parameter_expression() -> ParameterExpr = + indirect:parameter_indirection() parameter:parameter() test_type:parameter_test_type() "-" default_value:parameter_expression_word()? { + ParameterExpr::UseDefaultValues { parameter, indirect, test_type, default_value } + } / + indirect:parameter_indirection() parameter:parameter() test_type:parameter_test_type() "=" default_value:parameter_expression_word()? { + ParameterExpr::AssignDefaultValues { parameter, indirect, test_type, default_value } + } / + indirect:parameter_indirection() parameter:parameter() test_type:parameter_test_type() "?" error_message:parameter_expression_word()? { + ParameterExpr::IndicateErrorIfNullOrUnset { parameter, indirect, test_type, error_message } + } / + indirect:parameter_indirection() parameter:parameter() test_type:parameter_test_type() "+" alternative_value:parameter_expression_word()? { + ParameterExpr::UseAlternativeValue { parameter, indirect, test_type, alternative_value } + } / + "#" parameter:parameter() { + ParameterExpr::ParameterLength { parameter, indirect: false } + } / + indirect:parameter_indirection() parameter:parameter() "%%" pattern:parameter_expression_word()? { + ParameterExpr::RemoveLargestSuffixPattern { parameter, indirect, pattern } + } / + indirect:parameter_indirection() parameter:parameter() "%" pattern:parameter_expression_word()? { + ParameterExpr::RemoveSmallestSuffixPattern { parameter, indirect, pattern } + } / + indirect:parameter_indirection() parameter:parameter() "##" pattern:parameter_expression_word()? { + ParameterExpr::RemoveLargestPrefixPattern { parameter, indirect, pattern } + } / + indirect:parameter_indirection() parameter:parameter() "#" pattern:parameter_expression_word()? { + ParameterExpr::RemoveSmallestPrefixPattern { parameter, indirect, pattern } + } / + // N.B. The following case is for non-sh extensions. + non_posix_extensions_enabled() e:non_posix_parameter_expression() { e } / + indirect:parameter_indirection() parameter:parameter() { + ParameterExpr::Parameter { parameter, indirect } + } + + rule parameter_test_type() -> ParameterTestType = + colon:":"? { + if colon.is_some() { + ParameterTestType::UnsetOrNull + } else { + ParameterTestType::Unset + } + } + + rule non_posix_parameter_expression() -> ParameterExpr = + "!" variable_name:variable_name() "[*]" { + ParameterExpr::MemberKeys { variable_name: variable_name.to_owned(), concatenate: true } + } / + "!" variable_name:variable_name() "[@]" { + ParameterExpr::MemberKeys { variable_name: variable_name.to_owned(), concatenate: false } + } / + indirect:parameter_indirection() parameter:parameter() ":" offset:substring_offset() length:(":" l:substring_length() { l })? { + ParameterExpr::Substring { parameter, indirect, offset, length } + } / + indirect:parameter_indirection() parameter:parameter() "@" op:non_posix_parameter_transformation_op() { + ParameterExpr::Transform { parameter, indirect, op } + } / + "!" prefix:variable_name() "*" { + ParameterExpr::VariableNames { prefix: prefix.to_owned(), concatenate: true } + } / + "!" prefix:variable_name() "@" { + ParameterExpr::VariableNames { prefix: prefix.to_owned(), concatenate: false } + } / + indirect:parameter_indirection() parameter:parameter() "/#" pattern:parameter_search_pattern() replacement:parameter_replacement_str()? { + ParameterExpr::ReplaceSubstring { parameter, indirect, pattern, replacement, match_kind: SubstringMatchKind::Prefix } + } / + indirect:parameter_indirection() parameter:parameter() "/%" pattern:parameter_search_pattern() replacement:parameter_replacement_str()? { + ParameterExpr::ReplaceSubstring { parameter, indirect, pattern, replacement, match_kind: SubstringMatchKind::Suffix } + } / + indirect:parameter_indirection() parameter:parameter() "//" pattern:parameter_search_pattern() replacement:parameter_replacement_str()? { + ParameterExpr::ReplaceSubstring { parameter, indirect, pattern, replacement, match_kind: SubstringMatchKind::Anywhere } + } / + indirect:parameter_indirection() parameter:parameter() "/" pattern:parameter_search_pattern() replacement:parameter_replacement_str()? { + ParameterExpr::ReplaceSubstring { parameter, indirect, pattern, replacement, match_kind: SubstringMatchKind::FirstOccurrence } + } / + indirect:parameter_indirection() parameter:parameter() "^^" pattern:parameter_expression_word()? { + ParameterExpr::UppercasePattern { parameter, indirect, pattern } + } / + indirect:parameter_indirection() parameter:parameter() "^" pattern:parameter_expression_word()? { + ParameterExpr::UppercaseFirstChar { parameter, indirect, pattern } + } / + indirect:parameter_indirection() parameter:parameter() ",," pattern:parameter_expression_word()? { + ParameterExpr::LowercasePattern { parameter, indirect, pattern } + } / + indirect:parameter_indirection() parameter:parameter() "," pattern:parameter_expression_word()? { + ParameterExpr::LowercaseFirstChar { parameter, indirect, pattern } + } + + rule parameter_indirection() -> bool = + non_posix_extensions_enabled() "!" { true } / + { false } + + rule non_posix_parameter_transformation_op() -> ParameterTransformOp = + "U" { ParameterTransformOp::ToUpperCase } / + "u" { ParameterTransformOp::CapitalizeInitial } / + "L" { ParameterTransformOp::ToLowerCase } / + "Q" { ParameterTransformOp::Quoted } / + "E" { ParameterTransformOp::ExpandEscapeSequences } / + "P" { ParameterTransformOp::PromptExpand } / + "A" { ParameterTransformOp::ToAssignmentLogic } / + "K" { ParameterTransformOp::PossiblyQuoteWithArraysExpanded { separate_words: false } } / + "a" { ParameterTransformOp::ToAttributeFlags } / + "k" { ParameterTransformOp::PossiblyQuoteWithArraysExpanded { separate_words: true } } + + + rule unbraced_parameter() -> Parameter = + p:unbraced_positional_parameter() { Parameter::Positional(p) } / + p:special_parameter() { Parameter::Special(p) } / + p:variable_name() { Parameter::Named(p.to_owned()) } + + // N.B. The indexing syntax is not a standard sh-ism. + pub(crate) rule parameter() -> Parameter = + p:positional_parameter() { Parameter::Positional(p) } / + p:special_parameter() { Parameter::Special(p) } / + non_posix_extensions_enabled() p:variable_name() "[@]" { Parameter::NamedWithAllIndices { name: p.to_owned(), concatenate: false } } / + non_posix_extensions_enabled() p:variable_name() "[*]" { Parameter::NamedWithAllIndices { name: p.to_owned(), concatenate: true } } / + non_posix_extensions_enabled() p:variable_name() "[" index:array_index() "]" {? + Ok(Parameter::NamedWithIndex { name: p.to_owned(), index: index.to_owned() }) + } / + p:variable_name() { Parameter::Named(p.to_owned()) } + + rule positional_parameter() -> u32 = + n:$(['1'..='9'](['0'..='9']*)) {? n.parse().or(Err("u32")) } + rule unbraced_positional_parameter() -> u32 = + n:$(['1'..='9']) {? n.parse().or(Err("u32")) } + + rule special_parameter() -> SpecialParameter = + "@" { SpecialParameter::AllPositionalParameters { concatenate: false } } / + "*" { SpecialParameter::AllPositionalParameters { concatenate: true } } / + "#" { SpecialParameter::PositionalParameterCount } / + "?" { SpecialParameter::LastExitStatus } / + "-" { SpecialParameter::CurrentOptionFlags } / + "$" { SpecialParameter::ProcessId } / + "!" { SpecialParameter::LastBackgroundProcessId } / + "0" { SpecialParameter::ShellName } + + rule variable_name() -> &'input str = + $(!['0'..='9'] ['_' | '0'..='9' | 'a'..='z' | 'A'..='Z']+) + + pub(crate) rule command_substitution() -> WordPiece = + "$(" c:command() ")" { WordPiece::CommandSubstitution(c.to_owned()) } / + "`" c:backquoted_command() "`" { WordPiece::BackquotedCommandSubstitution(c) } + + pub(crate) rule command() -> &'input str = + $(command_piece()*) + + pub(crate) rule command_piece() -> () = + word_piece(<[')']>, true /*in_command*/) {} / + ([' ' | '\t'])+ {} / + ['\'' | '`'] {} + + rule backquoted_command() -> String = + chars:(backquoted_char()*) { chars.into_iter().collect() } + + rule backquoted_char() -> &'input str = + "\\`" { "`" } / + "\\\\" { "\\\\" } / + s:$([^'`']) { s } + + rule arithmetic_expansion() -> WordPiece = + "$((" e:$(arithmetic_word(<"))">)) "))" { WordPiece::ArithmeticExpression(ast::UnexpandedArithmeticExpr { value: e.to_owned() } ) } + + rule legacy_arithmetic_expansion() -> WordPiece = + "$[" e:$(arithmetic_word(<"]">)) "]" { WordPiece::ArithmeticExpression(ast::UnexpandedArithmeticExpr { value: e.to_owned() } ) } + + rule substring_offset() -> ast::UnexpandedArithmeticExpr = + s:$(arithmetic_word(<[':' | '}']>)) { ast::UnexpandedArithmeticExpr { value: s.to_owned() } } + + rule substring_length() -> ast::UnexpandedArithmeticExpr = + s:$(arithmetic_word(<[':' | '}']>)) { ast::UnexpandedArithmeticExpr { value: s.to_owned() } } + + rule parameter_replacement_str() -> String = + "/" s:$(word(<['}']>)) { s.to_owned() } + + rule parameter_search_pattern() -> String = + s:$(word(<['}' | '/']>)) { s.to_owned() } + + rule parameter_expression_word() -> String = + s:$(word(<['}']>)) { s.to_owned() } + + rule extglob_enabled() -> () = + &[_] {? if parser_options.enable_extended_globbing { Ok(()) } else { Err("no extglob") } } + + rule non_posix_extensions_enabled() -> () = + &[_] {? if !parser_options.sh_mode { Ok(()) } else { Err("posix") } } + + rule tilde_exprs_at_word_start_enabled() -> () = + &[_] {? if parser_options.tilde_expansion_at_word_start { Ok(()) } else { Err("no tilde expansion at word start") } } + + rule tilde_exprs_after_colon_enabled() -> () = + &[_] {? if parser_options.tilde_expansion_after_colon { Ok(()) } else { Err("no tilde expansion after colon") } } + + // Assignment rules. + + pub(crate) rule name_equals_scalar_value() -> ast::Assignment = + nae:name_equals() value:assigned_scalar_value() { + let (name, append) = nae; + ast::Assignment { name, value, append, loc: SourceSpan::default() } + } + + pub(crate) rule name_equals() -> (ast::AssignmentName, bool) = + name:assignment_name() append:("+"?) "=" { + (name, append.is_some()) + } + + pub(crate) rule literal_array_element() -> (Option, String) = + "[" inner:$((!"]" [_])*) "]=" value:$([_]*) { + (Some(inner.to_owned()), value.to_owned()) + } / + value:$([_]+) { + (None, value.to_owned()) + } + + rule assignment_name() -> ast::AssignmentName = + aen:array_element_name() { + let (name, index) = aen; + ast::AssignmentName::ArrayElementName(name.to_owned(), index.to_owned()) + } / + name:assigned_scalar_name() { + ast::AssignmentName::VariableName(name.to_owned()) + } + + rule array_element_name() -> (&'input str, &'input str) = + name:assigned_scalar_name() "[" ai:array_index() "]" { (name, ai) } + + rule array_index() -> &'input str = + $(arithmetic_word(<"]">)) + + rule assigned_scalar_name() -> &'input str = + $(alpha_or_underscore() non_first_variable_char()*) + + rule non_first_variable_char() -> () = + ['_' | '0'..='9' | 'a'..='z' | 'A'..='Z'] {} + + rule alpha_or_underscore() -> () = + ['_' | 'a'..='z' | 'A'..='Z'] {} + + rule assigned_scalar_value() -> ast::AssignmentValue = + v:$([_]*) { ast::AssignmentValue::Scalar(ast::Word::from(v.to_owned())) } + } +} + +#[cfg(test)] +#[allow(clippy::panic_in_result_fn)] +mod tests { + use super::*; + use anyhow::Result; + use insta::assert_ron_snapshot; + use pretty_assertions::assert_matches; + + #[derive(serde::Serialize, serde::Deserialize)] + struct ParseTestResults<'a> { + input: &'a str, + result: Vec, + } + + fn test_parse(word: &str) -> Result> { + let parsed = super::parse(word, &ParserOptions::default())?; + Ok(ParseTestResults { + input: word, + result: parsed, + }) + } + + #[test] + fn parse_ansi_c_quoted_text() -> Result<()> { + assert_ron_snapshot!(test_parse(r"$'hi\nthere\t'")?); + Ok(()) + } + + #[test] + fn parse_ansi_c_quoted_escape_seq() -> Result<()> { + assert_ron_snapshot!(test_parse(r"$'\\'")?); + Ok(()) + } + + #[test] + fn parse_tilde_after_colon() -> Result<()> { + let opts = ParserOptions { + tilde_expansion_after_colon: true, + ..ParserOptions::default() + }; + + let parsed = super::parse("a:~", &opts)?; + + // Should have: Text("a:"), TildeExpansion("") + assert_eq!(parsed.len(), 2); + assert_matches!(parsed[0].piece, WordPiece::Text(_)); + assert_matches!(parsed[1].piece, WordPiece::TildeExpansion(_)); + + Ok(()) + } + + #[test] + fn parse_double_quoted_text() -> Result<()> { + assert_ron_snapshot!(test_parse(r#""a ${b} c""#)?); + Ok(()) + } + + #[test] + fn parse_gettext_double_quoted_text() -> Result<()> { + assert_ron_snapshot!(test_parse(r#"$"a ${b} c""#)?); + Ok(()) + } + + #[test] + fn parse_command_substitution() -> Result<()> { + super::expansion_parser::command_piece("echo", &ParserOptions::default())?; + super::expansion_parser::command_piece("hi", &ParserOptions::default())?; + super::expansion_parser::command("echo hi", &ParserOptions::default())?; + super::expansion_parser::command_substitution("$(echo hi)", &ParserOptions::default())?; + + assert_ron_snapshot!(test_parse("$(echo hi)")?); + + Ok(()) + } + + #[test] + fn parse_command_substitution_with_embedded_quotes() -> Result<()> { + super::expansion_parser::command_piece("echo", &ParserOptions::default())?; + super::expansion_parser::command_piece(r#""hi""#, &ParserOptions::default())?; + super::expansion_parser::command(r#"echo "hi""#, &ParserOptions::default())?; + super::expansion_parser::command_substitution( + r#"$(echo "hi")"#, + &ParserOptions::default(), + )?; + + assert_ron_snapshot!(test_parse(r#"$(echo "hi")"#)?); + Ok(()) + } + + #[test] + fn parse_command_substitution_with_embedded_extglob() -> Result<()> { + assert_ron_snapshot!(test_parse("$(echo !(x))")?); + Ok(()) + } + + #[test] + fn parse_command_sub_with_unbalanced_single_quote() -> Result<()> { + assert_ron_snapshot!(test_parse("\"$(cat <<'EOF'\nit's here\nEOF\n)\"")?); + Ok(()) + } + + #[test] + fn parse_command_sub_with_unbalanced_backtick() -> Result<()> { + assert_ron_snapshot!(test_parse("\"$(cat <<'EOF'\na ` b\nEOF\n)\"")?); + Ok(()) + } + + #[test] + fn parse_command_sub_with_balanced_double_quotes() -> Result<()> { + assert_ron_snapshot!(test_parse("\"$(cat <<'EOF'\n\"hello\"\nEOF\n)\"")?); + Ok(()) + } + + #[test] + fn parse_command_sub_with_balanced_single_quotes() -> Result<()> { + assert_ron_snapshot!(test_parse("\"$(cat <<'EOF'\n'hello'\nEOF\n)\"")?); + Ok(()) + } + + #[test] + fn parse_command_sub_with_balanced_backticks() -> Result<()> { + assert_ron_snapshot!(test_parse("\"$(cat <<'EOF'\n`hello`\nEOF\n)\"")?); + Ok(()) + } + + #[test] + fn parse_backquoted_command() -> Result<()> { + assert_ron_snapshot!(test_parse("`echo hi`")?); + Ok(()) + } + + #[test] + fn parse_backquoted_command_in_double_quotes() -> Result<()> { + assert_ron_snapshot!(test_parse(r#""`echo hi`""#)?); + Ok(()) + } + + #[test] + fn parse_extglob_with_embedded_parameter() -> Result<()> { + assert_ron_snapshot!(test_parse("+([$var])")?); + Ok(()) + } + + #[test] + fn parse_arithmetic_expansion() -> Result<()> { + assert_ron_snapshot!(test_parse("$((0))")?); + Ok(()) + } + + #[test] + fn parse_arithmetic_expansion_with_parens() -> Result<()> { + assert_ron_snapshot!(test_parse("$((((1+2)*3)))")?); + Ok(()) + } + + #[test] + fn test_arithmetic_word_parsing() { + let options = ParserOptions::default(); + + assert!(super::expansion_parser::is_arithmetic_word("a", &options).is_ok()); + assert!(super::expansion_parser::is_arithmetic_word("b", &options).is_ok()); + assert!(super::expansion_parser::is_arithmetic_word(" a + b ", &options).is_ok()); + assert!(super::expansion_parser::is_arithmetic_word("(a)", &options).is_ok()); + assert!(super::expansion_parser::is_arithmetic_word("((a))", &options).is_ok()); + assert!(super::expansion_parser::is_arithmetic_word("(((a)))", &options).is_ok()); + assert!(super::expansion_parser::is_arithmetic_word("(1+2)", &options).is_ok()); + assert!(super::expansion_parser::is_arithmetic_word("(1+2)*3", &options).is_ok()); + assert!(super::expansion_parser::is_arithmetic_word("((1+2)*3)", &options).is_ok()); + } + + #[test] + fn test_arithmetic_word_piece_parsing() { + let options = ParserOptions::default(); + + assert!(super::expansion_parser::is_arithmetic_word_piece("a", &options).is_ok()); + assert!(super::expansion_parser::is_arithmetic_word_piece("b", &options).is_ok()); + assert!(super::expansion_parser::is_arithmetic_word_piece(" a + b ", &options).is_ok()); + assert!(super::expansion_parser::is_arithmetic_word_piece("(a)", &options).is_ok()); + assert!(super::expansion_parser::is_arithmetic_word_piece("((a))", &options).is_ok()); + assert!(super::expansion_parser::is_arithmetic_word_piece("(((a)))", &options).is_ok()); + assert!(super::expansion_parser::is_arithmetic_word_piece("(1+2)", &options).is_ok()); + assert!(super::expansion_parser::is_arithmetic_word_piece("((1+2))", &options).is_ok()); + assert!(super::expansion_parser::is_arithmetic_word_piece("((1+2)*3)", &options).is_ok()); + assert!(super::expansion_parser::is_arithmetic_word_piece("(a", &options).is_err()); + assert!(super::expansion_parser::is_arithmetic_word_piece("(a))", &options).is_err()); + assert!(super::expansion_parser::is_arithmetic_word_piece("((a)", &options).is_err()); + } + + #[test] + fn test_brace_expansion_parsing() -> Result<()> { + let options = ParserOptions::default(); + + let inputs = ["x{a,b}y", "{a,b{1,2}}"]; + + for input in inputs { + assert_ron_snapshot!(super::parse_brace_expansions(input, &options)?.ok_or_else( + || anyhow::anyhow!("Expected brace expansion to be parsed successfully") + )?); + } + + Ok(()) + } + + #[test] + fn parse_assignment_word() -> Result<()> { + super::parse_assignment_word("x=3")?; + super::parse_assignment_word("x=")?; + super::parse_assignment_word("x[3]=a")?; + super::parse_assignment_word("x[${y[3]}]=a")?; + super::parse_assignment_word("x[y[3]]=a")?; + Ok(()) + } +} diff --git a/local/recipes/shells/brush/source/brush-shell/Cargo.toml b/local/recipes/shells/brush/source/brush-shell/Cargo.toml new file mode 100644 index 0000000000..46dd28ee66 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/Cargo.toml @@ -0,0 +1,150 @@ +[package] +name = "brush-shell" +description = "Rust-implemented shell focused on POSIX and bash compatibility" +version = "0.4.0" +authors.workspace = true +categories.workspace = true +edition.workspace = true +keywords.workspace = true +license.workspace = true +readme.workspace = true +repository.workspace = true +rust-version.workspace = true + +[package.metadata.binstall] +pkg-url = "{ repo }/releases/download/{ name }-v{ version }/brush-{ target }{ archive-suffix }" + +[lib] +bench = false + +[[bin]] +name = "brush" +path = "src/main.rs" +bench = false + +[[test]] +name = "brush-compat-tests" +path = "tests/compat_tests.rs" +harness = false + +[[test]] +name = "brush-integration-tests" +path = "tests/integration_tests.rs" +harness = false + +[[test]] +name = "brush-version-tests" +path = "tests/version_tests.rs" + +[[test]] +name = "brush-interactive-tests" +path = "tests/interactive_tests.rs" + +[[test]] +name = "brush-completion-tests" +path = "tests/completion_tests.rs" + +[[bench]] +name = "shell" +path = "benches/shell.rs" +harness = false + +[features] +default = ["basic", "reedline", "minimal"] +basic = ["brush-interactive/basic"] +minimal = ["brush-interactive/minimal"] +reedline = ["brush-interactive/reedline"] +experimental = [ + "experimental-builtins", + "experimental-bundled-coreutils", + "experimental-load", + "experimental-parser", +] +experimental-builtins = ["dep:brush-experimental-builtins"] +experimental-bundled-coreutils = [ + "dep:brush-coreutils-builtins", + "brush-coreutils-builtins/coreutils.all", +] +experimental-load = ["dep:serde_json", "brush-core/serde"] +experimental-parser = [ + "brush-core/experimental-parser", + "brush-parser/winnow-parser", +] +schema = ["dep:schemars"] + +[lints] +workspace = true + +[dependencies] +brush-core = { version = "^0.5.0", path = "../brush-core" } +brush-builtins = { version = "^0.2.0", path = "../brush-builtins" } +brush-coreutils-builtins = { version = "^0.1.0", path = "../brush-coreutils-builtins", optional = true } +brush-experimental-builtins = { version = "^0.1.0", path = "../brush-experimental-builtins", optional = true } +clap = { version = "4.6.0", features = ["derive", "env"] } +color-print = "0.3.7" +etcetera = "0.11.0" +schemars = { version = "1.2.1", optional = true } +serde = { version = "1.0", features = ["derive"] } +toml = "1.1.0" +const_format = "0.2.36" +git-version = "0.3.9" +serde_json = { version = "1.0.149", optional = true } +thiserror = "2.0.18" +tracing = "0.1.44" +tracing-subscriber = "0.3.23" +human-panic = "2.0.6" + +[target.'cfg(not(any(unix, windows)))'.dependencies] +brush-interactive = { version = "^0.4.0", path = "../brush-interactive", features = [ + "minimal", +] } +tokio = { version = "1.52.3", features = ["rt", "sync"] } + +[target.'cfg(any(unix, windows))'.dependencies] +brush-interactive = { version = "^0.4.0", path = "../brush-interactive", features = [ + "basic", + "reedline", +] } +crossterm = "0.29.0" +tokio = { version = "1.52.3", features = ["rt", "rt-multi-thread", "sync"] } + +[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" +tempfile = "3.27.0" +brush-parser = { version = "^0.4.0", path = "../brush-parser" } +brush-test-harness = { path = "../brush-test-harness", features = ["insta"] } +assert_cmd = "2.2.0" +assert_fs = "1.1.3" +colored = "3.1.1" +criterion = { version = "0.8.2", features = ["async_tokio", "html_reports"] } +descape = "3.0.0" +diff = "0.1.13" +glob = "0.3.3" +indent = "0.1.1" +indexmap = "2.13.0" +junit-report = "0.9.0" +os-release = "0.1.0" +predicates = "3.1.4" +pretty_assertions = { version = "1.4.1", features = ["unstable"] } +regex = "1.12.3" +serde = { version = "1.0.228", features = ["derive"] } +serde_yaml = "0.9.34" +strip-ansi-escapes = "0.2.1" +test-with = { version = "0.16.0", default-features = false, features = [ + "executable", +] } +version-compare = "0.2.1" +walkdir = "2.5.0" +which = "8.0.2" + +[target.'cfg(unix)'.dev-dependencies] +nix = { version = "0.31.2" } + +# expectrl's pty support (via ptyprocess) is limited to these platforms; +# the interactive tests that rely on it are gated accordingly. +[target.'cfg(any(target_os = "linux", target_os = "android", target_os = "macos", target_os = "freebsd"))'.dev-dependencies] +expectrl = "0.8.0" diff --git a/local/recipes/shells/brush/source/brush-shell/LICENSE b/local/recipes/shells/brush/source/brush-shell/LICENSE new file mode 120000 index 0000000000..ea5b60640b --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/LICENSE @@ -0,0 +1 @@ +../LICENSE \ No newline at end of file diff --git a/local/recipes/shells/brush/source/brush-shell/about.hbs b/local/recipes/shells/brush/source/brush-shell/about.hbs new file mode 100644 index 0000000000..42c277d725 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/about.hbs @@ -0,0 +1,70 @@ + + + + + + + +

+
+

Third Party Licenses

+

This page lists the licenses of the projects used in brush-shell.

+
+ +

Overview of licenses:

+
    + {{#each overview}} +
  • {{name}} ({{count}})
  • + {{/each}} +
+ +

All license text:

+ +
+ + + diff --git a/local/recipes/shells/brush/source/brush-shell/about.toml b/local/recipes/shells/brush/source/brush-shell/about.toml new file mode 100644 index 0000000000..722d7a8203 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/about.toml @@ -0,0 +1,16 @@ +ignore-build-dependencies = true +ignore-dev-dependencies = true + +accepted = [ + "MIT", + "Apache-2.0", + "BSD-2-Clause", + "BSL-1.0", + "ISC", + "CDDL-1.0", + "MPL-2.0", + "NCSA", + "Unicode-3.0", + "WTFPL", + "Zlib", +] diff --git a/local/recipes/shells/brush/source/brush-shell/benches/shell.rs b/local/recipes/shells/brush/source/brush-shell/benches/shell.rs new file mode 100644 index 0000000000..4352b6e698 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/benches/shell.rs @@ -0,0 +1,174 @@ +//! Benchmarks for the brush-shell crate. + +#![allow(missing_docs)] +#![allow(clippy::unwrap_used)] + +#[cfg(unix)] +mod unix { + use brush_builtins::ShellBuilderExt; + use brush_parser::SourceSpan; + use criterion::Criterion; + use std::hint::black_box; + + async fn instantiate_shell() -> brush_core::Shell { + brush_core::Shell::builder() + .default_builtins(brush_builtins::BuiltinSet::BashMode) + .build() + .await + .unwrap() + } + + async fn instantiate_shell_with_init_scripts() -> brush_core::Shell { + brush_core::Shell::builder() + .interactive(true) + .read_commands_from_stdin(true) + .default_builtins(brush_builtins::BuiltinSet::BashMode) + .build() + .await + .unwrap() + } + + async fn run_one_command(shell: &mut brush_core::Shell, command: &str) { + let _ = shell + .run_string( + command.to_owned(), + &brush_core::SourceInfo::default(), + &shell.default_exec_params(), + ) + .await + .unwrap(); + } + + async fn expand_string(shell: &mut brush_core::Shell, s: &str) { + let params = shell.default_exec_params(); + let _ = shell.basic_expand_string(¶ms, s).await.unwrap(); + } + + fn eval_arithmetic_expr(shell: &mut brush_core::Shell, expr: &str) { + let parsed_expr = brush_parser::arithmetic::parse(expr).unwrap(); + let _ = shell.eval_arithmetic(&parsed_expr).unwrap(); + } + + /// This function defines core shell benchmarks. + pub(crate) fn criterion_benchmark(c: &mut Criterion) { + // Construct a runtime for us to run async code on. + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .unwrap(); + + // Benchmark shell instantiation. + c.bench_function("instantiate_shell", |b| { + b.to_async(&rt).iter(|| black_box(instantiate_shell())); + }); + c.bench_function("instantiate_shell_with_init_scripts", |b| { + b.to_async(&rt) + .iter(|| black_box(instantiate_shell_with_init_scripts())); + }); + + // Benchmark: cloning a shell object. + let shell = rt.block_on(instantiate_shell()); + c.bench_function("clone_shell_object", |b| { + b.iter(|| black_box(shell.clone())); + }); + + // Benchmark: parsing and evaluating an arithmetic expression.. + let shell = rt.block_on(instantiate_shell()); + c.bench_function("eval_arithmetic", |b| { + b.iter_batched_ref( + || shell.clone(), + |s| eval_arithmetic_expr(s, "3 + 10 * 2"), + criterion::BatchSize::SmallInput, + ); + }); + + // Benchmark: running the echo built-in command. + let shell = rt.block_on(instantiate_shell()); + c.bench_function("run_echo_builtin_command", |b| { + b.iter_batched_ref( + || shell.clone(), + |s| rt.block_on(run_one_command(s, "echo 'Hello, world!' >/dev/null")), + criterion::BatchSize::SmallInput, + ); + }); + + // Benchmark: running an external command. + // let shell = rt.block_on(instantiate_shell()); + // c.bench_function("run_one_external_command", |b| { + // b.iter_batched_ref( + // || shell.clone(), + // |s| { + // rt.block_on(run_one_command( + // s, + // "/usr/bin/echo 'Hello, world!' >/dev/null", + // )); + // }, + // criterion::BatchSize::SmallInput, + // ); + // }); + + // Benchmark: word expansion. + let shell = rt.block_on(instantiate_shell()); + c.bench_function("expand_one_string", |b| { + b.iter_batched_ref( + || shell.clone(), + |s| rt.block_on(expand_string(s, "My version is ${BASH_VERSINFO[@]}")), + criterion::BatchSize::SmallInput, + ); + }); + + // Benchmark: function invocation. + let mut shell = rt.block_on(instantiate_shell()); + shell.define_func( + String::from("testfunc"), + brush_parser::ast::FunctionDefinition { + fname: String::from("testfunc").into(), + body: brush_parser::ast::FunctionBody( + brush_parser::ast::CompoundCommand::BraceGroup( + brush_parser::ast::BraceGroupCommand { + list: brush_parser::ast::CompoundList(vec![]), + loc: SourceSpan::default(), + }, + ), + None, + ), + }, + &brush_core::SourceInfo::default(), + ); + c.bench_function("function_call", |b| { + b.iter_batched_ref( + || shell.clone(), + |s| { + rt.block_on(run_one_command(s, "testfunc")); + }, + criterion::BatchSize::SmallInput, + ); + }); + + // Benchmark: for loop. + let shell = rt.block_on(instantiate_shell()); + c.bench_function("for_loop", |b| { + b.iter_batched_ref( + || shell.clone(), + |s| { + rt.block_on(run_one_command(s, "for ((i = 0; i < 10; i++)); do :; done")); + }, + criterion::BatchSize::SmallInput, + ); + }); + } +} + +#[cfg(unix)] +criterion::criterion_group! { + name = benches; + config = criterion::Criterion::default() + .measurement_time(std::time::Duration::from_secs(10)); + targets = unix::criterion_benchmark +} + +#[cfg(unix)] +criterion::criterion_main!(benches); + +#[cfg(not(unix))] +fn main() {} diff --git a/local/recipes/shells/brush/source/brush-shell/src/args.rs b/local/recipes/shells/brush/source/brush-shell/src/args.rs new file mode 100644 index 0000000000..a924790db2 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/src/args.rs @@ -0,0 +1,303 @@ +//! Types for brush command-line parsing. + +use clap::{Parser, builder::styling}; +use std::io::IsTerminal; +use std::path::PathBuf; + +use crate::{events, productinfo}; + +const SHORT_DESCRIPTION: &str = "Bo[u]rn[e] RUsty SHell 🦀 (https://brush.sh)"; + +const LONG_DESCRIPTION: &str = r"brush is a bash-compatible, Rust-implemented, POSIX-style shell. + +brush is distributed under the terms of the MIT license. If you encounter any issues or discrepancies in behavior from bash, please report them at https://github.com/reubeno/brush. + +For more information, visit https://brush.sh."; + +const USAGE: &str = color_print::cstr!( + "brush [OPTIONS]... [SCRIPT_PATH [SCRIPT_ARGS]...]" +); + +const VERSION: &str = const_format::concatcp!( + productinfo::PRODUCT_VERSION, + " (", + productinfo::PRODUCT_GIT_VERSION, + ")" +); + +const HEADING_STANDARD_OPTIONS: &str = "Standard shell options"; + +const HEADING_CONFIG_OPTIONS: &str = "Configuration options"; + +const HEADING_UI_OPTIONS: &str = "User interface options"; + +const HEADING_EXPERIMENTAL_OPTIONS: &str = "*Experimental* options (unstable)"; + +/// Identifies the input backend to use for the shell. +#[derive(Clone, Copy, clap::ValueEnum)] +pub enum InputBackendType { + /// Richest input backend, based on reedline. + Reedline, + /// Basic input backend that provides minimal completion support for testing. + Basic, + /// Most minimal input backend. + Minimal, +} + +/// Parsed command-line arguments for the brush shell. +#[derive(Clone, Parser)] +#[clap(name = productinfo::PRODUCT_NAME, + version = VERSION, + about = SHORT_DESCRIPTION, + long_about = LONG_DESCRIPTION, + author, + override_usage = USAGE, + disable_help_flag = true, + disable_version_flag = true, + styles = brush_help_styles())] +pub struct CommandLineArgs { + /// Display usage information. + #[clap(long = "help", action = clap::ArgAction::HelpShort)] + pub help: Option, + + /// Display shell version. + #[clap(long = "version", action = clap::ArgAction::Version)] + pub version: Option, + + /// Path to TOML-based `brush` config file (overrides default location). + #[clap(long = "config", value_name = "FILE", help_heading = HEADING_CONFIG_OPTIONS)] + pub config_file: Option, + + /// Disable loading of TOML-based `brush` config file. + #[clap(long = "no-config", help_heading = HEADING_CONFIG_OPTIONS)] + pub no_config: bool, + + /// Enable `noclobber` shell option. + #[arg(short = 'C', help_heading = HEADING_STANDARD_OPTIONS)] + pub disallow_overwriting_regular_files_via_output_redirection: bool, + + /// Execute the provided command and then exit. + #[arg(short = 'c', value_name = "COMMAND", help_heading = HEADING_STANDARD_OPTIONS)] + pub command: Option, + + /// Enable error-on-exit behavior. + #[clap(short = 'e', help_heading = HEADING_STANDARD_OPTIONS)] + pub exit_on_nonzero_command_exit: bool, + + /// Disable pathname expansion (also known as filename globbing). + #[clap(short = 'f', help_heading = HEADING_STANDARD_OPTIONS)] + pub disable_pathname_expansion: bool, + + /// Run in interactive mode. + #[clap(short = 'i', help_heading = HEADING_STANDARD_OPTIONS)] + pub interactive: bool, + + /// Inherit the specified file descriptors injected by the parent process. + #[clap(long = "inherit-fd", value_name = "FD", help_heading = HEADING_STANDARD_OPTIONS)] + pub inherited_fds: Vec, + + /// Make shell act as if it had been invoked as a login shell. + #[clap(short = 'l', long = "login", help_heading = HEADING_STANDARD_OPTIONS)] + pub login: bool, + + /// Do not execute commands. + #[clap(short = 'n', help_heading = HEADING_STANDARD_OPTIONS)] + pub do_not_execute_commands: bool, + + /// Don't use readline for input. + #[clap(long = "noediting", help_heading = HEADING_STANDARD_OPTIONS)] + pub no_editing: bool, + + /// Don't process any profile/login files (`/etc/profile`, `~/.bash_profile`, `~/.bash_login`, + /// `~/.profile`). + #[clap(long = "noprofile", help_heading = HEADING_STANDARD_OPTIONS)] + pub no_profile: bool, + + /// Don't process "rc" files if the shell is interactive (e.g., `~/.bashrc`, `~/.brushrc`). + #[clap(long = "norc", help_heading = HEADING_STANDARD_OPTIONS)] + pub no_rc: bool, + + /// Don't inherit environment variables from the calling process. + #[clap(long = "noenv", help_heading = HEADING_STANDARD_OPTIONS)] + pub do_not_inherit_env: bool, + + /// Enable option (`set -o` option). + #[clap(short = 'o', value_name = "OPTION", help_heading = HEADING_STANDARD_OPTIONS)] + pub enabled_options: Vec, + + /// Disable option (`set -o` option). + #[clap(long = "+o", value_name = "OPTION", hide = true, help_heading = HEADING_STANDARD_OPTIONS)] + pub disabled_options: Vec, + + /// Enable `shopt` option. + #[clap(short = 'O', value_name = "SHOPT_OPTION", help_heading = HEADING_STANDARD_OPTIONS)] + pub enabled_shopt_options: Vec, + + /// Disable `shopt` option. + #[clap(long = "+O", value_name = "SHOPT_OPTION", hide = true, help_heading = HEADING_STANDARD_OPTIONS)] + pub disabled_shopt_options: Vec, + + /// Disable non-POSIX extensions. + #[clap(long = "posix", help_heading = HEADING_STANDARD_OPTIONS)] + pub posix: bool, + + /// Path to the rc file to load in interactive shells (instead of `bash.bashrc` and + /// `~/.bashrc`). + #[clap(long = "rcfile", alias = "init-file", value_name = "FILE", help_heading = HEADING_STANDARD_OPTIONS)] + pub rc_file: Option, + + /// Read commands from standard input. + #[clap(short = 's', help_heading = HEADING_STANDARD_OPTIONS)] + pub read_commands_from_stdin: bool, + + /// Run in `sh` compatibility mode, as if run as `/bin/sh`. + #[clap(long = "sh")] + pub sh_mode: bool, + + /// Run only one command and then exit. + #[clap(short = 't', help_heading = HEADING_STANDARD_OPTIONS)] + pub exit_after_one_command: bool, + + /// Treat expansion of an unset variable as an error. + #[clap(short = 'u', help_heading = HEADING_STANDARD_OPTIONS)] + pub treat_unset_variables_as_error: bool, + + /// Print input when it's processed. + #[clap(short = 'v', long = "verbose", help_heading = HEADING_STANDARD_OPTIONS)] + pub verbose: bool, + + /// Print commands as they execute. + #[clap(short = 'x', help_heading = HEADING_STANDARD_OPTIONS)] + pub print_commands_and_arguments: bool, + + /// Enable xtrace and configure for the given output file. + #[clap(long = "xtrace-file", value_name = "FILE", help_heading = HEADING_UI_OPTIONS)] + pub xtrace_file_path: Option, + + /// Disable bracketed paste. + #[clap(long = "disable-bracketed-paste", help_heading = HEADING_UI_OPTIONS)] + pub disable_bracketed_paste: bool, + + /// Disable colorized output. + #[clap(long = "disable-color", help_heading = HEADING_UI_OPTIONS)] + pub disable_color: bool, + + /// Enable syntax highlighting in input. + #[clap(long = "enable-highlighting", help_heading = HEADING_UI_OPTIONS, default_value_t = crate::entry::DEFAULT_ENABLE_HIGHLIGHTING)] + pub enable_highlighting: bool, + + /// Enable experimental parser (not ready for use). + #[cfg(feature = "experimental-parser")] + #[clap(long = "experimental-parser", help_heading = HEADING_EXPERIMENTAL_OPTIONS)] + pub experimental_parser: bool, + + /// Enable terminal integration (**experimental**). + #[clap(long = "enable-terminal-integration", help_heading = HEADING_EXPERIMENTAL_OPTIONS)] + pub terminal_shell_integration: bool, + + /// Enable zsh-style preexec/precmd hooks (**experimental**). + #[clap(long = "enable-zsh-hooks", help_heading = HEADING_EXPERIMENTAL_OPTIONS)] + pub zsh_style_hooks: bool, + + /// Input backend. + #[clap(long = "input-backend", value_name = "BACKEND", help_heading = HEADING_UI_OPTIONS)] + pub input_backend: Option, + + /// Load state from the given file; the saved state should be in JSON format + /// and overrides any non-UI command-line options provided. + #[cfg(feature = "experimental-load")] + #[clap(long = "load", value_name = "FILE", help_heading = HEADING_EXPERIMENTAL_OPTIONS)] + pub load_file: Option, + + /// Enable debug logging for classes of tracing events. + #[clap(long = "debug", alias = "log-enable", value_name = "EVENT", help_heading = HEADING_UI_OPTIONS)] + pub enabled_debug_events: Vec, + + /// Disable logging for classes of tracing events (takes same event types as `--debug`). + #[clap( + long = "disable-event", + alias = "log-disable", + value_name = "EVENT", + hide_possible_values = true, + help_heading = HEADING_UI_OPTIONS + )] + pub disabled_events: Vec, + + /// Path and arguments for script to execute (optional). + #[clap( + trailing_var_arg = true, + allow_hyphen_values = false, + value_name = "SCRIPT_PATH [SCRIPT_ARGS]..." + )] + pub script_args: Vec, +} + +impl CommandLineArgs { + /// Returns a `CommandLineArgs` with all clap-defined default values. + /// + /// This is useful for detecting which CLI arguments were explicitly provided + /// vs. which retained their default values (e.g., for config file merging). + #[must_use] + #[allow( + clippy::missing_panics_doc, + reason = "parsing defaults should not panic" + )] + pub fn default_values() -> Self { + use clap::Parser; + // Parse with just the program name to get all defaults. + // This won't fail because all arguments have defaults or are optional. + #[allow(clippy::expect_used)] + Self::try_parse_from(["brush"]).expect("parsing defaults should never fail") + } + + /// Returns whether or not the arguments indicate that the shell should run in interactive mode. + pub fn is_interactive(&self) -> bool { + // If -i is provided, then that overrides any further consideration; it forces + // interactive mode. + if self.interactive { + return true; + } + + // If -c or non-option arguments are provided, then we're not in interactive mode. + if self.command.is_some() || !self.script_args.is_empty() { + return false; + } + + // If *either* stdin or stderr is not a terminal, then we're not in interactive mode. + if !std::io::stdin().is_terminal() || !std::io::stderr().is_terminal() { + return false; + } + + // In all other cases, we assume interactive mode. + true + } +} + +/// Returns clap styling to be used for command-line help. +#[doc(hidden)] +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()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_values() { + let args = CommandLineArgs::default_values(); + // Verify some basic defaults + assert!(!args.interactive); + assert!(!args.login); + assert!(args.command.is_none()); + assert!(args.script_args.is_empty()); + } +} diff --git a/local/recipes/shells/brush/source/brush-shell/src/brushctl.rs b/local/recipes/shells/brush/source/brush-shell/src/brushctl.rs new file mode 100644 index 0000000000..509e04e87c --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/src/brushctl.rs @@ -0,0 +1,242 @@ +use brush_core::{ExecutionResult, sys}; +use clap::{Parser, Subcommand}; +use std::io::Write; + +use crate::events; + +/// Extension trait for adding brush-specific built-in commands to a shell builder. +pub(crate) trait ShellBuilderBrushBuiltinExt { + /// Add brush-specific builtins to a shell being built. + #[must_use] + fn brush_builtins(self) -> Self; +} + +impl + ShellBuilderBrushBuiltinExt for brush_core::ShellBuilder +{ + fn brush_builtins(self) -> Self { + // For compatibility with previous releases, we register the command under both + // `brushctl` and `brushinfo` names. It will behave identically across the two. + self.builtin( + "brushctl", + brush_core::builtins::builtin::(), + ) + .builtin( + "brushinfo", + brush_core::builtins::builtin::(), + ) + } +} + +/// Configure the running brush shell. +#[derive(Parser)] +pub(crate) struct BrushCtlCommand { + #[clap(subcommand)] + command_group: CommandGroup, +} + +#[derive(Subcommand)] +enum CommandGroup { + #[clap(subcommand)] + Complete(CompleteCommand), + #[clap(subcommand)] + Call(CallCommand), + #[clap(subcommand)] + Events(EventsCommand), + #[clap(subcommand)] + Process(ProcessCommand), +} + +/// Commands for inspecting call state. +#[derive(Subcommand)] +enum CallCommand { + /// Display the current call stack. + #[clap(name = "stack")] + ShowCallStack { + /// Whether to show more details. + #[clap(short = 'd', long = "detailed")] + detailed: bool, + }, +} + +/// Commands for generating completions. +#[derive(Subcommand)] +enum CompleteCommand { + /// Generate completions for an input line. + #[clap(name = "line")] + Line { + /// The 0-indexed cursor position for generation. + #[arg(long = "cursor", short = 'c')] + cursor_index: Option, + + /// The input line to generate completions for. + line: String, + }, +} + +/// Commands for configuring tracing events. +#[derive(Subcommand)] +enum EventsCommand { + /// Display status of enabled events. + Status, + + /// Enable event. + Enable { + /// Event to enable. + event: events::TraceEvent, + }, + + /// Disable event. + Disable { + /// Event to disable. + event: events::TraceEvent, + }, +} + +/// Commands for inspecting process state. +#[expect(clippy::enum_variant_names)] +#[derive(Subcommand)] +enum ProcessCommand { + /// Display process ID. + #[clap(name = "pid")] + ShowProcessId, + /// Display process group ID. + #[clap(name = "pgid")] + ShowProcessGroupId, + /// Display foreground process ID. + #[clap(name = "fgpid")] + ShowForegroundProcessId, + /// Display parent process ID. + #[clap(name = "ppid")] + ShowParentProcessId, +} + +impl brush_core::builtins::Command for BrushCtlCommand { + type Error = brush_core::Error; + + async fn execute( + &self, + mut context: brush_core::ExecutionContext<'_, SE>, + ) -> Result { + match &self.command_group { + CommandGroup::Call(call) => call.execute(&context), + CommandGroup::Complete(complete) => complete.execute(&mut context).await, + CommandGroup::Events(events) => events.execute(&context), + CommandGroup::Process(process) => process.execute(&context), + } + } +} + +impl CallCommand { + fn execute( + &self, + context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, + ) -> Result { + match self { + Self::ShowCallStack { detailed } => { + let stack = context.shell.call_stack(); + let format_options = brush_core::callstack::FormatOptions { + show_args: *detailed, + show_entry_points: *detailed, + }; + + write!(context.stdout(), "{}", stack.format(&format_options))?; + + Ok(ExecutionResult::success()) + } + } + } +} + +impl CompleteCommand { + async fn execute( + &self, + context: &mut brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, + ) -> Result { + match self { + Self::Line { cursor_index, line } => { + let completions = context + .shell + .complete(line, cursor_index.unwrap_or(line.len())) + .await?; + for candidate in completions.candidates { + writeln!(context.stdout(), "{candidate}")?; + } + Ok(ExecutionResult::success()) + } + } + } +} + +impl EventsCommand { + fn execute( + &self, + context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, + ) -> Result { + let event_config = crate::entry::get_event_config(); + + let mut event_config = event_config.try_lock().map_err(|_| { + brush_core::Error::from(brush_core::ErrorKind::Unimplemented( + "Failed to acquire lock on event configuration", + )) + })?; + + if let Some(event_config) = event_config.as_mut() { + match self { + Self::Status => { + let enabled_events = event_config.get_enabled_events(); + for event in enabled_events { + writeln!(context.stdout(), "{event}")?; + } + } + Self::Enable { event } => event_config.enable(*event)?, + Self::Disable { event } => event_config.disable(*event)?, + } + + Ok(brush_core::ExecutionResult::success()) + } else { + Err(brush_core::ErrorKind::Unimplemented("event configuration not initialized").into()) + } + } +} + +impl ProcessCommand { + fn execute( + &self, + context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>, + ) -> Result { + match self { + Self::ShowProcessId => { + writeln!(context.stdout(), "{}", std::process::id())?; + Ok(ExecutionResult::success()) + } + Self::ShowProcessGroupId => { + if let Some(pgid) = sys::terminal::get_process_group_id() { + writeln!(context.stdout(), "{pgid}")?; + Ok(ExecutionResult::success()) + } else { + writeln!(context.stderr(), "failed to get process group ID")?; + Ok(ExecutionResult::general_error()) + } + } + Self::ShowForegroundProcessId => { + if let Some(pid) = sys::terminal::get_foreground_pid() { + writeln!(context.stdout(), "{pid}")?; + Ok(ExecutionResult::success()) + } else { + writeln!(context.stderr(), "failed to get foreground process ID")?; + Ok(ExecutionResult::general_error()) + } + } + Self::ShowParentProcessId => { + if let Some(pid) = sys::terminal::get_parent_process_id() { + writeln!(context.stdout(), "{pid}")?; + Ok(ExecutionResult::success()) + } else { + writeln!(context.stderr(), "failed to get parent process ID")?; + Ok(ExecutionResult::general_error()) + } + } + } + } +} diff --git a/local/recipes/shells/brush/source/brush-shell/src/bundled.rs b/local/recipes/shells/brush/source/brush-shell/src/bundled.rs new file mode 100644 index 0000000000..d410bd2485 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/src/bundled.rs @@ -0,0 +1,289 @@ +//! Bundled commands: utilities that ship inside the brush binary. +//! +//! Utilities are shipped busybox-style (one binary, many names) but execute +//! as a subprocess of brush so that shell redirections, pipes, and +//! process-group state are honored by code that reads/writes the host +//! process's standard fds (e.g., uutils crates). +//! +//! ## Protocol +//! +//! The brush binary recognizes a hidden first-position argument +//! [`DISPATCH_FLAG`] followed by ` [ARGS...]`. When present, brush +//! dispatches early in `main()` to the registered function for `NAME`, before +//! any shell state is built, and exits with the function's return code. The +//! dispatched function has the same signature as `uutils`' `uumain`: +//! `fn(Vec) -> i32`, with the bundled name as `argv[0]`. +//! +//! ## Shell integration +//! +//! For every entry in the registry, [`register_shims`] installs a brush +//! builtin (using `register_builtin_if_unset`, so brush's own builtins always +//! win on conflict). The builtin's execution path uses brush-core's existing +//! external-command machinery to spawn `current_exe() +//! `, inheriting the shell's redirection state for free. +//! +//! The mechanism is generic — the registry is just `name → fn pointer`. The +//! `experimental-bundled-coreutils` feature populates it with uutils, but +//! anything matching the signature can be registered. + +use std::collections::HashMap; +use std::ffi::OsString; +use std::io::Write; +use std::path::PathBuf; +use std::sync::OnceLock; + +use brush_core::ExecutionExitCode; +use brush_core::builtins::{BoxFuture, ContentOptions, ContentType, Registration}; +use brush_core::commands::{self, CommandArg, ExecutionContext}; +use brush_core::extensions::ShellExtensions; + +/// The leading flag that signals a bundled-command dispatch. +/// +/// Deliberately obscure so that it's unlikely to collide with future +/// first-class shell flags or with scripts that happen to contain the +/// literal token. +pub const DISPATCH_FLAG: &str = "--invoke-bundled"; + +/// Signature of a bundled command's entry point — matches `uu_*::uumain`. +pub type BundledFn = fn(args: Vec) -> i32; + +/// Process-wide registry. Set once at startup, read on each shim invocation +/// (and during bundled-dispatch fast path). +static REGISTRY: OnceLock> = OnceLock::new(); + +/// Cached path to the running brush executable. Populated lazily on first +/// shim invocation; left as `Err`-equivalent if `current_exe()` fails. +static SELF_EXE: OnceLock> = OnceLock::new(); + +/// Installs the bundled-command registry. Idempotent: only the first call +/// takes effect. +#[allow( + clippy::implicit_hasher, + reason = "registry uses the default hasher; callers build with HashMap::new()" +)] +pub fn install(commands: HashMap) { + let _ = REGISTRY.set(commands); +} + +/// Installs the registry from all compiled-in providers. +/// +/// Providers are controlled by Cargo features. Binaries should call this +/// once, before [`maybe_dispatch`], so both the dispatch fast path and the +/// shell's shim builtins see a populated registry. +pub fn install_default_providers() { + #[allow(unused_mut)] + let mut commands: HashMap = HashMap::new(); + + #[cfg(feature = "experimental-bundled-coreutils")] + commands.extend(brush_coreutils_builtins::bundled_commands()); + + install(commands); +} + +/// Returns the registered bundled commands, if [`install`] was called. +#[must_use] +pub fn registry() -> Option<&'static HashMap> { + REGISTRY.get() +} + +/// Runs the bundled-command fast path if the process was invoked for it. +/// +/// If the process was invoked as `brush [ARGS...]` +/// (with `` as the very first argument after `argv[0]`), runs +/// the registered function and returns its exit code as `Some(code)`. The +/// caller is responsible for exiting the process with that code — +/// centralizing the exit call in the binary's `main()` keeps destructors / +/// panic hooks / tracing guards in the loop. +/// +/// Returns `None` when the process was not invoked as a bundled dispatch, so +/// normal shell startup can proceed. +/// +/// The dispatch flag is only recognized in the leading position so that +/// ordinary scripts and command lines containing the literal token elsewhere +/// are not affected. +#[must_use] +pub fn maybe_dispatch() -> Option { + let mut raw = std::env::args_os(); + let _argv0 = raw.next(); + let first = raw.next()?; + if first != DISPATCH_FLAG { + return None; + } + + // Everything after `DISPATCH_FLAG` belongs to the bundled command. The + // first such argument is the command name; subsequent arguments form its + // argv (with the name itself supplied as argv[0] to match the convention + // `uutils` and most CLI tools expect). + let rest: Vec = raw.collect(); + let Some((name, args)) = rest.split_first() else { + eprintln!("brush: {DISPATCH_FLAG} requires a command name"); + return Some(exit_code(ExecutionExitCode::InvalidUsage)); + }; + + // The registry is keyed by UTF-8 `String`, so a non-UTF-8 name can never + // match. Reject up front rather than allocating a lossy-substituted + // lookup key that could accidentally collide with a real registration. + let Some(name_str) = name.to_str() else { + eprintln!("brush: unknown bundled command: {}", name.to_string_lossy()); + return Some(exit_code(ExecutionExitCode::NotFound)); + }; + + let Some(func) = REGISTRY.get().and_then(|r| r.get(name_str)) else { + eprintln!("brush: unknown bundled command: {name_str}"); + return Some(exit_code(ExecutionExitCode::NotFound)); + }; + + let mut argv: Vec = Vec::with_capacity(1 + args.len()); + argv.push(name.clone()); + argv.extend(args.iter().cloned()); + + Some(func(argv)) +} + +fn exit_code(code: ExecutionExitCode) -> i32 { + u8::from(code).into() +} + +/// Returns the path to the running brush executable (cached). +fn self_exe() -> Option<&'static PathBuf> { + SELF_EXE + .get_or_init(|| std::env::current_exe().ok()) + .as_ref() +} + +/// Help/usage content provider for the shim builtin. brush calls this for +/// `help `, `type `, etc. +#[allow( + clippy::needless_pass_by_value, + clippy::unnecessary_wraps, + reason = "signature dictated by brush_core::builtins::CommandContentFunc" +)] +fn shim_content( + name: &str, + content_type: ContentType, + _options: &ContentOptions, +) -> Result { + match content_type { + ContentType::ShortDescription => Ok(format!("{name} - bundled command")), + ContentType::DetailedHelp => Ok(format!( + "{name} - bundled command (executes via `brush {DISPATCH_FLAG} {name}`)\n" + )), + // A bundled command never contributes its own short-usage or man page + // through this path; detailed help comes from the bundled utility + // itself (`brush --help` or equivalent). + ContentType::ShortUsage | ContentType::ManPage => Ok(String::new()), + } +} + +/// Builtin execute function shared by all bundled commands. Looks up the +/// invoked name from `context.command_name` and re-executes the running +/// brush binary as `brush `. +/// +/// Reuses the same entry point the `command` builtin uses (see +/// `brush-builtins/src/command.rs`): constructs a [`commands::SimpleCommand`] +/// whose `command_name` is the absolute brush exe path. Because that contains +/// a path separator, `SimpleCommand::execute` routes directly to the +/// external-execution path, bypassing the builtin/function lookup that would +/// otherwise re-enter this very shim. +/// +/// `use_functions = false` is defensive: even though the path-separator +/// branch already skips function dispatch, we don't want a hypothetical +/// refactor of `SimpleCommand` to silently break us. +// +// TODO(bundled): Process-group propagation. +// The shim leaves `SimpleCommand::process_group_id` as `None`, so when a +// bundled command appears in a pipeline it doesn't join the pipeline's +// pgid — job control and pipeline-wide signal delivery misbehave. +// `ExecutionContext` doesn't currently carry the dispatcher's pgid, so +// fixing this requires plumbing the pgid through the builtin dispatch +// boundary (likely as a field on `ExecutionParameters` or a new +// `ExecutionContext` accessor). +// +// TODO(bundled): Pipeline serialization. +// The builtin contract returns an `ExecutionResult` (a completed command), +// not an `ExecutionSpawnResult` (a spawn handle), so this function has to +// `.await` the child to completion before returning. That's fine for a +// standalone bundled command or for the tail of a pipeline, but for a +// bundled stage in the middle of `a | b | c` it means stage N only +// "starts" (from brush's perspective) after its child has fully exited — +// downstream stages get no parallelism with it. Fixing this means +// bypassing the builtin API for bundled dispatch: either detect the shim +// inside `SimpleCommand::execute`'s dispatch table and return an +// `ExecutionSpawnResult::StartedProcess` directly (same shape as external +// dispatch), or generalize the builtin API so a builtin can return a +// spawn handle instead of a finished result. +fn shim_execute( + context: ExecutionContext<'_, SE>, + args: Vec, +) -> BoxFuture<'_, Result> { + Box::pin(async move { + let exe_path = if let Some(p) = self_exe() { + p.to_string_lossy().into_owned() + } else { + let _ = writeln!( + context.stderr(), + "brush: cannot determine path to running executable" + ); + return Ok(ExecutionExitCode::CannotExecute.into()); + }; + + // Build the argv for the spawned brush. `SimpleCommand::args[0]` is + // dropped by the external-execution path (argv[0] of the spawned + // process comes from `cmd.argv0` below), so a placeholder suffices; + // args[1..] become the spawned process's argv[1..]. The caller's + // `args[0]` is the bundled name by builtin-dispatch convention — we + // replace it with an explicit `` after `DISPATCH_FLAG` so the + // child's dispatcher sees it in a fixed slot. + let bundled_name = context.command_name.clone(); + let mut child_args: Vec = Vec::with_capacity(args.len() + 2); + child_args.push(CommandArg::String(String::new())); // args[0], dropped + child_args.push(CommandArg::String(DISPATCH_FLAG.into())); + child_args.push(CommandArg::String(bundled_name.clone())); + child_args.extend(args.into_iter().skip(1)); + + let mut cmd = commands::SimpleCommand::new( + commands::ShellForCommand::ParentShell(context.shell), + context.params, + exe_path, + child_args, + ); + cmd.use_functions = false; + // Override the spawned process's argv[0] so tools that report errors + // via their own argv[0] (uutils' `uucore::util_name()` reads + // `std::env::args_os()[0]` into a LazyLock at first use) render as + // `:` rather than `brush:`. Without this the child sees the + // brush exe path as argv[0] and misattributes errors. + cmd.argv0 = Some(bundled_name); + + let spawn_result = cmd.execute().await?; + let wait_result = spawn_result.wait().await?; + Ok(wait_result.into()) + }) +} + +/// Constructs a [`Registration`] for the bundled-shim builtin. The same +/// registration value can be reused for every bundled name; per-name +/// dispatch happens via `context.command_name` at execution time. +fn shim_registration() -> Registration { + Registration { + execute_func: shim_execute::, + content_func: shim_content, + disabled: false, + special_builtin: false, + declaration_builtin: false, + } +} + +/// Registers a shim builtin for every name in the installed bundled-command +/// registry. +/// +/// Uses `register_builtin_if_unset` so brush's own builtins (echo, printf, +/// true, false, etc.) win on conflict. +pub fn register_shims(shell: &mut brush_core::Shell) { + let Some(registry) = REGISTRY.get() else { + return; + }; + for name in registry.keys() { + shell.register_builtin_if_unset(name.clone(), shim_registration::()); + } +} diff --git a/local/recipes/shells/brush/source/brush-shell/src/config.rs b/local/recipes/shells/brush/source/brush-shell/src/config.rs new file mode 100644 index 0000000000..b50a71e2d1 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/src/config.rs @@ -0,0 +1,446 @@ +//! Configuration file support for the brush shell. +//! +//! This module provides TOML-based configuration file loading with the following features: +//! - Forward-compatible: unknown fields are ignored +//! - Graceful degradation: parse errors are logged but don't prevent shell startup +//! - Layered configuration: defaults < config file < command-line arguments + +use brush_interactive::UIOptions; +use etcetera::BaseStrategy; +use std::{ + borrow::Cow, + path::{Path, PathBuf}, +}; + +use crate::args::CommandLineArgs; + +/// Root configuration structure for the brush shell. +/// +/// All fields are optional to support forward compatibility and partial configuration. +/// Unknown fields in the TOML file are silently ignored. +#[derive(Debug, Default, Clone, serde::Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(default)] +pub struct Config { + /// User interface configuration options. + pub ui: UiConfig, + + /// Experimental features configuration. + pub experimental: ExperimentalConfig, +} + +/// User interface configuration options. +#[derive(Debug, Default, Clone, serde::Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(default)] +pub struct UiConfig { + /// Enable syntax highlighting in the input line. + #[serde(rename = "syntax-highlighting")] + pub syntax_highlighting: Option, +} + +/// Experimental features configuration. +/// +/// These options control unstable features that may change or be removed in future versions. +#[derive(Debug, Default, Clone, serde::Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(default)] +pub struct ExperimentalConfig { + /// Enable zsh-style preexec/precmd hooks. + #[serde(rename = "zsh-hooks")] + pub zsh_hooks: Option, + + /// Enable terminal shell integration. + #[serde(rename = "terminal-shell-integration")] + pub terminal_shell_integration: Option, +} + +impl Config { + /// Converts the configuration to [`UIOptions`], merging with CLI arguments. + /// + /// Settings are applied with the following priority (highest to lowest): + /// 1. CLI arguments (if explicitly set, i.e., different from default) + /// 2. Config file values + /// 3. Default values + /// + /// CLI defaults are automatically inferred from clap's parsed defaults. + /// + /// # Arguments + /// + /// * `args` - The parsed command-line arguments + #[must_use] + pub fn to_ui_options(&self, args: &CommandLineArgs) -> UIOptions { + // Get clap's defaults by parsing an empty argument list. + // This lets us detect which CLI values were explicitly set vs. defaulted. + let defaults = CommandLineArgs::default_values(); + + let enable_highlighting = merge_bool_setting( + args.enable_highlighting, + defaults.enable_highlighting, + self.ui.syntax_highlighting, + ); + let terminal_shell_integration = merge_bool_setting( + args.terminal_shell_integration, + defaults.terminal_shell_integration, + self.experimental.terminal_shell_integration, + ); + let zsh_style_hooks = merge_bool_setting( + args.zsh_style_hooks, + defaults.zsh_style_hooks, + self.experimental.zsh_hooks, + ); + + UIOptions::builder() + .disable_bracketed_paste(args.disable_bracketed_paste) + .disable_color(args.disable_color) + .disable_highlighting(!enable_highlighting) + .terminal_shell_integration(terminal_shell_integration) + .zsh_style_hooks(zsh_style_hooks) + .build() + } +} + +/// Merges a boolean setting from CLI args, config file, and defaults. +/// +/// Priority: CLI (if explicitly set) > config file > default. +/// +/// Since boolean CLI flags can't distinguish between "explicitly set to false" and +/// "not provided" (both result in `false`), we use a heuristic: +/// - If the CLI value differs from the default, the user explicitly provided it +/// - Otherwise, use the config value if present, or fall back to the default +const fn merge_bool_setting( + cli_value: bool, + cli_default: bool, + config_value: Option, +) -> bool { + if cli_value != cli_default { + // CLI was explicitly set to a non-default value + cli_value + } else if let Some(config) = config_value { + // Use config file value + config + } else { + // Fall back to default + cli_default + } +} + +/// Result of attempting to load a configuration file. +#[derive(Debug, Default)] +pub struct ConfigLoadResult { + /// The loaded configuration, or default if loading failed. + pub config: Config, + + /// The path that was used (or attempted) for loading. + pub path: Option, + + /// Any error that occurred during loading. + pub error: Option, + + /// Whether the path was explicitly provided by the user (via `--config`). + pub explicit_path: bool, +} + +impl ConfigLoadResult { + /// Consumes the result and returns the configuration. + /// + /// If an error occurred: + /// - For explicit paths (user-provided via `--config`): returns `Err` with a formatted error + /// - For default paths: logs a warning and returns the default configuration + /// + /// # Errors + /// + /// Returns an error if an explicit config path was provided and loading failed. + pub fn into_config_or_log(self) -> Result { + let Some(err) = self.error else { + return Ok(self.config); + }; + + let path_display = self.path.as_ref().map_or(Cow::Borrowed(""), |p| { + Cow::Owned(p.display().to_string()) + }); + + if self.explicit_path { + // User explicitly provided --config; treat errors as fatal. + return Err(format!("failed to load config from {path_display}: {err}")); + } + + // Default config path; log warning but continue with defaults. + tracing::warn!("failed to load config from {path_display}: {err}"); + Ok(self.config) + } +} + +/// Errors that can occur when loading configuration. +#[derive(Debug, thiserror::Error)] +pub enum ConfigLoadError { + /// Failed to read the configuration file. + #[error("failed to read config file: {0}")] + Io(#[from] std::io::Error), + + /// Failed to parse the TOML content. + #[error("failed to parse config file: {0}")] + Parse(#[from] toml::de::Error), +} + +const CONFIG_SUBDIR_NAME: &str = "brush"; +const CONFIG_FILE_NAME: &str = "config.toml"; + +/// Returns the default configuration file path for the current platform. +/// +/// Uses the XDG Base Directory specification on Linux/macOS and appropriate +/// platform conventions on other systems via the `etcetera` crate. +/// +/// Returns `None` if the platform's config directory cannot be determined. +pub fn default_config_path() -> Option { + let strategy = etcetera::choose_base_strategy().ok()?; + Some( + strategy + .config_dir() + .join(CONFIG_SUBDIR_NAME) + .join(CONFIG_FILE_NAME), + ) +} + +/// Loads configuration from the specified path. +/// +/// Returns a `ConfigLoadResult` containing: +/// - The parsed configuration (or default on error) +/// - The path that was used +/// - Any error that occurred +/// +/// Note: This function sets `explicit_path` to `false`. Use `load_config` for +/// proper handling of explicit vs. default paths. +pub fn load_from_path(path: &Path) -> ConfigLoadResult { + let content = match std::fs::read_to_string(path) { + Ok(content) => content, + Err(e) => { + return ConfigLoadResult { + path: Some(path.to_path_buf()), + error: Some(ConfigLoadError::Io(e)), + ..Default::default() + }; + } + }; + + match toml::from_str(&content) { + Ok(config) => ConfigLoadResult { + config, + path: Some(path.to_path_buf()), + ..Default::default() + }, + Err(e) => ConfigLoadResult { + path: Some(path.to_path_buf()), + error: Some(ConfigLoadError::Parse(e)), + ..Default::default() + }, + } +} + +/// Loads configuration based on the provided options. +/// +/// # Arguments +/// +/// * `disabled` - If true, skip loading and return defaults +/// * `explicit_path` - If provided, use this path instead of the default +/// +/// # Returns +/// +/// A `ConfigLoadResult` containing the configuration and any errors encountered. +/// If `explicit_path` is provided and loading fails, the result will have +/// `explicit_path: true` to indicate that the error should be treated as fatal. +pub fn load_config(disabled: bool, explicit_path: Option<&Path>) -> ConfigLoadResult { + if disabled { + return ConfigLoadResult::default(); + } + + let is_explicit = explicit_path.is_some(); + + let path = match explicit_path { + Some(p) => p.to_path_buf(), + None => match default_config_path() { + Some(p) => p, + None => { + // Can't determine config path; use defaults silently + return ConfigLoadResult::default(); + } + }, + }; + + // If using default path and file doesn't exist, silently use defaults + if !is_explicit && !path.exists() { + return ConfigLoadResult { + path: Some(path), + ..Default::default() + }; + } + + let mut result = load_from_path(&path); + result.explicit_path = is_explicit; + result +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + + #[test] + fn empty_config() { + let config: Config = toml::from_str("").unwrap(); + assert!(config.ui.syntax_highlighting.is_none()); + assert!(config.experimental.zsh_hooks.is_none()); + assert!(config.experimental.terminal_shell_integration.is_none()); + } + + #[test] + fn full_config() { + let toml = r" + [ui] + syntax-highlighting = true + + [experimental] + zsh-hooks = true + terminal-shell-integration = false + "; + + let config: Config = toml::from_str(toml).unwrap(); + assert_eq!(config.ui.syntax_highlighting, Some(true)); + assert_eq!(config.experimental.zsh_hooks, Some(true)); + assert_eq!(config.experimental.terminal_shell_integration, Some(false)); + } + + #[test] + fn partial_config() { + let toml = r" + [ui] + syntax-highlighting = false + "; + + let config: Config = toml::from_str(toml).unwrap(); + assert_eq!(config.ui.syntax_highlighting, Some(false)); + assert!(config.experimental.zsh_hooks.is_none()); + } + + #[test] + fn unknown_fields_ignored() { + let toml = r#" + [ui] + syntax-highlighting = true + unknown-field = "should be ignored" + another-unknown = 42 + + [experimental] + zsh-hooks = false + future-feature = true + + [unknown-section] + foo = "bar" + "#; + + let config: Config = toml::from_str(toml).unwrap(); + assert_eq!(config.ui.syntax_highlighting, Some(true)); + assert_eq!(config.experimental.zsh_hooks, Some(false)); + } + + #[test] + fn load_config_disabled() { + let result = load_config(true, None); + assert!(result.path.is_none()); + assert!(result.error.is_none()); + } + + #[test] + fn load_config_nonexistent_default() { + // When using default path and file doesn't exist, should return defaults without error + let result = load_config(false, None); + // We may or may not get a path depending on platform, but shouldn't error + assert!(result.error.is_none()); + } + + #[test] + fn load_config_nonexistent_explicit() { + let path = Path::new("/nonexistent/path/to/config.toml"); + let result = load_config(false, Some(path)); + assert!(result.error.is_some()); + assert!(matches!(result.error, Some(ConfigLoadError::Io(_)))); + } + + #[test] + fn to_ui_options_defaults_only() { + let config = Config::default(); + let args = CommandLineArgs::default_values(); + let ui = config.to_ui_options(&args); + + assert!(!ui.disable_bracketed_paste); + assert!(!ui.disable_color); + // Note: whether highlighting is enabled by default depends on the compile-time + // DEFAULT_ENABLE_HIGHLIGHTING constant (true with reedline, false without) + assert!(!ui.terminal_shell_integration); + assert!(!ui.zsh_style_hooks); + } + + #[test] + fn to_ui_options_config_overrides_defaults() { + let toml = r" + [ui] + syntax-highlighting = true + + [experimental] + zsh-hooks = true + terminal-shell-integration = true + "; + let config: Config = toml::from_str(toml).unwrap(); + let args = CommandLineArgs::default_values(); + + // CLI values match defaults, so config should take effect + let ui = config.to_ui_options(&args); + + assert!(!ui.disable_highlighting); // config enabled highlighting + assert!(ui.terminal_shell_integration); + assert!(ui.zsh_style_hooks); + } + + #[test] + fn to_ui_options_cli_overrides_config() { + let toml = r" + [ui] + syntax-highlighting = false + + [experimental] + zsh-hooks = false + "; + let config: Config = toml::from_str(toml).unwrap(); + + // Simulate CLI explicitly setting values different from defaults + // by parsing with the flags enabled + let args = CommandLineArgs::try_parse_from([ + "brush", + "--enable-highlighting", + "--enable-zsh-hooks", + ]) + .unwrap(); + + // CLI explicitly enables highlighting and zsh-hooks (differs from default) + let ui = config.to_ui_options(&args); + + assert!(!ui.disable_highlighting); // CLI enabled highlighting + assert!(ui.zsh_style_hooks); // CLI enabled + } + + #[test] + fn to_ui_options_cli_only_settings() { + let config = Config::default(); + let args = CommandLineArgs::try_parse_from([ + "brush", + "--disable-bracketed-paste", + "--disable-color", + ]) + .unwrap(); + + let ui = config.to_ui_options(&args); + + assert!(ui.disable_bracketed_paste); + assert!(ui.disable_color); + } +} diff --git a/local/recipes/shells/brush/source/brush-shell/src/entry.rs b/local/recipes/shells/brush/source/brush-shell/src/entry.rs new file mode 100644 index 0000000000..384f0caa62 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/src/entry.rs @@ -0,0 +1,833 @@ +//! Implements the command-line interface for the `brush` shell. + +use crate::args::CommandLineArgs; +use crate::args::InputBackendType; +use crate::brushctl::ShellBuilderBrushBuiltinExt as _; +use crate::bundled; +use crate::config; +use crate::error_formatter; +use crate::events; +use crate::productinfo; +use brush_builtins::ShellBuilderExt as _; +#[cfg(feature = "experimental-builtins")] +use brush_experimental_builtins::ShellBuilderExt as _; +use clap::CommandFactory; +use std::sync::LazyLock; +use std::{path::Path, sync::Arc}; +use tokio::sync::Mutex; + +#[allow(unused_imports, reason = "only used in some configs")] +use std::io::IsTerminal; + +static TRACE_EVENT_CONFIG: LazyLock>>> = + LazyLock::new(|| Arc::new(tokio::sync::Mutex::new(None))); + +type BrushShellExtensions = brush_core::extensions::ShellExtensionsImpl; +type BrushShell = brush_core::Shell; + +// WARN: this implementation shadows `clap::Parser::parse_from` one so it must be defined +// after the `use clap::Parser` +impl CommandLineArgs { + // Work around clap's limitation handling `--` like a regular value + // TODO(cmdline): We can safely remove this `impl` after the issue is resolved + // https://github.com/clap-rs/clap/issues/5055 + // This function takes precedence over [`clap::Parser::parse_from`] + fn try_parse_from(itr: impl IntoIterator) -> Result { + let mut args: Vec = itr.into_iter().collect(); + + // In bash, `-c` treats `--` as an option terminator and takes its + // command string from the first argument *after* `--`. (Other + // value-taking flags like `-o` and `-O` instead consume `--` as their + // literal value in bash, rejecting it as an invalid option name.) + // + // Remove the `--` so that `-c` naturally consumes the next token as its + // value via clap. Other value-taking flags are unaffected: for them + // try_parse_known splits at `--` before clap sees it, so they still + // produce an error for invocations like `-o --`/`-O --` (via a missing + // value rather than an invalid option name). In both cases, we + // intentionally do not treat `--` as an option terminator for those + // flags. + if let Some(dd_idx) = args.iter().position(|a| a == "--") { + if let Some(flag_idx) = dd_idx + .checked_sub(1) + .filter(|&i| Self::has_pending_c_flag(&args[i])) + { + // Remove the option-terminating `--`. + args.remove(dd_idx); + + // If the command value (now at dd_idx) is itself `--`, merge it + // into the flag as an attached value (e.g., "-c" + "--" → "-c--"). + // Clap parses `-c--` as `-c` with value `"--"` (standard POSIX + // short-option-with-attached-value syntax). This prevents + // try_parse_known from splitting at it again. + if args.get(dd_idx).map(String::as_str) == Some("--") { + let value = args.remove(dd_idx); + args[flag_idx].push_str(&value); + } + } + } + + let (mut this, script_args) = brush_core::builtins::try_parse_known::(args)?; + + // Collect any args from after `--` (handled by try_parse_known) into + // script_args, which become positional parameters ($0, $1, ...). + if let Some(args) = script_args { + this.script_args.extend(args); + } + + Ok(this) + } + + /// Returns true if `arg` is `-c` or a combined short-flag group ending in + /// `c` (like `-ec`) where all preceding characters are boolean flags. + /// + /// This specifically targets `-c` because it is the only short flag with + /// special `--` option-terminator behavior in bash. Other value-taking flags + /// (`-o`, `-O`) consume `--` as their literal value instead. + /// + /// Uses clap's argument definitions to validate preceding flags, avoiding + /// a hardcoded list of boolean flag characters. + fn has_pending_c_flag(arg: &str) -> bool { + // Must be a short flag group ending in 'c': "-c", "-ec", "-xec", etc. + let Some(flags) = arg.strip_prefix('-') else { + return false; + }; + let Some(preceding) = flags.strip_suffix('c') else { + return false; + }; + // Reject long-option-like args (e.g., "--c"). + if preceding.starts_with('-') { + return false; + } + + // For "-c" alone, preceding is empty and the check below is vacuously + // true. For combined flags like `-ec`, verify all chars before the + // trailing `c` are boolean flags. If any preceding char takes a value + // (like `o`), then `c` is consumed as that flag's value, not as `-c`. + let cmd = Self::command(); + preceding.chars().all(|ch| { + cmd.get_arguments().any(|a| { + a.get_short() == Some(ch) + && !matches!( + a.get_action(), + clap::ArgAction::Set | clap::ArgAction::Append + ) + }) + }) + } +} + +/// Main entry point for the `brush` shell. +pub fn run() { + // + // Install the bundled-command registry so it's available both for + // bundled dispatch (handled next) and for builtin shim registration + // during shell construction. With no bundled-providing features enabled + // the registry is empty and both code paths become no-ops. + // + bundled::install_default_providers(); + + // + // If we were invoked as `brush [args...]`, run the + // bundled command and exit before doing any shell setup. This is the + // hot path for in-binary coreutils invocations. + // + if let Some(code) = bundled::maybe_dispatch() { + std::process::exit(code); + } + + // + // Install panic handlers to clean up on panic. + // + install_panic_handlers(); + + // + // Parse args. + // + let mut args: Vec<_> = std::env::args().collect(); + + // Work around clap's limitations handling +O options. + for arg in &mut args { + if arg.starts_with("+O") { + arg.insert_str(0, "--"); + } + } + + let parsed_args = match CommandLineArgs::try_parse_from(args.iter().cloned()) { + Ok(parsed_args) => parsed_args, + Err(e) => { + let _ = e.print(); + + // Check for whether this is something we'd truly consider fatal. clap returns + // errors for `--help`, `--version`, etc. + let exit_code = match e.kind() { + clap::error::ErrorKind::DisplayVersion => 0, + clap::error::ErrorKind::DisplayHelp => 0, + _ => 2, + }; + + std::process::exit(exit_code); + } + }; + + // + // Run. + // + // Redox's tokio multi-threaded runtime fails to build (EINVAL on worker + // thread setup); the current-thread runtime is the correct model there and + // is all an interactive shell needs. + #[cfg(all(any(unix, windows), not(target_os = "redox")))] + let mut builder = tokio::runtime::Builder::new_multi_thread(); + #[cfg(any(not(any(unix, windows)), target_os = "redox"))] + let mut builder = tokio::runtime::Builder::new_current_thread(); + + let Ok(runtime) = builder.enable_all().build() else { + tracing::error!("error: failed to create Tokio runtime"); + std::process::exit(1); + }; + + let result = runtime.block_on(run_async(&args, parsed_args)); + + let exit_code = match result { + Ok(code) => code, + Err(err) => { + tracing::error!("error: {err:#}"); + 1 + } + }; + + std::process::exit(i32::from(exit_code)); +} + +/// Installs panic handlers to report our panic and cleanly exit on panic. +fn install_panic_handlers() { + // + // Set up panic handler. On release builds, it will capture panic details to a + // temporary .toml file and report a human-readable message to the screen. + // + human_panic::setup_panic!( + human_panic::Metadata::new(productinfo::PRODUCT_NAME, productinfo::PRODUCT_VERSION) + .homepage(env!("CARGO_PKG_HOMEPAGE")) + .support("please post a GitHub issue at https://github.com/reubeno/brush/issues/new") + ); + + // + // If stdout is connected to a terminal, then register a new panic handler that + // resets the terminal and then invokes the previously registered handler. In + // dev/debug builds, the previously registered handler will be the default + // handler; in release builds, it will be the one registered by `human_panic`. + // + if std::io::stdout().is_terminal() { + let original_panic_handler = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |panic_info| { + // Best-effort attempt to reset the terminal to defaults. + let _ = try_reset_terminal_to_defaults(); + + // Invoke the original handler + original_panic_handler(panic_info); + })); + } +} + +#[cfg(feature = "experimental")] +pub(crate) const DEFAULT_ENABLE_HIGHLIGHTING: bool = true; +#[cfg(not(feature = "experimental"))] +pub(crate) const DEFAULT_ENABLE_HIGHLIGHTING: bool = false; + +/// Run the brush shell. Returns the exit code. +/// +/// # Arguments +/// +/// * `cli_args` - The command-line arguments to the shell, in string form. +/// * `args` - The already-parsed command-line arguments. +#[doc(hidden)] +async fn run_async( + cli_args: &[String], + args: CommandLineArgs, +) -> Result { + // Initializing tracing. + let mut event_config = TRACE_EVENT_CONFIG.lock().await; + *event_config = Some(events::TraceEventConfig::init( + &args.enabled_debug_events, + &args.disabled_events, + )); + drop(event_config); + + // Load configuration file. + let file_config = config::load_config(args.no_config, args.config_file.as_deref()) + .into_config_or_log() + .map_err(|e| brush_interactive::ShellError::IoError(std::io::Error::other(e)))?; + + // Instantiate an appropriately configured shell and wrap it in an `Arc`. Note that we do + // *not* run any code in the shell yet. We'll delay loading profiles and such until after + // we've set up everything else (in `run_in_shell`). + let shell: BrushShell = instantiate_shell(&args, cli_args).await?; + let shell = Arc::new(Mutex::new(shell)); + + // Run with the selected input backend. Each branch instantiates the concrete + // backend type and calls `run_in_shell`, preserving static dispatch. + let default_backend = get_default_input_backend_type(&args); + let selected_backend = args.input_backend.unwrap_or(default_backend); + + // Build UI options by merging config file with CLI args. + #[allow(unused_variables, reason = "not used when no backend features enabled")] + let ui_options = file_config.to_ui_options(&args); + + let result = match selected_backend { + #[cfg(all(feature = "reedline", any(unix, windows)))] + InputBackendType::Reedline => { + let mut input_backend = + brush_interactive::ReedlineInputBackend::new(&ui_options, &shell)?; + run_in_shell(&shell, args, &mut input_backend, &ui_options).await + } + #[cfg(any(not(feature = "reedline"), not(any(unix, windows))))] + InputBackendType::Reedline => Err(brush_interactive::ShellError::InputBackendNotSupported), + + #[cfg(feature = "basic")] + InputBackendType::Basic => { + let mut input_backend = brush_interactive::BasicInputBackend; + run_in_shell(&shell, args, &mut input_backend, &ui_options).await + } + #[cfg(not(feature = "basic"))] + InputBackendType::Basic => Err(brush_interactive::ShellError::InputBackendNotSupported), + + #[cfg(feature = "minimal")] + InputBackendType::Minimal => { + let mut input_backend = brush_interactive::MinimalInputBackend; + run_in_shell(&shell, args, &mut input_backend, &ui_options).await + } + #[cfg(not(feature = "minimal"))] + InputBackendType::Minimal => Err(brush_interactive::ShellError::InputBackendNotSupported), + }; + + // Display any error that percolated up. + let exit_code = match result { + Ok(code) => code, + Err(brush_interactive::ShellError::ShellError(e)) => { + let shell = shell.lock().await; + let mut stderr = shell.stderr(); + let _ = shell.display_error(&mut stderr, &e); + drop(shell); + 1 + } + Err(err) => { + tracing::error!("error: {err:#}"); + 1 + } + }; + + Ok(exit_code) +} + +/// Determines whether `run_in_shell` will run the shell interactively. Must be sync'd with it. +const fn will_run_interactively(args: &CommandLineArgs) -> bool { + if args.command.is_some() { + false + } else if args.read_commands_from_stdin { + true + } else { + args.script_args.is_empty() + } +} + +/// Runs the shell according to the provided command-line arguments. +/// Also responsible for loading profiles and rc files as appropriate. +/// +/// # Arguments +/// +/// * `shell_ref` - A reference to the shell to run. +/// * `args` - The parsed command-line arguments. +/// * `input_backend` - The input backend to use. +/// * `ui_options` - The user interface options to use. +async fn run_in_shell( + shell_ref: &brush_interactive::ShellRef, + args: CommandLineArgs, + input_backend: &mut impl brush_interactive::InputBackend, + ui_options: &brush_interactive::UIOptions, +) -> Result { + // First load profile and rc files as appropriate. + initialize_shell(shell_ref, &args).await?; + + // If a command was specified via -c, then run that command and then exit. + if let Some(command) = args.command { + shell_ref.lock().await.run_dash_c_command(command).await?; + + // If -s was provided, then read commands from stdin. If there was a script (and optionally + // args) passed on the command line via positional arguments, then we copy over the + // parameters but do *not* execute it. + } else if args.read_commands_from_stdin { + let interactive_options = ui_options.into(); + brush_interactive::InteractiveShell::new(shell_ref, input_backend, &interactive_options)? + .run_interactively() + .await?; + + // If a script path was provided, then run the script. + } else if !args.script_args.is_empty() { + // The path to a script was provided on the command line; run the script. + shell_ref + .lock() + .await + .run_script( + Path::new(&args.script_args[0]), + args.script_args.iter().skip(1), + ) + .await?; + + // If we got down here, then we don't have any commands to run. We'll be reading + // them in from stdin one way or the other. + } else { + let interactive_options = ui_options.into(); + brush_interactive::InteractiveShell::new(shell_ref, input_backend, &interactive_options)? + .run_interactively() + .await?; + } + + // Make sure to return the last result observed in the shell. + let result = shell_ref.lock().await.last_exit_status(); + + Ok(result) +} + +/// Initializes a shell by loading profile and rc files as appropriate. +/// +/// # Arguments +/// +/// * `shell_ref` - A reference to the shell to initialize. +/// * `args` - The parsed command-line arguments. +async fn initialize_shell( + shell_ref: &brush_interactive::ShellRef, + args: &CommandLineArgs, +) -> Result<(), brush_interactive::ShellError> { + // Compute desired profile-loading behavior. + let profile = if args.no_profile { + brush_core::ProfileLoadBehavior::Skip + } else { + brush_core::ProfileLoadBehavior::LoadDefault + }; + + // Compute desired rc-loading behavior. + let rc = if args.no_rc { + brush_core::RcLoadBehavior::Skip + } else if let Some(rc_file) = &args.rc_file { + brush_core::RcLoadBehavior::LoadCustom(rc_file.clone()) + } else { + brush_core::RcLoadBehavior::LoadDefault + }; + + shell_ref.lock().await.load_config(&profile, &rc).await?; + + Ok(()) +} + +/// Instantiates a shell from command-line arguments. Does *not* run any code in the shell. +/// +/// # Arguments +/// +/// * `args` - The parsed command-line arguments. +/// * `cli_args` - The raw command-line arguments. +async fn instantiate_shell( + args: &CommandLineArgs, + cli_args: &[String], +) -> Result { + #[cfg(feature = "experimental-load")] + let mut shell = if let Some(load_file) = &args.load_file { + instantiate_shell_from_file(load_file.as_path())? + } else { + instantiate_shell_from_args(args, cli_args).await? + }; + + #[cfg(not(feature = "experimental-load"))] + let mut shell = instantiate_shell_from_args(args, cli_args).await?; + + // Register shims for any bundled commands in the installed registry. + // Done here (not inside the inner instantiators) so both paths are + // covered from a single site. + bundled::register_shims(&mut shell); + + Ok(shell) +} + +#[cfg(feature = "experimental-load")] +fn instantiate_shell_from_file( + file_path: &Path, +) -> Result { + let mut shell: BrushShell = serde_json::from_reader(std::fs::File::open(file_path)?) + .map_err(|e| brush_interactive::ShellError::IoError(std::io::Error::other(e)))?; + + // NOTE: We need to manually register builtins because we can't serialize/deserialize them. + // TODO(serde): we should consider whether we could/should at least track *which* are enabled. + let builtin_set = if shell.options().sh_mode { + brush_builtins::BuiltinSet::ShMode + } else { + brush_builtins::BuiltinSet::BashMode + }; + + let builtins = brush_builtins::default_builtins(builtin_set); + + for (builtin_name, builtin) in builtins { + shell.register_builtin(&builtin_name, builtin); + } + + // Add experimental builtins (if enabled). + #[cfg(feature = "experimental-builtins")] + for (builtin_name, builtin) in brush_experimental_builtins::experimental_builtins() { + shell.register_builtin(&builtin_name, builtin); + } + + Ok(shell) +} + +/// Instantiates a shell from command-line arguments. Does *not* run any code in the shell. +/// +/// # Arguments +/// +/// * `args` - The parsed command-line arguments. +/// * `cli_args` - The raw command-line arguments. +async fn instantiate_shell_from_args( + args: &CommandLineArgs, + cli_args: &[String], +) -> Result { + // Compute login flag. + let login = args.login || cli_args.first().is_some_and(|argv0| argv0.starts_with('-')); + + // Compute shell name. + let shell_name = if args.command.is_some() && !args.script_args.is_empty() { + Some(args.script_args[0].clone()) + } else if !cli_args.is_empty() { + Some(cli_args[0].clone()) + } else if args.sh_mode { + // Simulate having been run as "sh". + Some(String::from("sh")) + } else { + None + }; + + // Compute positional shell arguments. + let shell_args = if args.command.is_some() { + Some(args.script_args.iter().skip(1).cloned().collect()) + } else if args.read_commands_from_stdin { + Some(args.script_args.clone()) + } else { + None + }; + + // Commands are read from stdin if -s was provided, or if no command was specified (either via + // -c or as a positional argument). + let read_commands_from_stdin = (args.read_commands_from_stdin && args.command.is_none()) + || (args.script_args.is_empty() && args.command.is_none()); + + let builtin_set = if args.sh_mode { + brush_builtins::BuiltinSet::ShMode + } else { + brush_builtins::BuiltinSet::BashMode + }; + + // Identify the file descriptors to inherit. + let fds = args + .inherited_fds + .iter() + .filter_map(|&fd| brush_core::sys::fd::try_get_file_for_open_fd(fd).map(|file| (fd, file))) + .collect(); + + // Select parser implementation to use. + #[cfg(feature = "experimental-parser")] + let parser_impl = if args.experimental_parser { + brush_core::parser::ParserImpl::Winnow + } else { + brush_core::parser::ParserImpl::Peg + }; + + #[cfg(not(feature = "experimental-parser"))] + let parser_impl = brush_core::parser::ParserImpl::Peg; + + // Set up the shell builder with the requested options. + // NOTE: We skip loading profile and rc files here; that will be handled later after we've + // fully instantiated everything we want set before running any code. + let shell = brush_core::Shell::builder_with_extensions::() + .disable_options(args.disabled_options.clone()) + .disable_shopt_options(args.disabled_shopt_options.clone()) + .disallow_overwriting_regular_files_via_output_redirection( + args.disallow_overwriting_regular_files_via_output_redirection, + ) + .enable_options(args.enabled_options.clone()) + .enable_shopt_options(args.enabled_shopt_options.clone()) + .do_not_execute_commands(args.do_not_execute_commands) + .exit_after_one_command(args.exit_after_one_command) + .login(login) + .interactive(args.is_interactive()) + .command_string_mode(args.command.is_some()) + .no_editing(args.no_editing) + .profile(brush_core::ProfileLoadBehavior::Skip) + .rc(brush_core::RcLoadBehavior::Skip) + .do_not_inherit_env(args.do_not_inherit_env) + .fds(fds) + .maybe_shell_args(shell_args) + .posix(args.posix || args.sh_mode) + .print_commands_and_arguments(args.print_commands_and_arguments) + .read_commands_from_stdin(read_commands_from_stdin) + .maybe_shell_name(shell_name) + .shell_product_display_str(productinfo::get_product_display_str()) + .sh_mode(args.sh_mode) + .treat_unset_variables_as_error(args.treat_unset_variables_as_error) + .exit_on_nonzero_command_exit(args.exit_on_nonzero_command_exit) + .disable_pathname_expansion(args.disable_pathname_expansion) + .verbose(args.verbose) + .parser(parser_impl) + .error_formatter(new_error_behavior(args)) + .shell_version(env!("CARGO_PKG_VERSION").to_string()); + + // Add builtins. + let shell = shell.default_builtins(builtin_set).brush_builtins(); + + // Add experimental builtins (if enabled). + #[cfg(feature = "experimental-builtins")] + let shell = shell.experimental_builtins(); + + // Build the shell. + let mut shell = shell.build().await?; + + // Make adjustments. + if let Some(xtrace_file_path) = &args.xtrace_file_path { + enable_xtrace_to_file(&mut shell, xtrace_file_path)?; + } + + Ok(shell) +} + +fn enable_xtrace_to_file( + shell: &mut brush_core::Shell, + file_path: &Path, +) -> Result<(), brush_interactive::ShellError> { + let file = std::fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(file_path) + .map_err(|e| { + brush_interactive::ShellError::FailedToCreateXtraceFile(file_path.to_path_buf(), e) + })?; + + let file = brush_core::openfiles::OpenFile::from(file); + let file_fd = shell.open_files_mut().add(file)?; + + shell.options_mut().print_commands_and_arguments = true; + shell.set_env_global( + "BASH_XTRACEFD", + brush_core::ShellVariable::new(file_fd.to_string()), + )?; + + Ok(()) +} + +const fn new_error_behavior(args: &CommandLineArgs) -> error_formatter::Formatter { + error_formatter::Formatter { + use_color: !args.disable_color, + } +} + +fn get_default_input_backend_type(args: &CommandLineArgs) -> InputBackendType { + // On Redox, reedline's crossterm raw-mode terminal handling does not yet + // work over the fbcon console scheme: crossterm's raw-mode event poll never + // receives key events, so the interactive shell hangs without reading input. + // Plain line-based stdin reads DO work over fbcon (getty/login read the + // username that way), so use the minimal backend, which reads stdin + // directly. Remove this once reedline/crossterm raw mode is supported. + #[cfg(target_os = "redox")] + { + let _args = args; + InputBackendType::Minimal + } + #[cfg(all(any(unix, windows), not(target_os = "redox")))] + { + // If stdin isn't a terminal, then `reedline` doesn't do the right thing + // (reference: https://github.com/nushell/reedline/issues/509). Switch to + // the minimal input backend instead for that scenario. + if std::io::stdin().is_terminal() && will_run_interactively(args) { + InputBackendType::Reedline + } else { + InputBackendType::Minimal + } + } + #[cfg(not(any(unix, windows)))] + { + let _args = args; + InputBackendType::Minimal + } +} + +pub(crate) fn get_event_config() -> Arc>> { + TRACE_EVENT_CONFIG.clone() +} + +fn try_reset_terminal_to_defaults() -> Result<(), std::io::Error> { + #[cfg(any(unix, windows))] + { + // Reset the console. + let exec_result = crossterm::execute!( + std::io::stdout(), + crossterm::terminal::LeaveAlternateScreen, + crossterm::terminal::EnableLineWrap, + crossterm::style::ResetColor, + crossterm::event::DisableMouseCapture, + crossterm::event::DisableBracketedPaste, + crossterm::cursor::Show, + crossterm::cursor::MoveToNextLine(1), + ); + + let raw_result = crossterm::terminal::disable_raw_mode(); + + exec_result?; + raw_result?; + } + + Ok(()) +} + +#[cfg(test)] +#[allow(clippy::panic_in_result_fn)] +mod tests { + use super::*; + use anyhow::Result; + use pretty_assertions::{assert_eq, assert_matches}; + + fn args(strs: &[&str]) -> Vec { + strs.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn parse_empty_args() -> Result<()> { + let parsed_args = CommandLineArgs::try_parse_from(args(&["brush"]))?; + assert_matches!(parsed_args.script_args.as_slice(), []); + Ok(()) + } + + #[test] + fn parse_script_and_args() -> Result<()> { + let parsed_args = CommandLineArgs::try_parse_from(args(&[ + "brush", + "some-script", + "-x", + "1", + "--option", + ]))?; + assert_eq!( + parsed_args.script_args, + ["some-script", "-x", "1", "--option"] + ); + Ok(()) + } + + #[test] + fn parse_script_and_args_with_double_dash_in_script_args() -> Result<()> { + let parsed_args = CommandLineArgs::try_parse_from(args(&["brush", "some-script", "--"]))?; + assert_eq!(parsed_args.script_args, ["some-script", "--"]); + Ok(()) + } + + #[test] + fn parse_unknown_args() { + let result = CommandLineArgs::try_parse_from(args(&["brush", "--unknown-option"])); + assert!(result.is_err()); + } + + #[test] + fn parse_c_with_double_dash_separator() -> Result<()> { + let parsed_args = + CommandLineArgs::try_parse_from(args(&["brush", "-c", "--", "echo hello", "arg0"]))?; + assert_eq!(parsed_args.command, Some("echo hello".to_string())); + assert_eq!(parsed_args.script_args, ["arg0"]); + Ok(()) + } + + #[test] + fn parse_c_with_double_dash_no_command() { + assert!(CommandLineArgs::try_parse_from(args(&["brush", "-c", "--"])).is_err()); + } + + #[test] + fn parse_c_with_double_dash_command_is_double_dash() -> Result<()> { + let parsed_args = + CommandLineArgs::try_parse_from(args(&["brush", "-c", "--", "--", "echo", "hi"]))?; + assert_eq!(parsed_args.command, Some("--".to_string())); + assert_eq!(parsed_args.script_args, ["echo", "hi"]); + Ok(()) + } + + #[test] + fn parse_ec_with_double_dash_separator() -> Result<()> { + let parsed_args = + CommandLineArgs::try_parse_from(args(&["brush", "-ec", "--", "echo hello", "arg0"]))?; + assert_eq!(parsed_args.command, Some("echo hello".to_string())); + assert!(parsed_args.exit_on_nonzero_command_exit); + assert_eq!(parsed_args.script_args, ["arg0"]); + Ok(()) + } + + #[test] + fn parse_c_with_value_before_double_dash_unchanged() -> Result<()> { + let parsed_args = + CommandLineArgs::try_parse_from(args(&["brush", "-c", "echo hi", "--", "arg0"]))?; + assert_eq!(parsed_args.command, Some("echo hi".to_string())); + assert_eq!(parsed_args.script_args, ["--", "arg0"]); + Ok(()) + } + + #[test] + fn parse_o_with_double_dash_is_not_transformed() { + // Unlike -c, bash's -o consumes -- as its literal value (invalid option + // name), not as an option terminator. Verify we don't transform it. + let result = CommandLineArgs::try_parse_from(args(&["brush", "-o", "--"])); + // Here, try_parse_from / try_parse_known splits at --, so -o ends up + // without a value and parsing correctly fails. The key assertion is + // that we MUST NOT reinterpret -- as an option terminator for -o and + // then take any later argument as its value. + assert!(result.is_err()); + } + + #[test] + fn parse_oc_not_treated_as_pending_c() -> Result<()> { + // -oc means -o with value "c", not -o flag + -c flag. The -- + // should NOT be treated as an option terminator for -c. + let parsed_args = CommandLineArgs::try_parse_from(args(&["brush", "-oc", "--", "echo"]))?; + // -o consumed "c" as its value; -- split the rest; no -c command. + assert!(parsed_args.command.is_none()); + assert_eq!(parsed_args.script_args, ["--", "echo"]); + Ok(()) + } + + #[test] + fn parse_bool_flag_before_double_dash_not_transformed() -> Result<()> { + // -e is a boolean flag, not -c. The -- should NOT be removed; + // everything from -- onward becomes positional (including -c). + let parsed_args = + CommandLineArgs::try_parse_from(args(&["brush", "-e", "--", "-c", "echo"]))?; + assert!(parsed_args.command.is_none()); + assert!(parsed_args.exit_on_nonzero_command_exit); + assert_eq!(parsed_args.script_args, ["--", "-c", "echo"]); + Ok(()) + } + + #[test] + fn parse_c_with_double_dash_and_later_double_dash() -> Result<()> { + // After removing the first --, -c gets "echo". The second -- is + // handled by try_parse_known and appears in script_args. + let parsed_args = + CommandLineArgs::try_parse_from(args(&["brush", "-c", "--", "echo", "--", "more"]))?; + assert_eq!(parsed_args.command, Some("echo".to_string())); + assert_eq!(parsed_args.script_args, ["--", "more"]); + Ok(()) + } + + #[test] + fn has_pending_c_flag_edge_cases() { + // Direct tests for the detection function. + assert!(CommandLineArgs::has_pending_c_flag("-c")); + assert!(CommandLineArgs::has_pending_c_flag("-ec")); + assert!(!CommandLineArgs::has_pending_c_flag("-C")); // uppercase, different flag + assert!(!CommandLineArgs::has_pending_c_flag("-oc")); // -o takes a value + assert!(!CommandLineArgs::has_pending_c_flag("--c")); // long-option-like + assert!(!CommandLineArgs::has_pending_c_flag("-")); // bare dash + assert!(!CommandLineArgs::has_pending_c_flag("c")); // no leading dash + assert!(!CommandLineArgs::has_pending_c_flag("")); // empty + } +} diff --git a/local/recipes/shells/brush/source/brush-shell/src/error_formatter.rs b/local/recipes/shells/brush/source/brush-shell/src/error_formatter.rs new file mode 100644 index 0000000000..7142fe6b1d --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/src/error_formatter.rs @@ -0,0 +1,20 @@ +#[derive(Debug, Default, Clone)] +pub(crate) struct Formatter { + pub use_color: bool, +} + +impl brush_core::extensions::ErrorFormatter for Formatter { + fn format_error( + &self, + err: &brush_core::error::Error, + _shell: &brush_core::Shell, + ) -> String { + let prefix = if self.use_color { + color_print::cstr!("error: ") + } else { + "error: " + }; + + std::format!("{prefix}{err:#}\n") + } +} diff --git a/local/recipes/shells/brush/source/brush-shell/src/events.rs b/local/recipes/shells/brush/source/brush-shell/src/events.rs new file mode 100644 index 0000000000..72f3d8f55b --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/src/events.rs @@ -0,0 +1,184 @@ +//! Facilities for configuring event tracing in the brush shell. + +use std::{collections::HashSet, fmt::Display}; + +use brush_core::Error; +use tracing_subscriber::{ + Layer, Registry, filter::Targets, layer::SubscriberExt, reload::Handle, util::SubscriberInitExt, +}; + +/// Type of event to trace. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, clap::ValueEnum)] +pub enum TraceEvent { + /// Traces parsing and evaluation of arithmetic expressions. + #[clap(name = "arithmetic")] + Arithmetic, + /// Traces command execution. + #[clap(name = "commands")] + Commands, + /// Traces command completion generation. + #[clap(name = "complete")] + Complete, + /// Traces word expansion. + #[clap(name = "expand")] + Expand, + /// Traces functions. + #[clap(name = "functions")] + Functions, + /// Traces input controls. + #[clap(name = "input")] + Input, + /// Traces job management. + #[clap(name = "jobs")] + Jobs, + /// Traces the process of parsing tokens into an abstract syntax tree. + #[clap(name = "parse")] + Parse, + /// Traces pattern matching. + #[clap(name = "pattern")] + Pattern, + /// Traces the process of tokenizing input text. + #[clap(name = "tokenize")] + Tokenize, + /// Traces usage of unimplemented functionality. + #[clap(name = "unimplemented", alias = "unimp")] + Unimplemented, +} + +impl Display for TraceEvent { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Arithmetic => write!(f, "arithmetic"), + Self::Commands => write!(f, "commands"), + Self::Complete => write!(f, "complete"), + Self::Expand => write!(f, "expand"), + Self::Functions => write!(f, "functions"), + Self::Input => write!(f, "input"), + Self::Jobs => write!(f, "jobs"), + Self::Parse => write!(f, "parse"), + Self::Pattern => write!(f, "pattern"), + Self::Tokenize => write!(f, "tokenize"), + Self::Unimplemented => write!(f, "unimplemented"), + } + } +} + +#[derive(Default)] +pub(crate) struct TraceEventConfig { + enabled_debug_events: HashSet, + disabled_events: HashSet, + handle: Option>, +} + +impl TraceEventConfig { + pub fn init(enabled_debug_events: &[TraceEvent], disabled_events: &[TraceEvent]) -> Self { + let enabled_debug_events: HashSet = + enabled_debug_events.iter().copied().collect(); + let disabled_events: HashSet = disabled_events.iter().copied().collect(); + + let mut config = Self { + enabled_debug_events, + disabled_events, + ..Default::default() + }; + + let filter = config.compose_filter(); + + // Make the filter reloadable so that we can change the log level at runtime. + let (reload_filter, handle) = tracing_subscriber::reload::Layer::new(filter); + + let layer = tracing_subscriber::fmt::layer() + .with_writer(std::io::stderr) + .without_time() + .with_target(false) + .with_filter(reload_filter); + + if tracing_subscriber::registry() + .with(layer) + .try_init() + .is_ok() + { + config.handle = Some(handle); + } else { + // Something went wrong; proceed on anyway but complain audibly. + eprintln!("warning: failed to initialize tracing."); + } + + config + } + + fn compose_filter(&self) -> tracing_subscriber::filter::Targets { + let mut filter = tracing_subscriber::filter::Targets::new() + .with_default(tracing_subscriber::filter::LevelFilter::INFO); + + for event in &self.enabled_debug_events { + let targets = Self::event_to_tracing_targets(event); + filter = filter.with_targets( + targets + .into_iter() + .map(|target| (target, tracing::Level::DEBUG)), + ); + } + + for event in &self.disabled_events { + let targets = Self::event_to_tracing_targets(event); + filter = filter.with_targets( + targets + .into_iter() + .map(|target| (target, tracing::level_filters::LevelFilter::OFF)), + ); + } + + filter + } + + const fn event_to_tracing_targets(event: &TraceEvent) -> [&str; 1] { + match event { + TraceEvent::Arithmetic => ["arithmetic"], + TraceEvent::Commands => ["commands"], + TraceEvent::Complete => ["completion"], + TraceEvent::Expand => ["expansion"], + TraceEvent::Functions => ["functions"], + TraceEvent::Input => ["input"], + TraceEvent::Jobs => ["jobs"], + TraceEvent::Parse => ["parse"], + TraceEvent::Pattern => ["pattern"], + TraceEvent::Tokenize => ["tokenize"], + TraceEvent::Unimplemented => ["unimplemented"], + } + } + + pub const fn get_enabled_events(&self) -> &HashSet { + &self.enabled_debug_events + } + + pub fn enable(&mut self, event: TraceEvent) -> Result<(), Error> { + // Don't bother to reload config if nothing has changed. + if !self.enabled_debug_events.insert(event) { + return Ok(()); + } + + self.reload_filter() + } + + pub fn disable(&mut self, event: TraceEvent) -> Result<(), Error> { + // Don't bother to reload config if nothing has changed. + if !self.enabled_debug_events.remove(&event) { + return Ok(()); + } + + self.reload_filter() + } + + fn reload_filter(&self) -> Result<(), Error> { + if let Some(handle) = &self.handle { + if handle.reload(self.compose_filter()).is_ok() { + Ok(()) + } else { + Err(brush_core::ErrorKind::Unimplemented("failed to enable tracing events").into()) + } + } else { + Err(brush_core::ErrorKind::Unimplemented("tracing not initialized").into()) + } + } +} diff --git a/local/recipes/shells/brush/source/brush-shell/src/lib.rs b/local/recipes/shells/brush/source/brush-shell/src/lib.rs new file mode 100644 index 0000000000..a3f746f883 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/src/lib.rs @@ -0,0 +1,12 @@ +//! Create for brush, an executable bash-compatible shell. + +#![allow(dead_code)] + +pub mod args; +mod brushctl; +pub mod bundled; +pub mod config; +pub mod entry; +mod error_formatter; +pub mod events; +mod productinfo; diff --git a/local/recipes/shells/brush/source/brush-shell/src/main.rs b/local/recipes/shells/brush/source/brush-shell/src/main.rs new file mode 100644 index 0000000000..97abd67727 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/src/main.rs @@ -0,0 +1,5 @@ +//! Main entry for the `brush` shell. + +fn main() { + brush_shell::entry::run(); +} diff --git a/local/recipes/shells/brush/source/brush-shell/src/productinfo.rs b/local/recipes/shells/brush/source/brush-shell/src/productinfo.rs new file mode 100644 index 0000000000..3e84aeb504 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/src/productinfo.rs @@ -0,0 +1,32 @@ +//! Information about this shell project. + +/// The formal name of this product. +pub const PRODUCT_NAME: &str = "brush"; + +const PRODUCT_HOMEPAGE: &str = env!("CARGO_PKG_HOMEPAGE"); +const PRODUCT_REPO: &str = env!("CARGO_PKG_REPOSITORY"); + +/// The URI to display as the product's homepage. +#[allow(clippy::const_is_empty)] +pub const PRODUCT_DISPLAY_URI: &str = if !PRODUCT_HOMEPAGE.is_empty() { + PRODUCT_HOMEPAGE +} else { + PRODUCT_REPO +}; + +/// The version of the product, in string form. +pub const PRODUCT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// Info regarding the specific version of sources used to build this product. +pub const PRODUCT_GIT_VERSION: &str = git_version::git_version!( + prefix = "git:", + cargo_prefix = "cargo:", + fallback = "unknown:", + args = ["--always", "--dirty=-modified", "--match", ""] +); + +pub(crate) fn get_product_display_str() -> String { + std::format!( + "{PRODUCT_NAME} version {PRODUCT_VERSION} ({PRODUCT_GIT_VERSION}) - {PRODUCT_DISPLAY_URI}" + ) +} diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/brush/cli.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/brush/cli.yaml new file mode 100644 index 0000000000..ebb2b669a6 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/brush/cli.yaml @@ -0,0 +1,89 @@ +name: "CLI tests" +cases: + - name: "Basic echo" + stdin: "echo hello world" + expected_exit_code: 0 + expected_stdout: "hello world\n" + + - name: "Exit code propagates" + stdin: "exit 42" + expected_exit_code: 42 + # WASI 0.2's wasi-cli/exit interface only models success/failure, so any + # non-zero exit code is clamped to 1 by the host (e.g., wasmtime). + incompatible_platforms: ["wasi"] + + - name: "Echo via args" + args: + - "-c" + - "echo from args" + expected_exit_code: 0 + expected_stdout: "from args\n" + + - name: "Version flag succeeds" + args: + - "--version" + expected_exit_code: 0 + # Note: can't easily check stdout content as it varies + + - name: "Help flag succeeds" + args: + - "--help" + expected_exit_code: 0 + + - name: "Invalid option fails" + args: + - "--unknown-argument-here" + expected_exit_code: 2 + # Exit code 2 for argument parsing errors. + # WASI 0.2's wasi-cli/exit interface only models success/failure, so any + # non-zero exit code is clamped to 1 by the host (e.g., wasmtime). + incompatible_platforms: ["wasi"] + + - name: "Explicit config file not found fails" + removed_default_args: + - "--no-config" + args: + - "--config" + - "/nonexistent/path/to/config.toml" + - "-c" + - "echo should_not_run" + expected_exit_code: 1 + + - name: "Explicit config file valid" + removed_default_args: + - "--no-config" + test_files: + - path: "config.toml" + contents: | + [ui] + syntax-highlighting = false + + [experimental] + zsh-hooks = false + args: + - "--config" + - "config.toml" + - "-c" + - "echo config_loaded" + expected_exit_code: 0 + expected_stdout: "config_loaded\n" + + - name: "Config file with unknown fields succeeds" + removed_default_args: + - "--no-config" + test_files: + - path: "config.toml" + contents: | + [ui] + syntax-highlighting = true + future-setting = "ignored" + + [unknown-section] + foo = "bar" + args: + - "--config" + - "config.toml" + - "-c" + - "echo forward_compat" + expected_exit_code: 0 + expected_stdout: "forward_compat\n" diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/brush/snaps/tracing_xtrace-file_captures_trace_output.snap b/local/recipes/shells/brush/source/brush-shell/tests/cases/brush/snaps/tracing_xtrace-file_captures_trace_output.snap new file mode 100644 index 0000000000..20aa8f0446 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/brush/snaps/tracing_xtrace-file_captures_trace_output.snap @@ -0,0 +1,12 @@ +--- +source: brush-test-harness/src/runner.rs +expression: snapshot_content +--- +exit_code: 0 +stdout: | + hello +stderr: "" +files: + trace.txt: | + + echo hello + + true diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/brush/tracing.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/brush/tracing.yaml new file mode 100644 index 0000000000..976dd85c81 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/brush/tracing.yaml @@ -0,0 +1,14 @@ +name: "Tracing tests" +cases: + - name: "xtrace-file captures trace output" + args: ["--xtrace-file", "trace.txt"] + stdin: | + echo hello + true + expected_exit_code: 0 + snapshot: true + # std::fs::File::try_clone() returns "operation not supported" on + # wasm32-wasip2, so brush's OpenFile::clone() falls back to a failing + # writer and the trace file ends up empty. Affects all output redirection + # under wasi, not just xtrace. + incompatible_platforms: ["wasi"] diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/and_or.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/and_or.yaml new file mode 100644 index 0000000000..3dd66ec0a4 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/and_or.yaml @@ -0,0 +1,83 @@ +name: "and/or" +cases: + - name: "Basic &&" + stdin: | + false && echo 1 + true && echo 2 + + - name: "Basic ||" + stdin: | + false || echo 1 + true || echo 2 + + - name: "Longer chains" + stdin: | + false || false || false || echo "Got to the end" + echo "1" && echo "2" && echo "3" && echo "4" + + - name: "Mixed chains" + stdin: | + false && true || echo "1. Got to the end" + false && false || echo "2. Got to the end" + true && false || echo "3. Got to the end" + + - name: "And with return in function" + stdin: | + f() { + echo "Before" && return 42 && echo "After return" + echo "After and-or" + } + f + echo "Exit code: $?" + + - name: "And with exit" + stdin: | + echo "Before" && exit 42 && echo "After exit" + echo "After and-or" + + - name: "And with break in loop" + stdin: | + for i in 1 2 3; do + echo "Iteration $i" && break && echo "After break" + echo "After and-or" + done + echo "After loop" + + - name: "And with continue in loop" + stdin: | + for i in 1 2 3; do + echo "Iteration $i" && continue && echo "After continue" + echo "After and-or" + done + echo "After loop" + + - name: "Or with return in function" + stdin: | + f() { + false || return 42 || echo "After return" + echo "After or-list" + } + f + echo "Exit code: $?" + + - name: "Or with exit" + stdin: | + false || exit 42 || echo "After exit" + echo "After or-list" + + - name: "Or with break in loop" + stdin: | + for i in 1 2 3; do + false || break || echo "After break" + echo "After or-list" + done + echo "After loop" + + - name: "Or with continue in loop" + stdin: | + for i in 1 2 3; do + echo "Iteration $i" + false || continue || echo "After continue" + echo "After or-list" + done + echo "After loop" diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/arguments.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/arguments.yaml new file mode 100644 index 0000000000..576e15daf7 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/arguments.yaml @@ -0,0 +1,122 @@ +name: "Argument handling tests" +common_test_files: + - path: "script.sh" + contents: | + echo \"$0\" \"$1\" \"$2\" \"$@\" + +cases: + - name: "-c mode arguments without --" + args: + - "-c" + - 'echo \"$0\" \"$1\" \"$2\" \"$@\"' + - 1 + - "-2" + - "3" + + - name: "-c mode arguments with --" + args: + - "-c" + - 'echo \"$0\" \"$1\" \"$2\" \"$@\"' + - "--" + - 1 + - 2 + - 3 + + - name: "-c mode and arguments with +O" + args: + - "+O" + - "nullglob" + - "-c" + - 'echo \"$0\" \"$1\" \"$2\" \"$@\"' + - "--" + - 1 + - 2 + - 3 + + - name: "-c mode -- torture" + args: + - "-c" + - 'echo \"$0\" \"$1\" \"$2\" \"$@\"' + - -- + - -- + - -&-1 + - --! + - "\"-2\"" + - "''--''" + - 3--* + + - name: "-c modeonly one --" + args: + - "-c" + - 'echo \"$0\" \"$1\" \"$2\" \"$@\"' + - -- + + - name: "script arguments without --" + args: + - script.sh + - -1 + - -2 + - -3 + + - name: "script arguments with --" + args: + - script.sh + - -- + - --1 + - -2 + - 3 + + - name: "script -- torture" + args: + - script.sh + - -- + - "--" + - -- + - -!-1* + - "\"-2\"" + - -- + - 3-- + + - name: "script only one --" + args: + - script.sh + - -- + + - name: "-c with -- separator and command" + args: + - "-c" + - "--" + - 'echo \"$0\" \"$1\" \"$2\" \"$@\"' + - "1" + - "-2" + - "3" + + - name: "-c with -- separator only" + ignore_stderr: true + args: + - "-c" + - "--" + + - name: "-c with -- separator where command is --" + ignore_stderr: true + args: + - "-c" + - "--" + - "--" + - "echo" + - "hi" + + - name: "-ec with -- separator and command" + args: + - "-ec" + - "--" + - 'echo \"$0\" \"$1\" \"$2\" \"$@\"' + - "1" + - "-2" + - "3" + + - name: "-o with -- is not an option terminator" + ignore_stderr: true + args: + - "-o" + - "--" diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/arithmetic.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/arithmetic.yaml new file mode 100644 index 0000000000..b3a4df4cf6 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/arithmetic.yaml @@ -0,0 +1,362 @@ +name: "Arithmetic" +cases: + - name: "Empty expression" + stdin: | + echo $(()) + + - name: "Unquoted arithmetic" + stdin: | + echo $((1+1)) + + - name: "Arithmetic literals" + stdin: | + echo "$((0))" + echo "$((1))" + echo "$((10))" + echo "$((010))" + echo "$((0010))" + echo "$((0x10))" + echo "$((0x010))" + + - name: "Arithmetic literals with explicit bases" + stdin: | + echo "$((2#1010))" + + echo "$((8#16))" + + echo "$((16#faf))" + echo "$((16#FAF))" + + echo "$((36#vvv))" + echo "$((36#VVV))" + + # Base 37: A-Z map to 36-61 + echo "$((37#a))" + echo "$((37#a0))" + echo "$((37#A))" + + # Base 62: a-z=10-35, A-Z=36-61 + echo "$((62#A))" + echo "$((62#a))" + echo "$((62#Z))" + echo "$((62#z))" + + # Base 63: @ = 62 + echo "$((63#@))" + echo "$((63#@9))" + + # Base 64: _ = 63 + echo "$((64#_))" + echo "$((64#@))" + echo "$((64#_1))" + echo "$((64#zzz))" + + - name: "Unary arithmetic operators with space" + stdin: | + echo "$(( - 1 ))" + echo "$(( + 1 ))" + echo "$(( ~ 10 ))" + echo "$(( ! 10 ))" + + - name: "Parentheses" + stdin: | + echo "$(((10)))" + echo "$((((10))))" + echo "$(((((10)))))" + + echo "$(( (10) ))" + echo "$(( (1+2) ))" + echo "$(( (1 + 2) ))" + + echo "$(( ((1+2)) ))" + echo "$(( ((1 + 2)) ))" + + echo "$(((1+2)*3))" + echo "$(((1 + 2) * 3))" + echo "$(( (1 + 2) * 3 ))" + + echo "$((((1+2)*3)))" + echo "$(( ((1 + 2) * 3) ))" + echo "$(( ( (1 + 2) * 3 ) ))" + + - name: "Basic quoted arithmetic" + stdin: | + echo " 1 + 1 == $((1+1))" + echo " 2 * 3 == $((2*3))" + echo " 9 / 3 == $((9/3))" + echo "10 % 3 == $((10%3))" + echo " 2 ** 3 == $((2**3))" + echo " 2 | 4 == $((2|4))" + echo " 2 & 4 == $((2&4))" + echo " 2 ^ 3 == $((2^3))" + echo " 1 && 1 == $((1&&1))" + echo " 1 && 0 == $((1&&0))" + echo " 1 || 0 == $((1||0))" + echo " 0 || 1 == $((1||0))" + echo " 1 , 2 == $((1,2))" + + - name: "Unary operators" + stdin: | + echo " -1 == $((-1))" + echo " +1 == $((+1))" + echo " ~10 == $((~10))" + echo " !10 == $((!10))" + + - name: "Conditional operator" + stdin: | + echo "1 ? 2 : 3 == $((1?2:3))" + echo "0 ? 2 : 3 == $((0?2:3))" + + - name: "Arithmetic with spacing" + stdin: | + echo $(( 75 + 68 )) + + - name: "Divide by zero" + ignore_stderr: true + stdin: | + echo "1 / 0 == $((1/0))" + echo "Result: $?" + + echo "1 % 0 == $((1%0))" + echo "Result: $?" + + - name: "Shift arithmetic" + stdin: | + echo "32 >> 2 == $((32>>2))" + echo " 1 << 4 == $((1<<4))" + + - name: Shift arithmetic in conditional" + stdin: | + (( 1 >> 1 )) && echo "1. true" + (( 1 << 1 )) && echo "2. true" + + - name: "Variable references" + stdin: | + x=10 + echo "x => $((x))" + echo "x + 1 => $((x+1))" + + y= + echo "y => $((y))" + echo "y + 1 => $((y+1))" + + z=notnumber + echo "z => $((z))" + echo "z + 1 => $((z+1))" + + - name: "Complex variable references" + stdin: | + x=10 + y=20 + z=x+y + a=z+z + + echo "z => $((z))" + echo "a => $((a))" + + - name: "Nested expressions" + stdin: | + echo "1: $(($(echo -n 1; echo 2) + 37))" + + op="+" + echo "2: $((10 ${op} 20))" + + expr="13 * 7" + echo "3: $(($expr))" + + - name: "Assignment arithmetic" + stdin: | + x=0 + + echo "x = 1 => $((x = 1))" + echo "x is now $x" + + echo "x += 3 => $((x += 3))" + echo "x is now $x" + + echo "x -= 1 => $((x -= 1))" + echo "x is now $x" + + echo "x *= 2 => $((x *= 2))" + echo "x is now $x" + + echo "x++ == $((x++))" + echo "x is now $x" + + echo "++x == $((++x))" + echo "x is now $x" + + echo "x-- == $((x--))" + echo "x is now $x" + + echo "--x == $((--x))" + echo "x is now $x" + + echo "x |= 3 => $((x |= 3))" + echo "x is now $x" + + echo "x &= 12 => $((x &= 12))" + echo "x is now $x" + + echo "x %= 8 => $((x %= 8))" + echo "x is now $x" + + echo "x ^= 7 => $((x ^= 7))" + echo "x is now $x" + + - name: "Assignment with references" + stdin: | + x=10+3 + echo "y = x => $((y = x))" + echo "y is now $y" + + - name: "Assignments in logical boolean expressions" + stdin: | + x=0 + echo "0 && x+=1 => $(( 0 && (x+=1) ))" + echo "x: $x" + + x=0 + echo "1 && x+=1 => $(( 1 && (x+=1) ))" + echo "x: $x" + + x=0 + echo "0 || x+=1 => $(( 0 || (x+=1) ))" + echo "x: $x" + + x=0 + echo "1 || x+=1 => $(( 1 || (x+=1) ))" + echo "x: $x" + + - name: "Array arithmetic" + stdin: | + a=(1 2 3) + declare -p a + echo "a[0]: $((a[0]))" + echo "a[2]: $((a[2]))" + echo "a[3]: $((a[3]))" + + echo "b[2]: $((b[2]))" + + echo "a[1]=4 => $((a[1]=4))" + echo "a[1]: $((a[1]))" + + echo "a[1]+=1 => $((a[1]+=1))" + echo "a[1]: $((a[1]))" + + echo "c[1] += 3 => $((c[1] += 3))" + + - name: "Basic arithmetic comparison" + stdin: | + echo "0 < 1: $((0 < 1))" + echo "0 <= 1: $((0 <= 1))" + echo "0 == 1: $((0 == 1))" + echo "0 != 1: $((0 != 1))" + echo "0 > 1: $((0 > 1))" + echo "0 >= 1: $((0 >= 1))" + echo "0 < 0: $((0 < 0))" + echo "0 <= 0: $((0 <= 0))" + echo "0 == 0: $((0 == 0))" + echo "0 != 0: $((0 != 0))" + echo "0 > 0: $((0 > 0))" + echo "0 >= 0: $((0 >= 0))" + echo "1 < 0: $((1 < 0))" + echo "1 <= 0: $((1 <= 0))" + echo "1 == 0: $((1 == 0))" + echo "1 != 0: $((1 != 0))" + echo "1 > 0: $((1 > 0))" + echo "1 >= 0: $((1 >= 0))" + + - name: "Legacy bracket arithmetic syntax" + stdin: | + x=5 + x=$[x + 1] + echo "x after increment: $x" + echo "Direct: $[1 + 2]" + + - name: "Legacy bracket arithmetic in extended test" + stdin: | + number=4 + [[ $[number % 2] == 0 ]] && echo "even" || echo "odd" + number=5 + [[ $[number % 2] == 0 ]] && echo "even" || echo "odd" + + - name: "Legacy bracket arithmetic in double-quoted string" + stdin: | + echo "Quoted: result: $[2 * 3]" + + - name: "Legacy bracket arithmetic complex expression" + stdin: | + echo "Complex: $[1 + 2 * 3 - 4 / 2 % 2]" + + - name: "Legacy bracket arithmetic with array subscript" + stdin: | + arr=(10 20 30) + echo "Array elem: $[arr[1] + 5]" + + - name: "Prefix increment/decrement with space before lvalue" + stdin: | + x=5 + echo "++ x => $(( ++ x ))" + echo "x is now $x" + echo "-- x => $(( -- x ))" + echo "x is now $x" + + # Also test in (( )) command form + y=10 + (( ++ y )) + echo "y after ++ y: $y" + (( -- y )) + echo "y after -- y: $y" + + - name: "INT64_MIN and wrapping arithmetic" + stdin: | + # INT64_MIN via unary minus of the literal + echo "$(( -9223372036854775808 ))" + + # Wrapping negation of INT64_MIN + echo "$(( - (-9223372036854775808) ))" + + # INT64_MIN % -1 should be 0 (not crash) + echo "$(( (-9223372036854775808) % (-1) ))" + + # INT64_MIN / -1 wraps + echo "$(( (-9223372036854775808) / (-1) ))" + + # Wrapping increment/decrement near boundaries + echo "$(( 9223372036854775807 + 1 ))" # wraps to INT64_MIN + + - name: "Non-cyclic variable dereference chain" + stdin: | + a=b + b=c + c=42 + echo "$((a))" + + - name: "Self-referencing variable in arithmetic" + ignore_stderr: true + stdin: | + a=a + echo "$(( a ))" + echo "exit: $?" + + - name: "Recursive variable evaluation with depth limit" + ignore_stderr: true + stdin: | + # Recursive variables should not cause stack overflow + a=b + b=a + echo "$(( a ))" + echo "exit: $?" + + - name: "Arithmetic with nameref variable" + known_failure: true + stdin: | + counter=10 + declare -n ref=counter + echo "before: $counter" + (( ref++ )) + echo "after ++: $counter" + (( ref += 5 )) + echo "after +=5: $counter" + echo "expr: $(( ref * 2 ))" diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/arrays.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/arrays.yaml new file mode 100644 index 0000000000..fcb1a6f942 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/arrays.yaml @@ -0,0 +1,393 @@ +name: "Arrays" +incompatible_configs: ["sh"] +common_test_files: + - path: "helpers.sh" + contents: | + stable_print_assoc_array() { + # TODO(nameref): enable use of nameref when implemented; for now + # we assume the name of the array is assoc_array + # local -n assoc_array=$1 + local key + + for key in $(printf "%s\n" "${!assoc_array[@]}" | sort -n); do + echo "\"${key}\" => ${assoc_array[${key}]}" + done + } +cases: + - name: "Basic indexed array definition and access" + stdin: | + var=(a b c) + echo "var: $var" + echo "var: ${var}" + echo "var[0]: ${var[0]}" + echo "var[1]: ${var[1]}" + echo "var[2]: ${var[2]}" + echo "var[3]: ${var[3]}" + echo "var[4]: ${var[4]}" + echo "var[*]: ${var[*]}" + declare -p var + + - name: "Negative indexed array access" + stdin: | + var=(a b c) + echo "var[-1]: ${var[-1]}" + echo "var[-2]: ${var[-2]}" + echo "var[-3]: ${var[-3]}" + + - name: "Too large negative indexed array access" + ignore_stderr: true + stdin: | + var=(a b c) + echo "var[-4]: ${var[-4]}" + + - name: "Negative indexed array access (set)" + stdin: | + var=(a b c d e f g) + var[-1]=x + var[-3]=z + echo "var[*]: ${var[*]}" + declare -p var + + - name: "Too large negative indexed array access (set)" + ignore_stderr: true + stdin: | + var=(a b c) + var[-4]=x + echo "var[*]: ${var[*]}" + declare -p var + + - name: "Define array with expansion" + stdin: | + body='a b' + + var=($body) + declare -p var + + declare -a var2=($body) + declare -p var2 + + - name: "Arrays with empty values" + stdin: | + var1=(a "" c) + declare -p var1 + + var2=(a "$nonexistent" c) + declare -p var2 + + var3=("" "" "") + declare -p var3 + + - name: "Basic associative array definition and access" + stdin: | + declare -A var=([0]=99 [a]=3 [b]=4 [c]=5) + echo "var: $var" + echo "var: ${var}" + echo "var[0]: ${var[0]}" + echo "var[a]: ${var[a]}" + echo "var[b]: ${var[b]}" + echo "var[c]: ${var[c]}" + echo "var[d]: ${var[d]}" + + key="a" + echo "var[key]: ${var[${key}]}" + + - name: "Array updating via index" + stdin: | + var=(a b c) + var[1]="d" + echo "var[*]: ${var[*]}" + declare -p var + + - name: "Array index assignment" + stdin: | + x=(3 2 1) + y[${x[0]}]=10 + y[x[1]]=11 + declare -p y + + - name: "Arithmetic index assignment on declared-but-unset variable" + stdin: | + declare a + i=1 + a[i]=help + declare -p a + + - name: "Arithmetic index assignment on local declared-but-unset variable" + stdin: | + f() { + local a + i=1 + a[i]=help + declare -p a + } + f + + - name: "Arithmetic index assignment on scalar variable" + stdin: | + a=foo + i=1 + a[i]=help + declare -p a + + - name: "Arithmetic index assignment on undeclared variable" + stdin: | + i=1 + a[i]=help + declare -p a + + - name: "Replacing array with string" + stdin: | + var=(a b c) + var="x" + echo "var[*]: ${var[*]}" + declare -p var + + - name: "Array replacing" + stdin: | + var=(a b c) + var=(d e f) + echo "var[*]: ${var[*]}" + declare -p var + + - name: "Appending array to indexed array" + stdin: | + var=(a b c) + var+=(e f g) + echo "var[*]: ${var[*]}" + declare -p var + + var+=("" "" "") + declare -p var + + var+=(h "i" "$nonexistent" j) + var+=(j k l) + declare -p var + + - name: "Appending array to declared-but-unset indexed array" + stdin: | + declare -a var + var+=(e f g) + echo "var[*]: ${var[*]}" + declare -p var + + - name: "Appending item to indexed array" + stdin: | + var=(a b c) + var+=d + echo "var[*]: ${var[*]}" + declare -p var + + - name: "Appending item to element of indexed array" + stdin: | + declare -a var=(1 2 3) + var[1]+=2 + echo "var[*]: ${var[*]}" + declare -p var + + - name: "Appending item to element of int indexed array" + stdin: | + declare -ai var=(1 2 3) + var[1]+=2 + echo "var[*]: ${var[*]}" + declare -p var + + - name: "Appending items to int indexed array" + stdin: | + declare -ai var=(1 2 3) + var+=(4 value 6) + declare -p var + + - name: "Appending array to associative array" + stdin: | + source helpers.sh + declare -A assoc_array=([a]=1 [b]=2 [c]=3) + assoc_array+=([d]=4 [e]=5 [f]=6) + stable_print_assoc_array assoc_array + + - name: "Appending array to declared-but-unset associative array" + stdin: | + source helpers.sh + declare -A assoc_array + assoc_array+=([d]=4 [e]=5 [f]=6) + stable_print_assoc_array assoc_array + + - name: "Appending item to associative array" + stdin: | + source helpers.sh + declare -A assoc_array=([a]=1 [b]=2 [c]=3) + assoc_array+=d + stable_print_assoc_array assoc_array + + - name: "Appending item to element of associative array" + stdin: | + source helpers.sh + declare -A assoc_array=([a]=1 [b]=2 [c]=3) + assoc_array[b]+=2 + stable_print_assoc_array assoc_array + + - name: "Appending item to element of int associative array" + stdin: | + source helpers.sh + declare -Ai assoc_array=([a]=1 [b]=2 [c]=3) + assoc_array[b]+=2 + stable_print_assoc_array assoc_array + + - name: "Appending items to int associative array" + stdin: | + source helpers.sh + declare -Ai assoc_array=([a]=1 [b]=2 [c]=3) + assoc_array+=([d]=4 [e]=value [f]=6) + stable_print_assoc_array assoc_array + + - name: "Appending array to item" + stdin: | + var=x + var+=(a b c) + echo "var[*]: ${var[*]}" + declare -p var + + - name: "Fill associative array" + stdin: | + declare -Ag myarray + + myfunc() { + local myarg=$1 element + shift + for element in "$@"; do + echo "Setting: myarray[$element]=$myarg" + myarray[$element]="${myarg}${element}" + done + } + + myfunc 'first' bunzip2 bzcat pbunzip2 pbzcat lbunzip2 lbzcat + + - name: "Declare via array index" + stdin: | + declare -A assoc_array["key"]="value" + declare -p assoc_array + + declare -a indexed_array[5]="value" + declare -p indexed_array + + declare implicit_array[5]="value" + declare -p implicit_array + + declare -A no_value_assoc_array["key"] + declare -p no_value_assoc_array + + declare no_value_indexed_array[33] + declare -p no_value_indexed_array + + - name: "Create array via index update" + stdin: | + i=0 + my_array[i++]="c" + echo "0: i=$i array=${my_array[@]}" + my_array[i++]="b" + echo "1: i=$i array=${my_array[@]}" + my_array[i++]="a" + echo "2: i=$i array=${my_array[@]}" + + - name: "Expansion of array body" + stdin: | + elements="a b c" + + array1=(${elements}) + declare -p array1 + + array2=("${elements}") + declare -p array2 + + - name: "Copy array" + stdin: | + src=(a b c) + + dest1=("${src[*]}") + declare -p dest1 + + dest2=(${src[*]}) + declare -p dest2 + + dest3=("${src[@]}") + declare -p dest3 + + dest4=(${src[@]}) + declare -p dest4 + + - name: "Multi-line definition of array" + stdin: | + array=( + a + b + c + ) + declare -p array + + - name: "Dump values from non-existent array" + stdin: | + for key in ${NONEXISTENT[@]}; do + echo "@-Key: ${key}" + done + for key in "${NONEXISTENT[@]}"; do + echo "@-Quoted Key: ${key}" + done + + for key in ${NONEXISTENT[*]}; do + echo "*-Key: ${key}" + done + for key in "${NONEXISTENT[*]}"; do + echo "*-Quoted Key: ${key}" + done + + - name: "Use non-existent var in indexed array index" + stdin: | + declare -a myarray=(10) + echo "Value: ${myarray[$non_existent_var]}" + + - name: "Use non-existent var in associative array index" + ignore_stderr: true + stdin: | + declare -A myarray=([0]="default" ["a"]="other") + echo "Value: ${myarray[$non_existent_var]}" + + - name: "Nested array reference" + stdin: | + x=(2 3 4) + echo ${x[${x[0]}]} + + - name: "Append array-resulting expansion to array" + stdin: | + declare -a otherarray=("a" "b" "c") + declare -a ourarray=() + + ourarray+=("${otherarray[@]/a/x}") + declare -p otherarray + declare -p ourarray + + - name: "Array declaration in a function" + stdin: | + outer() { + inner=() + if test true; then + echo true + else + echo false + fi + } + + outer + + - name: "Array element expansion with non-standard IFS" + stdin: | + IFS='|' + + declare -a myarray=(abc 1 f) + echo "\"\${myarray[@]}\": ${myarray[@]}" + echo "\"\${myarray[*]}\": ${myarray[*]}" + echo "\${myarray[@]}:" ${myarray[@]} + echo "\${myarray[*]}:" ${myarray[*]} + + - name: "Empty array with newline" + stdin: | + declare -a myarray=( + ) diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/assignments.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/assignments.yaml new file mode 100644 index 0000000000..2eaddc27ca --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/assignments.yaml @@ -0,0 +1,78 @@ +name: "Assignments" +cases: + - name: "First char is equals sign" + ignore_stderr: true + stdin: | + =x + + - name: "Basic assignment" + stdin: | + x=yz + echo "x: ${x}" + + - name: "Invalid variable name" + ignore_stderr: true + stdin: | + @=something + + - name: "Quoted equals sign" + ignore_stderr: true + stdin: | + x"="3 + + - name: "Multiple equals signs" + stdin: | + x=y=z + echo "x: ${x}" + + - name: "Assignment with tilde expansion" + env: + HOME: /some/dir + stdin: | + # Basic tilde at start (currently works) + var=~/file1.txt + echo "~/file1.txt: ${var}" + + # Tilde after colon in assignment (currently broken - doesn't expand second tilde) + var=~/file1.txt:~/file2.txt + echo "~/file1.txt:~/file2.txt: ${var}" + + # Multiple tildes after colons + var=~:~/bin:~/local/bin + echo "~:~/bin:~/local/bin: ${var}" + + # Mixed with regular paths + PATH=/usr/bin:~:/bin:~/local + echo "PATH-like: ${PATH}" + + - name: "Array assignment with tilde expansion" + env: + HOME: /testhome + stdin: | + # Array elements with tildes + arr=(~ ~/bin ~root) + echo "Array elements:" + for i in "${arr[@]}"; do + echo " $i" + done + + # Array elements with colon-tilde + arr2=(~:~/bin /usr/bin:~) + echo "Array with colons:" + for i in "${arr2[@]}"; do + echo " $i" + done + + - name: "Append assignment with tilde expansion" + env: + HOME: /testhome + stdin: | + # Basic append + PATH=/usr/bin + PATH+=:~ + echo "After append tilde: ${PATH}" + + # Append with colon-tilde + PATH=/usr/bin + PATH+=:~:~/local + echo "After append colon-tilde: ${PATH}" diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/background_jobs.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/background_jobs.yaml new file mode 100644 index 0000000000..7afb338f77 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/background_jobs.yaml @@ -0,0 +1,89 @@ +name: "Background jobs" +cases: + # === Implicit wait on exit: shell must wait for bg jobs before terminating === + # These use file side-effects rather than stdout to avoid racy output ordering. + + - name: "Single background job via -c without explicit wait" + skip: true # Issue #1079 + args: + - "-c" + - "touch bg_done &" + + - name: "Single background job via stdin without explicit wait" + skip: true # Issue #1079 + stdin: | + touch bg_done & + + - name: "Background compound command via -c without explicit wait" + skip: true # Issue #1079 + args: + - "-c" + - "{ touch a_done; touch b_done; } &" + + - name: "Background compound command via stdin without explicit wait" + skip: true # Issue #1079 + stdin: | + { touch a_done; touch b_done; } & + + - name: "Multiple background jobs via -c without explicit wait" + skip: true # Issue #1079 + args: + - "-c" + - "touch one & touch two & touch three &" + + - name: "Multiple background jobs via stdin without explicit wait" + skip: true # Issue #1079 + stdin: | + touch one & touch two & touch three & + + - name: "Background and foreground via -c without explicit wait" + skip: true # Issue #1079 + args: + - "-c" + - "touch bg_done & touch fg_done" + + - name: "Background and foreground via stdin without explicit wait" + skip: true # Issue #1079 + stdin: | + touch bg_done & touch fg_done + + # === Explicit wait === + + - name: "Background job side effect observed after wait via -c" + args: + - "-c" + - "touch bg_done & wait; test -f bg_done && echo exists" + + - name: "Background job side effect observed after wait via stdin" + stdin: | + touch bg_done & + wait + test -f bg_done && echo exists + + - name: "Single background job with explicit wait via -c" + args: + - "-c" + - | + echo hello & + wait + + - name: "Single background job with explicit wait via stdin" + stdin: | + echo hello & + wait + + - name: "Two sequential background jobs with explicit wait via -c" + args: + - "-c" + - | + echo 1 & + wait + echo 2 & + wait + + - name: "Two sequential background jobs with explicit wait via stdin" + stdin: | + echo 1 & + wait + echo 2 & + wait diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/basic.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/basic.yaml new file mode 100644 index 0000000000..9003060334 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/basic.yaml @@ -0,0 +1,132 @@ +name: "Basic tests" +cases: + - name: "Basic -c usage" + args: + - "-c" + - "echo hi" + + - name: "Basic -c usage: output" + stdin: | + # Capture raw output of command + raw_output=$($0 -c 'echo hi') + + # Dump raw output as characters + echo "[Character output]" + for ((i = 0; i < ${#raw_output}; i++)); do + char="${raw_output:i:1}" + printf '%c' "$char" + done + printf '\n\n' + + # Dump raw output as hex + echo "[Hex output]" + for ((i = 0; i < ${#raw_output}; i++)); do + if (( i % 16 == 0 )); then + printf '%04x: ' "$i" + fi + + char="${raw_output:i:1}" + printf '%02x ' "'$char" + + if (( (i + 1) % 16 == 0 )); then + printf '\n' + fi + done + + printf '\n' + + - name: "Basic stdin usage" + stdin: | + echo hi + + - name: "Basic sequence" + stdin: | + echo 'hi'; echo 'there' + + - name: "Basic -s usage with -c" + args: ["-s", "-c", "echo hi1 hi2"] + stdin: | + [[ $(type -P $0) == $(type -P $BASH) ]] && echo '$0 matches $BASH' + echo "\$1: $1" + echo "\$2: $2" + echo "\$3: $3" + + - name: "Basic -s usage with positional args" + args: ["-s", "arg1", "arg2", "arg3"] + stdin: | + [[ $(type -P $0) == $(type -P $BASH) ]] && echo '$0 matches $BASH' + echo "\$1: $1" + echo "\$2: $2" + echo "\$3: $3" + + - name: "Basic -i usage with commands via stdin" + args: ["-i"] + ignore_stderr: true + stdin: | + echo hi + echo there + + - name: "Basic script execution" + test_files: + - path: "script.sh" + contents: | + echo "ARGS: $@" + exit 22 + args: ["./script.sh", 1, 2, 3] + + - name: "Passing option-like arg to script" + test_files: + - path: "script.sh" + contents: | + echo "ARGS: $@" + exit 22 + args: ["./script.sh", "-v"] + + - name: "Ensure ~ is resolvable" + stdin: "test ~" + + - name: "Rc file loading (interactive)" + ignore_stderr: true + removed_default_args: ["--norc"] # Need to cancel this out so we can override + args: ["--rcfile", "testrc", "-i"] + env: + PS1: "" + test_files: + - path: "testrc" + contents: | + echo "Executing in rc file" + stdin: | + echo "Executing in main script body" + + - name: "Rc file loading (non-interactive)" + ignore_stderr: true + removed_default_args: ["--norc"] # Need to cancel this out so we can override + args: ["--rcfile", "testrc"] + env: + PS1: "" + test_files: + - path: "testrc" + contents: | + echo "Executing in rc file" + stdin: | + echo "Executing in main script body" + + - name: "Missing rc file" + ignore_stderr: true + removed_default_args: ["--norc"] # Need to cancel this out so we can override + args: ["--rcfile", "non-existent-file", "-i"] + env: + PS1: "" + stdin: | + echo "Executing in main script body" + + - name: "Unknown option" + ignore_stderr: true + args: ["--unknown-option", "-c", "echo hi"] + + - name: "-f on the command line" + args: ["-f"] + stdin: | + touch abc + touch abd + echo a* diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/alias.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/alias.yaml new file mode 100644 index 0000000000..d7f7283034 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/alias.yaml @@ -0,0 +1,33 @@ +name: "Builtins: alias" +cases: + - name: "Basic alias usage" + stdin: | + shopt -s expand_aliases + alias myalias=echo + alias + myalias 'hello' + + - name: "Alias with trailing space" + known_failure: true # Issue #57 + stdin: | + shopt -s expand_aliases + alias cmd='echo ' + alias other='replaced ' + alias otherother='also-replaced' + + cmd other otherother + + - name: "Alias referencing to alias" + known_failure: true # Issue #57 + stdin: | + shopt -s expand_aliases + alias myalias=echo + alias outeralias=myalias + outeralias 'hello' + + - name: "Alias to keywords" + known_failure: true # Issue #286 + stdin: | + shopt -s expand_aliases + alias myalias=if + myalias true; then echo "true"; fi diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/bind.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/bind.yaml new file mode 100644 index 0000000000..a1c5385fcb --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/bind.yaml @@ -0,0 +1,160 @@ +name: "Builtins: bind" +cases: + - name: "bind succeeds in non-interactive mode" + stdin: | + bind > /dev/null 2>&1 + echo "exit: $?" + + - name: "bind -l" + args: ["run.sh"] + removed_default_args: ["--input-backend=basic"] + additional_test_args: ["--input-backend=reedline"] + ignore_stderr: true + min_oracle_version: "5.3" # Added functions in 5.3 + test_files: + - path: "run.sh" + contents: | + # List bindable actions but ignore brush custom entries + echo "[bind -l]" + bind -l | grep -v brush- | sort + echo "bind -l result: ${PIPESTATUS[@]}" + + - name: "bind -q/-P/-p" + args: ["run.sh"] + removed_default_args: ["--input-backend=basic"] + additional_test_args: ["--input-backend=reedline"] + ignore_stderr: true + test_files: + - path: "run.sh" + contents: | + # Basic-check some known bindings + well_known_func="clear-screen" + + echo "[bind -q]" + bind -q ${well_known_func} + echo "bind -q result: ${PIPESTATUS[@]}" + + echo "[bind -P]" + bind -P | grep ${well_known_func} + echo "bind -P result: ${PIPESTATUS[@]}" + + echo "[bind -p]" + bind -p | grep ${well_known_func} + echo "bind -p result: ${PIPESTATUS[@]}" + + - name: "bind -x/-X/-r/-u" + args: ["run.sh"] + removed_default_args: ["--input-backend=basic"] + additional_test_args: ["--input-backend=reedline"] + min_oracle_version: "5.3" # -X output changed in 5.3 + ignore_stderr: true + test_files: + - path: "run.sh" + contents: | + # Bind a key and check it + echo "[bind -x]" + bind -x '"\C-t":echo hi' + echo "bind -x result: $?" + + echo "[bind -X]" + bind -X + echo "bind -X result: $?" + + # Unbind it via -r + echo "[bind -r]" + bind -r '\C-t' + echo "bind -r result: $?" + bind -X + echo "bind -X result: $?" + echo "[bind -r (again)]" + bind -r '\C-t' + echo "bind -r (again) result: $?" + + # Unbind via -u + well_known_func="clear-screen" + echo "[bind -u]" + bind -u ${well_known_func} + echo "bind -u result: $?" + echo "[bind -q (after -u)]" + bind -q ${well_known_func} + echo "bind -q (after -u) result: $?" + + - name: "bind keyseq" + args: ["run.sh"] + removed_default_args: ["--input-backend=basic"] + additional_test_args: ["--input-backend=reedline"] + ignore_stderr: true + test_files: + - path: "run.sh" + contents: | + well_known_func="clear-screen" + bind -u ${well_known_func} # Ensure it's unbound first + + # Bind a keyseq + echo "[bind keyseq]" + bind '"\C-t":clear-screen' + echo "bind keyseq result: $?" + + # List current bindings + bind -q ${well_known_func} + echo "bind -q result: $?" + + - name: "bind macro 1" + args: ["run.sh"] + removed_default_args: ["--input-backend=basic"] + additional_test_args: ["--input-backend=reedline"] + ignore_stderr: true + test_files: + - path: "run.sh" + contents: | + key_to_bind='\C-r' + + echo "[bind -S (pre)]" + bind -S | grep "${key_to_bind}" + echo "bind -S result: ${PIPESTATUS[@]}" + + echo "[bind -s (pre)]" + bind -s | grep "${key_to_bind}" + echo "bind -s result: ${PIPESTATUS[@]}" + + echo "[bind macro]" + bind '"\C-r": "\e[0;1A\e[0;0A"' + echo "bind macro result: $?" + + echo "[bind -S]" + bind -S | grep "${key_to_bind}" + echo "bind -S result: ${PIPESTATUS[@]}" + + echo "[bind -s]" + bind -s | grep "${key_to_bind}" + echo "bind -s result: ${PIPESTATUS[@]}" + + echo "[bind -r]" + bind -r "${key_to_bind}" + echo "bind -r result: ${PIPESTATUS[@]}" + + echo "[bind -s (after -r)]" + bind -s | grep "${key_to_bind}" + echo "bind -s (after -r) result: ${PIPESTATUS[@]}" + + echo "[bind -S (after -r)]" + bind -S | grep "${key_to_bind}" + echo "bind -S (after -r) result: ${PIPESTATUS[@]}" + + - name: "bind macro 2" + args: ["run.sh"] + removed_default_args: ["--input-backend=basic"] + additional_test_args: ["--input-backend=reedline"] + ignore_stderr: true + test_files: + - path: "run.sh" + contents: | + key_to_bind='\ec' + + echo "[bind macro]" + bind '"\ec": " \C-b\C-k`something`\e\C-e"' + echo "bind macro result: $?" + + echo "[bind -S]" + bind -S | grep "${key_to_bind}" + echo "bind -S result: ${PIPESTATUS[@]}" diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/builtin.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/builtin.yaml new file mode 100644 index 0000000000..0060a1c9a4 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/builtin.yaml @@ -0,0 +1,44 @@ +name: "Builtins: builtin" +cases: + - name: "builtin with no builtin" + stdin: builtin + + - name: "builtin with unknown builtin" + ignore_stderr: true + stdin: builtin not-a-builtin args + + - name: "valid builtin" + stdin: builtin echo "Hello world" + + - name: "valid builtin with hyphen args" + stdin: builtin echo -e "Hello\nWorld" + + - name: "builtin passing through results" + stdin: | + builtin false; echo "builtin false => $?" + builtin true; echo "builtin true => $?" + + - name: "builtin with non-decl builtin" + stdin: | + builtin echo variable=value + + - name: "builtin with decl builtin" + stdin: | + builtin declare variable=value + declare -p variable + + - name: "builtin with disabled builtin" + ignore_stderr: true + incompatible_os: + - opensuse-tumbleweed + stdin: | + cd . + echo $? + + enable -n cd + builtin cd . + echo $? + + enable cd + builtin cd . + echo $? diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/caller.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/caller.yaml new file mode 100644 index 0000000000..c5f39278d4 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/caller.yaml @@ -0,0 +1,67 @@ +name: "caller builtin" +cases: + - name: "caller in function" + test_files: + - path: script.sh + contents: | + function outer() { + echo "In outer" + inner + } + + function inner() { + echo "In inner" + + echo "[Calling caller 0]" + caller 0 + + echo "[Calling caller 1]" + caller 1 + } + stdin: | + source script.sh + outer + + - name: "caller no args" + known_failure: true # TODO(caller): needs investigation + test_files: + - path: script.sh + contents: | + function myfunc() { + echo "In myfunc" + caller + } + stdin: | + source script.sh + myfunc + + - name: "caller no args (nested)" + test_files: + - path: script.sh + contents: | + function myfunc() { + echo "In myfunc" + caller + } + + function myouterfunc() { + echo "In myouterfunc" + myfunc + } + stdin: | + source script.sh + myouterfunc + + - name: "caller invalid expr" + ignore_stderr: true # Different error messages + stdin: | + f() { caller x; } + f + + - name: "caller out of range" + stdin: | + caller 100 + + - name: "caller not in function" + stdin: | + caller diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/cd.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/cd.yaml new file mode 100644 index 0000000000..b9cb57785c --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/cd.yaml @@ -0,0 +1,171 @@ +name: "Builtins: cd" +cases: + - name: "Basic cd usage" + ignore_stderr: true + stdin: | + echo "cd /" + cd / + echo $? + echo "pwd: $PWD" + + echo "cd usr" + cd usr + echo $? + echo "pwd: $PWD" + + echo "cd" + cd + echo $? + echo "pwd: $PWD" + + - name: "cd to file" + ignore_stderr: true + test_files: + - path: "file" + contents: "file contents" + stdin: | + ls -1 + cd file + echo $? + ls -1 + + - name: "cd -" + ignore_stderr: true + stdin: | + cd / + echo "pwd: $PWD" + cd usr + echo "pwd: $PWD" + echo "oldpwd: $OLDPWD" + cd - + echo $? + echo "pwd: $PWD" + + - name: "cd ~" + stdin: | + mkdir ./my_home + export HOME="$(realpath ./my_home)" + echo "Set HOME: $(basename $HOME)" + ( + echo "Subshell 1: HOME=$(basename $HOME)" + cd ~ + echo "pwd: $(basename $PWD)" + ) + ( + echo "Subshell 2: HOME=$(basename $HOME)" + cd -L ~ + echo "pwd: $(basename $PWD)" + ) + ( + echo "Subshell 3: HOME=$(basename $HOME)" + cd -P ~ + echo "pwd: $(basename $PWD)" + ) + + - name: "cd with symlink" + stdin: | + mkdir -p ./level1/level2/level3 + cd level1 + ln -s ./level2/level3 ./symlink + + # -L by default + ( + cd ./symlink + echo "$(basename $PWD)" + cd .. + echo "$(basename $PWD)" + ) + + - name: "cd -L" + stdin: | + mkdir -p ./level1/level2/level3 + cd level1 + ln -s ./level2/level3 ./symlink + + echo "[Case 1]" + ( + cd ./symlink + echo "$(basename $PWD)" + cd -L .. + echo "$(basename $PWD)" + ) + + echo "[Case 2]" + ( + cd -L ./symlink + echo "$(basename $PWD)" + cd .. + echo "$(basename $PWD)" + ) + + # without pwd + echo "[Case 3]" + ( + cd -L ./symlink + echo "$(basename $PWD)" + export PWD= + cd -L .. + echo "$(basename $PWD)" + ) + + echo "[Case 4]" + ( + cd -L ./symlink + export PWD= + # start a shell without $PWD + ( + cd . + echo "$(basename $PWD)" + ) + ) + + - name: "cd -P" + stdin: | + mkdir -p ./level1/level2/level3 + cd level1 + ln -s ./level2/level3 ./symlink + + echo "[Case 1]" + ( + cd ./symlink + echo "cd ./symlink => $(basename $PWD)" + cd -P .. + echo "cd -P .. => $(basename $PWD)" + ) + + echo "[Case 2]" + ( + cd -P ./symlink + echo "cd -P ./symlink => $(basename $PWD)" + cd .. + echo "cd .. => $(basename $PWD)" + ) + + - name: "OLDPWD" + env: + OLDPWD: "/nonexistent/path" + stdin: | + mkdir subdir + + echo "[0]" + echo ${OLDPWD+set} + + echo "[1]" + echo ${OLDPWD-NOTSET} + + echo "[2]" + declare -p OLDPWD 2>&1 | grep -o "declare -[a-z]* OLDPWD" + + echo "[3]" + env | grep "^OLDPWD=" || echo "NOT_IN_ENV" + + echo + echo "[Unsetting + cd'ing...]" + unset OLDPWD + cd $PWD/subdir + + echo "[4]" + declare -p OLDPWD 2>&1 | grep -o "declare -[a-z]* OLDPWD" + + echo "[5]" + env | grep -o "^OLDPWD=" diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/colon.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/colon.yaml new file mode 100644 index 0000000000..541e33b1d1 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/colon.yaml @@ -0,0 +1,13 @@ +name: "Builtins: colon" +cases: + - name: "Basic colon usage" + ignore_stderr: true + stdin: | + : + echo "Result 1: $?" + : something + echo "Result 2: $?" + : --anything=here or here + echo "Result 3: $?" + : --help + echo "Result 4: $?" diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/command.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/command.yaml new file mode 100644 index 0000000000..0d44b2932b --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/command.yaml @@ -0,0 +1,98 @@ +name: "Builtins: command" +cases: + - name: "Basic command usage" + ignore_stderr: true + stdin: | + echo "Executing echo built-in" + command echo "Hello" + + echo "Executing ls using name" + command ls -d / + + echo "Executing ls using absolute path" + command $(which ls) -d / + + echo "Executing non-existent command by name" + command non-existent + + echo "Executing non-existent command by path" + command /usr/bin/non-existent + + - name: "Non-executable command" + ignore_stderr: true + stdin: | + mkdir subdir + + echo "Before executing" + ./subdir + echo "After executing: result=$?" + + echo "Before and-or list execution" + ./subdir || true + echo "After and-or list execution: result=$?" + + - name: "command -v" + stdin: | + echo "PATH: $PATH" + + echo "[echo]" + command -v echo + + echo "[non-existent]" + command -v non-existent || echo "1. Not found" + + echo "[/usr/bin/non-existent]" + command -v /usr/bin/non-existent || echo "2. Not found" + + - name: "command -v -p" + # On NixOS, `command -p`'s default "standard utilities" path diverges between + # brush and the oracle. brush derives it from glibc's confstr(_CS_PATH), which + # nixpkgs patches to include the Nix profile (so `cat` is found there), whereas + # nixpkgs' bash resolves a default path that excludes the Nix profile (so it + # finds nothing). This reflects where NixOS keeps its standard utilities, not a + # brush behavior difference, so it can't be reconciled against the oracle. + incompatible_os: ["nixos"] + stdin: | + unset PATH + + echo "[no -p]" + command -v cat + + echo "[-p]" + command -v -p cat + + - name: "command -v with full paths" + stdin: | + echo "[cat]" + command -v cat + + echo "[\$(command -v cat)]" + command -v $(command -v cat) + + - name: "command -V" + ignore_stderr: true + stdin: | + command -V echo + command -V ls + command -V $(which ls) + + command -V non-existent || echo "1. Not found" + command -V /usr/bin/non-existent || echo "2. Not found" + + - name: "command with --" + stdin: | + command -- ls + + - name: "command with --help" + stdin: | + command ls --help + + - name: "command with -p" + ignore_stderr: true # In case it fails on the oracle as well, ignore the specific error message. + # See "command -v -p" above: `command -p`'s default utility path resolves + # differently on NixOS for brush (glibc confstr) vs. the oracle (nixpkgs bash). + incompatible_os: ["nixos"] + stdin: | + unset PATH + + command -p -- ls diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/common.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/common.yaml new file mode 100644 index 0000000000..82538d6d0b --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/common.yaml @@ -0,0 +1,20 @@ +name: "Builtin Common Tests" +cases: + - name: "Piping builtin output" + stdin: | + shopt | wc -l | wc -l + + - name: "Redirecting builtin output" + stdin: | + declare my_variable=10 + declare -p my_variable >out.txt + + echo "Dumping file contents..." + cat out.txt + + - name: "Overrides" + ignore_stderr: true + stdin: | + declare -p myvar + myvar=10 declare -p myvar + declare -p myvar diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/compgen.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/compgen.yaml new file mode 100644 index 0000000000..7c979c4856 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/compgen.yaml @@ -0,0 +1,309 @@ +name: "Builtins: compgen" +cases: + - name: "compgen -A alias" + stdin: | + alias myalias="ls" + alias myalias2="echo hi" + + compgen -A alias myalias | sort + + - name: "compgen -A binding" + stdin: | + compgen -A binding tab | sort + + - name: "compgen -A builtin" + stdin: | + compgen -A builtin cd | sort + + - name: "compgen -A directory" + stdin: | + touch somefile + mkdir somedir + mkdir somedir2 + + compgen -A directory some | sort + + - name: "compgen -A file" + stdin: | + touch somefile + mkdir somedir + mkdir somedir2 + + compgen -A file some | sort + + - name: "compgen -A file with dot files" + stdin: | + touch .file + mkdir .dir + + echo "[without dotglob]" + shopt -u dotglob + compgen -A file | sort + + echo "[with dotglob]" + shopt -s dotglob + compgen -A file | sort + + - name: "compgen -A function" + stdin: | + myfunc() { + echo hi + } + + myfunc2() { + echo hello + } + + compgen -A function myfunc | sort + + - name: "compgen -A keyword" + stdin: | + compgen -A keyword esa | sort + + - name: "compgen -A variable" + stdin: | + declare myvar=10 + declare myvar2=11 + + compgen -A variable myvar | sort + + - name: "compgen -W" + stdin: | + echo "1. Basic compgen -W" + compgen -W "one two three" -- t | sort + echo "2. compgen -W with expansion" + myvar=value compgen -W '${myvar}' + + - name: "compgen -W does not pathname-expand" + stdin: | + touch foo.txt bar.txt baz.log + + echo "[glob pattern should be literal]" + compgen -W '*.txt' | sort + + echo "[question mark glob should be literal]" + compgen -W '?.log' | sort + + - name: "compgen -W with unsorted values" + stdin: | + compgen -W 'c b d a' + + - name: "compgen -W with options" + stdin: | + compgen -W '--abc --def' -- '--ab' + + - name: "compgen with no matches" + stdin: | + compgen -W yes no && echo "1. Result" + + - name: "compgen -f with tilde" + home_dir: testhome # relative to default working dir + stdin: | + mkdir testhome && cd testhome + + echo "HOME base: $(basename $HOME)" + echo "~ base:" $(basename ~) + + touch item1 + + echo "[0]" + for p in $(compgen -f ~); do + echo ${p//$HOME/HOME} + done + + echo "[1]" + for p in $(compgen -f ~/); do + echo ${p//$HOME/HOME} + done + + - name: "compgen -f with quoted tilde" + known_failure: true + stdin: | + touch item1 + HOME=$(pwd) + + echo "[0]" + for p in $(compgen -f '~/'); do + echo ${p//$HOME/HOME} + done + + - name: "compgen -f with quoted var" + known_failure: true + stdin: | + touch item1 + HOME=$(pwd) + + echo "[0]" + for p in $(compgen -f '$HOME/'); do + echo ${p//$HOME/HOME} + done + + - name: "compgen -f with quoted + escaped var" + known_failure: true + stdin: | + touch item1 + HOME=$(pwd) + + echo "[0]" + for p in $(compgen -f '\$HOME/'); do + echo ${p//$HOME/HOME} + done + + - name: "compgen with interesting hyphens" + stdin: | + compgen -P-before -S-after -W "one two three" -- t | sort + + - name: "compgen -X" + stdin: | + echo "[Take 1]" + compgen -W 'foo bar' -X 'foo' '' + + echo "[Take 2]" + compgen -W 'foo bar' -X 'f*' '' + + echo "[Take 3]" + compgen -W '&1 foo' -X '\&*' '' + + - name: "compgen -X with replacement" + stdin: | + echo "[Take 1]" + compgen -W 'somebody something' -X '&b*' some + + - name: "compgen -X with extglob" + stdin: | + touch README + shopt -s extglob + + echo "[Take 1]" + compgen -f READ + + echo "[Take 2]" + compgen -f -X "READ" READ + + echo "[Take 3]" + compgen -f -X "README" READ + + echo "[Take 4]" + compgen -f -X "!(READ)" READ + + echo "[Take 5]" + compgen -f -X "!(README)" READ + + echo "[Take 6]" + compgen -f -X "!!(READ)" READ + + echo "[Take 7]" + compgen -f -X "!!(README)" READ + + - name: "compgen -o dirnames" + stdin: | + echo "[Take 1]" + compgen -W 'completion' -o dirnames -- c | sort + + echo "[Take 2]" + mkdir subdir + touch subfile + compgen -W 'completion' -o dirnames -- s | sort + + - name: "compgen -o default" + stdin: | + echo "[Take 1]" + compgen -W 'completion' -o default -- c | sort + + echo "[Take 2]" + mkdir subdir + touch subfile + compgen -W 'completion' -o default -- s | sort + + - name: "compgen -o bashdefault" + stdin: | + echo "[Take 1]" + compgen -W 'completion' -o bashdefault -- c | sort + + echo "[Take 2]" + mkdir subdir + touch subfile + compgen -W 'completion' -o bashdefault -- s | sort + + - name: "compgen -o plusdirs" + stdin: | + echo "[Take 1]" + touch cfile + mkdir cdir + compgen -W 'completion' -o plusdirs -o default -S suffix -- c | sort + + - name: "compgen -A command" + # Temporarily marked unconstrained Path case as `skip: true` to keep CI stable. + # Environment-dependent due to host PATH contents(Ubuntu includes X11, while Arch and others do not). + skip: true + stdin: | + compgen -A command | sort | grep -v "^brush" + + - name: "compgen -A command in a constrained PATH" + stdin: | + SORT=$(command -v sort) + GREP=$(command -v grep) + PATH=$(pwd) + compgen -A command | $SORT | $GREP -v "^brush" + + - name: "compgen -A command builtins in a constrained PATH" + stdin: | + SORT=$(command -v sort) + PATH=$(pwd) + compgen -A command cd | $SORT + + - name: "compgen -A command keywords in a constrained PATH" + stdin: | + SORT=$(command -v sort) + PATH=$(pwd) + compgen -A command [ | $SORT + + - name: "compgen -A command function in a constrained PATH" + stdin: | + SORT=$(command -v sort) + PATH=$(pwd) + myfunc() { + echo hi + } + myfunc2() { + echo hello + } + compgen -A command myfunc | $SORT + + - name: "compgen -A command with executable symlink" + stdin: | + # pwd + # drwxr-xr-x ├──  test-bin + # .rwxr-xr-x │ ├──  dummy_exe + # .rw-r--r-- │ └──  dummy_non_exe + # drwxr-xr-x └──  test-links + # lrwxrwxrwx ├──  link_valid ⇒ $(pwd)/test-bin/dummy_exe + # lrwxrwxrwx └──  link_valid_exe_rec ⇒ $(pwd)/test-links/link_valid + # lrwxrwxrwx ├──  link_non_exec ⇒ $(pwd)/test-bin/dummy_non_exe + # lrwxrwxrwx ├──  link_broken ⇒ $(pwd)/test-bin/dummy_non_exist + # + mkdir test-bin + touch test-bin/dummy_exe + touch test-bin/dummy_non_exe + chmod +x test-bin/dummy_exe + + mkdir test-links + ln -s $(pwd)/test-bin/dummy_exe $(pwd)/test-links/link_valid + ln -s $(pwd)/test-links/link_vaild $(pwd)/test-links/link_valid_rec + ln -s $(pwd)/test-bin/dummy_non_exe $(pwd)/test-links/link_non_exec + ln -s $(pwd)/test-bin/dummy_non_exist $(pwd)/test-links/link_broken + + PATH="$(pwd)/test-links:$PATH" + + echo "[Valid]" + compgen -A command -- link_valid | sort + + echo "[Invalid]" + compgen -A command -- link_non_exec + compgen -A command -- link_broken + + - name: "compgen -F sets COMP_KEY and COMP_TYPE to 0" + ignore_stderr: true + stdin: | + _comp() { echo "KEY=${COMP_KEY:-unset} TYPE=${COMP_TYPE:-unset}"; COMPREPLY=(); } + compgen -F _comp mycmd diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/complete.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/complete.yaml new file mode 100644 index 0000000000..3d6d13b435 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/complete.yaml @@ -0,0 +1,114 @@ +name: "Builtins: complete" +cases: + - name: "complete with no options" + stdin: | + complete -W foo mycmd + complete + + - name: "Roundtrip: complete -W" + stdin: | + complete -W foo mycmd + complete -p mycmd + + complete -W 'foo bar' mycmd + complete -p mycmd + + - name: "Roundtrip: complete -P" + stdin: | + complete -P myprefix mycmd + complete -p mycmd + + complete -P 'my prefix' mycmd + complete -p mycmd + + - name: "Roundtrip: complete -S" + stdin: | + complete -S mysuffix mycmd + complete -p mycmd + + complete -S 'my suffix' mycmd + complete -p mycmd + + - name: "Roundtrip: complete -F" + stdin: | + complete -Fmyfunc mycmd + complete -p mycmd + + - name: "Roundtrip: complete -F" + stdin: | + complete -G pattern mycmd + complete -p mycmd + + complete -G 'pat tern' mycmd + complete -p mycmd + + - name: "Roundtrip: complete -X" + stdin: | + complete -X pattern mycmd + complete -p mycmd + + complete -X 'pat tern' mycmd + complete -p mycmd + + - name: "Roundtrip: complete -C" + stdin: | + complete -C cmd mycmd + complete -p mycmd + + complete -C 'c md' mycmd + complete -p mycmd + + - name: "Roundtrip: complete -A" + stdin: | + for action in alias arrayvar binding builtin command directory disabled enabled export file 'function' group helptopic hostname job keyword running service setopt shopt signal stopped user variable; do + complete -A ${action} mycmd + complete -p mycmd + done + + - name: "Roundtrip: complete -o options" + stdin: | + for opt in bashdefault default dirnames filenames noquote nosort nospace plusdirs; do + echo "--- Testing option: ${opt} ------------------" + complete -o ${opt} mycmd_${opt} + complete -p mycmd_${opt} + done + + - name: "complete -r" + stdin: | + echo "[Removing non-existent]" + complete -r nonexistent + echo $? + + echo "[Removing existing]" + complete -W token mycmd + complete -r mycmd + echo $? + complete -p mycmd 2>/dev/null + + - name: "complete -r with no args" + stdin: | + complete -W token mycmd1 + complete -W token mycmd2 + + complete -r + echo $? + + complete -p + + - name: "complete -r with special options" + stdin: | + complete -W token mycmd + complete -W other -E + + complete -r -E + echo $? + + complete -p + + - name: "compgen -W preserves duplicates" + stdin: | + compgen -W "a b a c b" + + - name: "compgen -W preserves order" + stdin: | + compgen -W "z y x a" diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/declare.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/declare.yaml new file mode 100644 index 0000000000..f9ede2049b --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/declare.yaml @@ -0,0 +1,451 @@ +name: "Builtins: declare" +common_test_files: + - path: "helpers.sh" + contents: | + stable_print_assoc_array() { + # TODO(nameref): enable use of nameref when implemented; for now + # we assume the name of the array is assoc_array + # local -n assoc_array=$1 + local key + + for key in $(printf "%s\n" "${!assoc_array[@]}" | sort -n); do + echo "\"${key}\" => ${assoc_array[${key}]}" + done + } + +cases: + - name: "Display vars" + stdin: | + declare myvar=something + declare -p myvar + + myarr=(a b c) + declare -p myarr + + - name: "Display vars with interesting chars" + stdin: | + (testvar="\"abc\"" && declare -p testvar && declare | grep testvar=) + echo "-------------------------" + (testvar="a b c" && declare -p testvar && declare | grep testvar=) + echo "-------------------------" + (testvar="'" && declare -p testvar && declare | grep testvar=) + + - name: "Display vars with interesting chars 2" + min_oracle_version: 5.2 # some sequences render differently in older shell versions + stdin: | + (testvar=$'a\nb' && declare -p testvar && declare | grep testvar=) + echo "-------------------------" + (testvar=$'\x03' && declare -p testvar && declare | grep testvar=) + echo "-------------------------" + (testvar=$'\x08' && declare -p testvar && declare | grep testvar=) + echo "-------------------------" + (testvar=$(printf '\033[34mabc\033[0m') && declare -p testvar && declare | grep testvar=) + + - name: "Declare integer" + stdin: | + declare -i num=10 + declare -p num + + echo $num + num+=10 + echo $num + + declare +i num + declare -p num + + echo $num + num+=10 + echo $num + + - name: "Declare integer with non-integer string" + stdin: | + declare -i var=value + declare -p var + + - name: "Update integer with non-integer string" + stdin: | + declare -i var=10 + declare -p var + + var=value + declare -p var + + - name: "Update integer array with non-integer string" + stdin: | + declare -ai arr=() + declare -p arr + + arr[0]="value" + declare -p arr + + - name: "Update integer array with non-integer string" + stdin: | + declare -Ai arr=() + declare -p arr + + arr['key']="value" + declare -p arr + + - name: "Declare readonly variable" + ignore_stderr: true + stdin: | + declare -r var="readonly" + declare -p var + + echo $var + + var="change" + echo "change result: $?" + echo "var: ${var}" + + declare +r var + echo "+r result: $?" + declare -p var + + - name: "Declare array" + stdin: | + declare -a arr=("element1" "element2" "element3") + declare -p arr + echo "[0]: ${arr[0]}" + echo "[1]: ${arr[0]}" + echo "[2]: ${arr[0]}" + echo "[3]: ${arr[0]}" + echo "STAR: ${arr[*]}" + echo "AT: ${arr[*]}" + + - name: "Declare associative array" + stdin: | + declare -A arr=(["x1"]=1 ["x2"]=2) + declare -p arr + echo "[x]: ${arr[x1]}" + echo "[y]: ${arr[x2]}" + echo "[z]: ${arr[x3]}" + echo "STAR: ${arr[*]}" + echo "AT: ${arr[*]}" + + - name: "Declare and export variable" + stdin: | + declare -x myexportedvar="exported variable" + env | grep myexportedvar + + - name: "Declare and export unset variable" + stdin: | + declare -x myunsetexportedvar + env | grep myunsetexportedvar + set | grep myunsetexportedvar= + + - name: "Re-declaring variable" + stdin: | + var="value" + declare var + echo "var: ${var}" + + - name: "Declaring without value" + stdin: | + [[ -v var ]] && echo "1: Variable is set" + declare var + declare -p var + [[ -v var ]] && echo "2: Variable is set" + declare var2="" + declare -p var2 + [[ -v var2 ]] && echo "3: Variable is set" + + - name: "Displaying local vars" + stdin: | + function test { + echo "Dumping local variables (should be empty)" + local -p + + local -i int_var=10 + local -A assoc_array=(["x1"]=1 ["x2"]=2) + local -a array=(a b c) + local -r ro_var="readonly" + local -t traced="value" + + echo "Dump all variables" + local -p + } + + test + + - name: "Using local to detect function presence" + stdin: | + function test { + local something 2>/dev/null && echo "In function" + } + + local something 2>/dev/null || echo "Not in function" + + test + + - name: "Displaying function names" + stdin: | + echo "Dumping function names" + declare -F + declare -p -F + + function test { + : + } + + echo "Dumping function names again" + declare -F + declare -p -F + + echo "Dumping test" + declare -F test + declare -p -F test + + - name: "Displaying functions" + stdin: | + echo "Dumping functions" + declare -f + declare -p -f + + function test { + : + } + + echo "Dumping functions again" + declare -f + declare -p -f + + echo "Dumping test" + declare -f test + declare -p -f test + + - name: "Displaying non-existent functions" + stdin: | + declare -f not_a_function + echo "Result (-f): $?" + + declare -F not_a_function + echo "Result (-F): $?" + + - name: "Valid conversions" + stdin: | + declare -a arr1=(a b c) + declare -a arr1 + echo "Conversion result: $?" + declare -p arr1 + + declare -A arr2=(["x1"]=1 ["x2"]=2) + declare -A arr2 + echo "Conversion result: $?" + declare -p arr2 + + declare scalar1="value" + declare -a scalar1 + echo "Conversion result: $?" + declare -p scalar1 + + declare scalar2="value" + declare -A scalar2 + echo "Conversion result: $?" + declare -p scalar2 + + - name: "Bad conversions" + ignore_stderr: true + stdin: | + declare -a arr1=(a b c) + declare -A arr1 + echo "Conversion result: $?" + declare -p arr1 + + declare -A arr2=(["x1"]=1 ["x2"]=2) + declare -a arr2 + echo "Conversion result: $?" + declare -p arr2 + + - name: "Declare -p using invalid forms" + ignore_stderr: true + stdin: | + declare arr=(a b c) + declare -p arr[0] + echo "Result: $?" + declare -p arr[0]=1 + echo "Result: $?" + + declare scalar=x + echo "Result: $?" + declare -p scalar=y + echo "Result: $?" + + - name: "Updating value" + stdin: | + declare var="value" + declare -p var + declare var="changed" + declare -p var + + - name: "Updating value attributes" + stdin: | + declare -ix var=10 + declare -p var + declare +ix var + declare -p var + + - name: "Updating array" + stdin: | + declare arr=(a b c) + declare -p arr + declare arr=(d e) + declare -p arr + declare arr=10 + declare -p arr + + declare arr2=a + declare -p arr2 + declare -A arr2=(["key"]="value") + declare -p arr2 + + - name: "Updating causing conversion" + stdin: | + source helpers.sh + + declare assoc_array="scalar-value" + declare -p assoc_array + + declare -A assoc_array["key"]="key-value" + stable_print_assoc_array assoc_array + + - name: "Uppercase attribute" + stdin: | + declare var=value + + declare -u var + declare -p var + + var="abcd" + declare -p var + + declare another="abcd" + declare -u another + another+=efg + declare -p another + + - name: "Lowercase attribute" + stdin: | + declare var=value + + declare -l var + declare -p var + + var="AbCd" + declare -p var + + declare another="ABCD" + declare -l another + another+=EFG + declare -p another + + - name: "Capitalize attribute" + stdin: | + declare var=value + + declare -c var + declare -p var + + var="aBcD eFg" + declare -p var + + declare another="aBcD" + declare -c another + another+=eFg + declare -p another + + - name: "Declare invalid identifiers" + ignore_stderr: true + stdin: | + declare 1=x && echo "Set 1" + declare @=10 && echo "Set @" + + - name: "Nameref declare -p shows nameref attribute" + stdin: | + target="hello" + declare -n ref=target + declare -p ref + declare -p target + + - name: "Nameref declare -n with initial value" + known_failure: true + stdin: | + target="original" + declare -n ref=target + echo "ref: $ref" + echo "target: $target" + + - name: "Nameref remove attribute with +n" + known_failure: true + stdin: | + target="value" + declare -n ref=target + echo "as nameref: $ref" + declare +n ref + echo "after +n: ref=$ref" + declare -p ref + + - name: "Nameref to array via declare" + known_failure: true + stdin: | + arr=(one two three) + declare -n ref=arr + echo "ref[0]: ${ref[0]}" + echo "ref[1]: ${ref[1]}" + echo "ref[@]: ${ref[@]}" + echo "ref[*]: ${ref[*]}" + + - name: "Nameref array modification via declare" + known_failure: true + stdin: | + arr=(a b c) + declare -n ref=arr + ref[1]="modified" + echo "arr[1]: ${arr[1]}" + echo "ref[1]: ${ref[1]}" + + - name: "Nameref to associative array via declare" + known_failure: true + stdin: | + declare -A assoc=(["key1"]="val1" ["key2"]="val2") + declare -n ref=assoc + echo "ref[key1]: ${ref[key1]}" + echo "ref[key2]: ${ref[key2]}" + + - name: "Nameref array iteration in function via local -n" + known_failure: true + stdin: | + print_array() { + local -n arr=$1 + echo "count: ${#arr[@]}" + for elem in "${arr[@]}"; do + echo "elem: $elem" + done + } + + myarr=(first second third) + print_array myarr + + - name: "Nameref get keys via declare" + known_failure: true + stdin: | + declare -A assoc=(["a"]=1 ["b"]=2 ["c"]=3) + declare -n ref=assoc + echo "keys (sorted): $(printf '%s\n' ${!ref[@]} | sort | tr '\n' ' ')" + echo "count: ${#ref[@]}" + + - name: "Nameref array length via declare" + known_failure: true + stdin: | + arr=(one two three four five) + declare -n ref=arr + echo "length: ${#ref[@]}" + echo "first element length: ${#ref[0]}" + + - name: "Nameref declare -n filter" + stdin: | + regular="plain" + declare -n ref1=regular + declare -n ref2=regular + not_a_ref="also plain" + declare -p -n 2>/dev/null | sort diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/dot.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/dot.yaml new file mode 100644 index 0000000000..d0c789c76d --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/dot.yaml @@ -0,0 +1,145 @@ +name: "Builtins: dot" +cases: + - name: "Basic dot usage" + test_files: + - path: "script.sh" + contents: | + echo "In sourced script" + var="updated" + stdin: | + var="orig" + . script.sh + echo "var: ${var}" + + - name: "Basic source usage" + test_files: + - path: "script.sh" + contents: | + echo "In sourced script" + for f in ${FUNCNAME[@]}; do + echo "FUNCNAME: '${f}'" + done + for s in ${BASH_SOURCE[@]}; do + echo "BASH_SOURCE: '${s}'" + done + var="updated" + + function script_func() { + echo "In script func" + for f in ${FUNCNAME[@]}; do + echo "FUNCNAME: '${f}'" + done + for s in ${BASH_SOURCE[@]}; do + echo "BASH_SOURCE: '${s}'" + done + } + stdin: | + var="orig" + source ./script.sh + echo "var: ${var}" + script_func + + - name: "Source non-existent file path" + ignore_stderr: true + stdin: | + source script.sh + echo "Result: $?" + + - name: "Source dir" + ignore_stderr: true + stdin: | + source . + echo "Result: $?" + + - name: "Source script without args" + test_files: + - path: "script.sh" + contents: | + echo "In sourced script" + echo "Args: $@" + stdin: | + set -- outer1 outer2 outer3 + source script.sh + + - name: "Source script with args" + test_files: + - path: "script.sh" + contents: | + echo "In sourced script" + echo "Args: $@" + stdin: | + set -- outer1 outer2 outer3 + source script.sh inner1 inner2 + + - name: "Source with redirection" + test_files: + - path: "script.sh" + contents: | + echo "In sourced script" + stdin: | + source script.sh arg1 arg2 > out.txt + echo "Sourced script; dumping..." + cat out.txt + + - name: "Source /dev/stdin with heredoc redirection" + stdin: | + VAR=outer + VAR=scoped . /dev/stdin <<'EOF' + echo "VAR in source: $VAR" + INNER=from_source + EOF + echo "VAR after source: $VAR" + echo "INNER after source: $INNER" + + - name: "Source with return" + test_files: + - path: "script.sh" + contents: | + echo "Before return" + return 42 + echo "After return" + stdin: | + echo "Before source" + source script.sh + echo "After source, exit code: $?" + + - name: "Source with exit" + test_files: + - path: "script.sh" + contents: | + echo "Before exit" + exit 42 + echo "After exit" + stdin: | + echo "Before source" + source script.sh + echo "After source" + + - name: "Source with break in caller's loop" + test_files: + - path: "script.sh" + contents: | + echo "In script, breaking" + break + stdin: | + for i in 1 2 3; do + echo "Iteration $i" + source script.sh + echo "After source" + done + echo "After loop" + + - name: "Source with continue in caller's loop" + test_files: + - path: "script.sh" + contents: | + echo "In script, continuing" + continue + echo "After continue" + stdin: | + for i in 1 2 3; do + echo "Iteration $i" + source script.sh + echo "After source" + done + echo "After loop" diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/echo.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/echo.yaml new file mode 100644 index 0000000000..47e79a7f53 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/echo.yaml @@ -0,0 +1,12 @@ +name: "Builtins: echo" +cases: + - name: "echo with only --" + stdin: echo -- + + - name: "echo with -- and args" + stdin: echo -- -1 --"aaa" ?^1as- + + - name: "echo -e" + stdin: | + echo -e "1. \x65" | hexdump -C + echo -e "2. \x{65}" | hexdump -C diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/enable.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/enable.yaml new file mode 100644 index 0000000000..b4a83143c0 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/enable.yaml @@ -0,0 +1,32 @@ +name: "Builtins: enable" +cases: + - name: "List special builtins" + stdin: enable -s + + - name: "List default-disabled builtins" + stdin: enable -n + + - name: "List all builtins" + stdin: | + # List builtins but ignore any brush specific ones. + enable | grep -v brush + + - name: "Disable builtins" + ignore_stderr: true + stdin: | + type printf + + # Disable the builtin + PATH= + enable -n printf + + # Check + type printf + print "Gone\n" + + # Re-enable + enable printf + + # Re-check + type printf + printf "Back\n" diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/eval.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/eval.yaml new file mode 100644 index 0000000000..b432fb0e33 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/eval.yaml @@ -0,0 +1,47 @@ +name: "Builtins: eval" +cases: + - name: "Basic eval usage" + stdin: | + eval 'echo 1 + 1 == $((1 + 1))' + + - name: "eval with return in function" + stdin: | + f() { + eval 'return 42' + echo "Should not reach here" + } + f + echo "Exit code: $?" + + - name: "eval with exit" + stdin: | + eval 'exit 42' + echo "After eval'd exit" + + - name: "eval with break in loop" + stdin: | + for i in 1 2 3; do + eval 'break' + echo "After eval'd break" + done + echo "After loop" + + - name: "eval with continue in loop" + stdin: | + for i in 1 2 3; do + echo "Iteration $i" + eval 'continue' + echo "After eval'd continue" + done + echo "After loop" + + - name: "eval with nested break levels" + stdin: | + for i in 1 2; do + for j in a b c; do + eval 'break 2' + echo "After eval'd break (inner loop)" + done + echo "After eval'd break (outer loop)" + done + echo "After outer loop" diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/exec.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/exec.yaml new file mode 100644 index 0000000000..f7eb5c6976 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/exec.yaml @@ -0,0 +1,51 @@ +name: "Builtins: exec" +cases: + - name: "Exec an invalid path" + ignore_stderr: true + stdin: | + exec /some/nonexistent/path + + - name: "Exec with no arguments" + stdin: | + pid=$$ + exec + [[ "${pid}" == "$$" ]] || echo "PID changed" + + - name: "Exec with no arguments and redirection for new fd" + stdin: | + exec 3>some-file.txt + echo "Hello, fd 3!" >&3 + + - name: "Exec with no arguments and redirection of stderr" + stdin: | + exec 2>/dev/null + + # Do something that should display an error to stderr + source /non-existent + + - name: "Exec with no arguments and redirection in a script" + test_files: + - path: "script.sh" + contents: | + exec 2>output-file.txt + echo hi >&2 + args: ["./script.sh"] + + - name: "Exec a command" + stdin: | + exec $0 -c 'echo "In child shell"' + echo "This is never reached" + + - name: "exec without -c" + stdin: | + export myvar=value + exec $0 -c 'echo "myvar: ${myvar}"' + + - name: "exec -c" + stdin: | + export myvar=value + exec -c $0 -c 'echo "myvar: ${myvar}"' + + - name: "exec -a" + stdin: | + exec -a shellname $0 -c 'echo "0: $0"' diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/exit.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/exit.yaml new file mode 100644 index 0000000000..92038ff81f --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/exit.yaml @@ -0,0 +1,85 @@ +name: "Builtins: exit" +cases: + - name: "Exit without code" + stdin: | + exit + + - name: "Exit with code" + stdin: | + exit 10 + + - name: "Exit with i64 max" + stdin: | + exit 9223372036854775807 + + - name: "Exit with i64 min" + stdin: | + exit -9223372036854775808 + + - name: "Exit in for loop" + stdin: | + for i in 1 2 3; do + exit 42 + echo "Got past exit in loop" + done + echo "Got past loop" + + - name: "Exit in arithmetic for loop body" + stdin: | + for ((i = 0; i < 10; i++)); do + exit 42 + echo "Got past exit in loop" + done + echo "Got past loop" + + - name: "Exit in while loop condition" + stdin: | + while exit 42; do + echo "In loop" + done + echo "Got past loop" + + - name: "Exit in while loop body" + stdin: | + while true; do + exit 42 + echo "Got past exit in body" + break + done + echo "Got past loop" + + - name: "Exit in sequence" + stdin: | + exit 42; echo "Should not be printed" + + - name: "Exit in function" + stdin: | + myfunc() { + exit 42 + echo "Got past exit in function" + } + + myfunc + echo "Got past function call" + + - name: "Exit in subshell" + stdin: | + (exit 42) + echo "Got past subshell" + + - name: "Exit in command substitution" + stdin: | + output=$(echo hi; exit 42; echo there) + echo "Got past command substitution" + + - name: "Exit in and/or" + stdin: | + exit 42 || echo "Got past exit" + + - name: "Exit in brace group" + stdin: | + { + exit 42 + echo "Got past exit" + } + echo "Got past brace group" diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/export.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/export.yaml new file mode 100644 index 0000000000..1aab4099f9 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/export.yaml @@ -0,0 +1,53 @@ +name: "Builtins: export" +cases: + - name: "Basic export usage" + stdin: | + MY_TEST_VAR="value" + echo "Looking for unexported variable..." + env | grep MY_TEST_VAR + + echo "Exporting and looking for it again..." + export MY_TEST_VAR + env | grep MY_TEST_VAR + + echo "Exporting with new value..." + export MY_TEST_VAR="changed value" + env | grep MY_TEST_VAR + + - name: "Exporting array" + stdin: | + export arr=(a 1 2) + declare -p arr + + - name: "Unexporting variable" + stdin: | + MY_TEST_VAR="value" + export MY_TEST_VAR="value" + export -n MY_TEST_VAR + + env | grep MY_TEST_VAR + + - name: "Export with assignment through nameref" + known_failure: true + stdin: | + declare -n ref=MY_EXPORT_VAR + export ref="exported_value" + echo "MY_EXPORT_VAR: $MY_EXPORT_VAR" + env | grep MY_EXPORT_VAR + + - name: "Export append to existing variable" + stdin: | + MYVAR="-O3 -pipe" + export MYVAR+=" -Wno-foo" + echo "MYVAR: $MYVAR" + + - name: "Export append to already-exported variable" + stdin: | + export MYVAR="-O3 -pipe" + export MYVAR+=" -Wno-baz" + echo "MYVAR: $MYVAR" + + - name: "Export append to unset variable" + stdin: | + export MYVAR+="first" + echo "MYVAR: $MYVAR" diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/fc.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/fc.yaml new file mode 100644 index 0000000000..e3445eed49 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/fc.yaml @@ -0,0 +1,275 @@ +name: "Builtins: fc" +cases: + - name: "fc -l lists recent commands" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + : cmd1 + : cmd2 + : cmd3 + fc -l + + - name: "fc -l with range (positive indices)" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + : cmd1 + : cmd2 + : cmd3 + : cmd4 + : cmd5 + fc -l 2 4 + + - name: "fc -l with single argument" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + : cmd1 + : cmd2 + : cmd3 + fc -l 2 + + - name: "fc -l with negative indices" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + : cmd1 + : cmd2 + : cmd3 + : cmd4 + : cmd5 + fc -l -3 -1 + + - name: "fc -l with string search" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + ignore_stderr: true + stdin: | + : cmd1 + : other + : cmd2 + : something + fc -l cmd + + - name: "fc -l with -n (no line numbers)" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + : cmd1 + : cmd2 + : cmd3 + fc -ln + + - name: "fc -l with -r (reverse order)" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + : cmd1 + : cmd2 + : cmd3 + fc -lr + + - name: "fc -l with -n and -r combined" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + : cmd1 + : cmd2 + : cmd3 + fc -lnr + + - name: "fc -l with range and -r" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + : cmd1 + : cmd2 + : cmd3 + : cmd4 + : cmd5 + fc -lr 2 4 + + - name: "fc -l 0 means -1" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + : cmd1 + : cmd2 + : cmd3 + fc -l 0 + + - name: "fc -l defaults to last 16 commands" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + : cmd1 + : cmd2 + : cmd3 + : cmd4 + : cmd5 + : cmd6 + : cmd7 + : cmd8 + : cmd9 + : cmd10 + : cmd11 + : cmd12 + : cmd13 + : cmd14 + : cmd15 + : cmd16 + : cmd17 + : cmd18 + fc -l + + - name: "fc -s re-executes last command" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + echo hello + fc -s + + - name: "fc -s with command argument" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + echo hello + echo world + fc -s echo + + - name: "fc -s with pattern replacement" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + echo hello + fc -s hello=goodbye + + - name: "fc -s with command number" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + echo first + echo second + echo third + fc -s 1 + + - name: "fc -s with negative offset" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + echo first + echo second + echo third + fc -s -2 + + - name: "fc -s with string prefix match" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + ignore_stderr: true + stdin: | + echo aaa + echo bbb + echo aac + fc -s aa + + - name: "fc -s with pattern replacement and command number" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + echo first + echo second + echo hello + fc -s hello=goodbye 3 + + - name: "fc -s with pattern replacement and string prefix" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + echo aaa + : somecommand + echo hello world + fc -s hello=goodbye echo + + - name: "fc -s with pattern replacement and negative index" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + echo one + echo two + echo hello world + fc -s hello=goodbye -1 + + - name: "fc -s pattern replacement when pattern doesn't match" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + echo hello + fc -s xyz=abc + + - name: "fc -s pattern replacement replaces all occurrences" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + echo test test test + fc -s test=foo + + - name: "fc -s with empty replacement" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + echo hello world + fc -s hello= + + - name: "fc with invalid range" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + : cmd1 + fc -l 100 200 + expect_status: 1 + + - name: "fc -s with nonexistent command" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + ignore_stderr: true + stdin: | + echo hello + fc -s xyz + expect_status: 1 + + - name: "fc -l with both string and number" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + ignore_stderr: true + stdin: | + : cmd1 + : cmd2 + : cmd3 + : other + fc -l cmd 3 diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/getopts.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/getopts.yaml new file mode 100644 index 0000000000..697de62928 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/getopts.yaml @@ -0,0 +1,476 @@ +name: "Builtins: getopts" +cases: + - name: "Basic getopts" + stdin: | + func() { + echo "Beginning of args" + while getopts "ab:" option "$@"; do + case ${option} in + a) echo "Option a; OPTARG=${OPTARG}";; + b) echo "Option b; OPTARG=${OPTARG}";; + *) echo "Unknown option: ${option}";; + esac + echo "OPTIND is now ${OPTIND}" + echo "OPTERR is now ${OPTERR}" + echo "----------------" + done + echo "Result: $?" + echo "option: ${option}" + echo "End of args" + } + + func -a -b my_b_arg + echo "OPTARG: ${OPTARG}" + echo "OPTIND: ${OPTIND}" + + - name: "getopts: no args" + stdin: | + getopts "a:b:" myvar + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG}" + echo "OPTIND: ${OPTIND}" + echo "OPTERR: ${OPTERR}" + + - name: "getopts: toplevel positional parameters" + stdin: | + set -- -a value + getopts "a:b:" myvar + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG}" + echo "OPTIND: ${OPTIND}" + echo "OPTERR: ${OPTERR}" + + - name: "getopts: function positional parameters" + stdin: | + my_function() { + getopts "a:b:" myvar + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG}" + echo "OPTIND: ${OPTIND}" + echo "OPTERR: ${OPTERR}" + } + + my_function -b value + + - name: "getopts: function script parameters" + test_files: + - path: script.sh + contents: | + getopts "a:b:" myvar + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG}" + echo "OPTIND: ${OPTIND}" + echo "OPTERR: ${OPTERR}" + stdin: | + source ./script.sh -a value + + - name: "getopts: no options given" + stdin: | + while getopts "a:b:" myvar notoption; do + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG}" + echo "OPTIND: ${OPTIND}" + echo "OPTERR: ${OPTERR}" + echo "----------------" + done + echo "Done; result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG}" + echo "OPTIND: ${OPTIND}" + echo "OPTERR: ${OPTERR}" + + - name: "getopts: options and non-options" + stdin: | + while getopts "ab:" myvar -a -b value notoption; do + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG}" + echo "OPTIND: ${OPTIND}" + echo "OPTERR: ${OPTERR}" + echo "----------------" + done + echo "Done; result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG}" + echo "OPTIND: ${OPTIND}" + echo "OPTERR: ${OPTERR}" + + - name: "getopts: --" + stdin: | + while getopts "ab:" myvar -a -b value -- -c notoption; do + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG}" + echo "OPTIND: ${OPTIND}" + echo "OPTERR: ${OPTERR}" + echo "----------------" + done + echo "Done; result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG}" + echo "OPTIND: ${OPTIND}" + echo "OPTERR: ${OPTERR}" + + - name: "getopts: -- as first arg" + stdin: | + while getopts "ab:" myvar -- -a; do + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG}" + echo "OPTIND: ${OPTIND}" + echo "OPTERR: ${OPTERR}" + echo "----------------" + done + echo "Done; result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG}" + echo "OPTIND: ${OPTIND}" + echo "OPTERR: ${OPTERR}" + + - name: "getopts: invalid option when optstr starts with colon" + stdin: | + getopts ":a:" myvar -b value + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG}" + echo "OPTIND: ${OPTIND}" + echo "OPTERR: ${OPTERR}" + + - name: "getopts: invalid option when optstr does not start with colon" + ignore_stderr: true + stdin: | + getopts "a:" myvar -b value + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG}" + echo "OPTIND: ${OPTIND}" + echo "OPTERR: ${OPTERR}" + + - name: "getopts: multiple options in one token" + stdin: | + while getopts "a:bcdefg" myvar -fedcba something -g; do + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG}" + echo "OPTIND: ${OPTIND}" + echo "OPTERR: ${OPTERR}" + echo "----------------" + done + echo "Done; result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG}" + echo "OPTIND: ${OPTIND}" + echo "OPTERR: ${OPTERR}" + + - name: "getopts: short option and value in single arg" + stdin: | + getopts "p:" myvar -p- + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG}" + echo "OPTIND: ${OPTIND}" + echo "OPTERR: ${OPTERR}" + + while getopts "abep:" myvar -abpsomething-else; do + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG}" + echo "OPTIND: ${OPTIND}" + echo "OPTERR: ${OPTERR}" + echo "-----------------" + done + + - name: "getopts: OPTERR=0 suppresses error for unknown option" + stdin: | + OPTERR=0 + getopts "a:" myvar -x 2>/dev/null + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG-UNSET}" + echo "OPTIND: ${OPTIND}" + + - name: "getopts: OPTERR=1 shows error for unknown option" + ignore_stderr: true + stdin: | + OPTERR=1 + getopts "a:" myvar -x + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG-UNSET}" + echo "OPTIND: ${OPTIND}" + + - name: "getopts: OPTERR nonzero shows error" + ignore_stderr: true + stdin: | + OPTERR=42 + getopts "a:" myvar -x + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG-UNSET}" + + - name: "getopts: missing required argument without leading colon" + ignore_stderr: true + stdin: | + getopts "a:" myvar -a + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG-UNSET}" + echo "OPTIND: ${OPTIND}" + + - name: "getopts: missing required argument with leading colon" + stdin: | + getopts ":a:" myvar -a + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG}" + echo "OPTIND: ${OPTIND}" + + - name: "getopts: missing required argument with OPTERR=0" + stdin: | + OPTERR=0 + getopts "a:" myvar -a 2>/dev/null + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG-UNSET}" + echo "OPTIND: ${OPTIND}" + + - name: "getopts: combined unknown and missing arg errors" + ignore_stderr: true + stdin: | + func() { + while getopts "a:b:" opt "$@"; do + case "$opt" in + a) echo "a=$OPTARG" ;; + b) echo "b=$OPTARG" ;; + \?) echo "unknown" ;; + :) echo "missing arg for $OPTARG" ;; + esac + done + } + func -a foo -x -b + echo "Done: $?" + + - name: "getopts: bare dash as argument" + stdin: | + getopts "ab:" myvar - + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG-UNSET}" + echo "OPTIND: ${OPTIND}" + + - name: "getopts: leading colon with dash-colon option" + stdin: | + getopts ":a:" myvar -: + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG}" + echo "OPTIND: ${OPTIND}" + + - name: "getopts: unknown option in middle of combined flags" + ignore_stderr: true + stdin: | + while getopts "ac" myvar -axc; do + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG-UNSET}" + echo "OPTIND: ${OPTIND}" + echo "----------------" + done + echo "Done; result: $?" + echo "myvar: ${myvar}" + + - name: "getopts: missing arg in combined flags" + ignore_stderr: true + stdin: | + while getopts "a:b" myvar -ba; do + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG-UNSET}" + echo "OPTIND: ${OPTIND}" + echo "----------------" + done + echo "Final: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG-UNSET}" + echo "OPTIND: ${OPTIND}" + + - name: "getopts: combined flags with arg-taking option consumes next arg" + stdin: | + while getopts "a:b" myvar -ba nextarg; do + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG-UNSET}" + echo "OPTIND: ${OPTIND}" + echo "----------------" + done + echo "Done; result: $?" + echo "myvar: ${myvar}" + echo "OPTIND: ${OPTIND}" + + - name: "getopts: OPTIND reset between calls" + stdin: | + while getopts "a:b" myvar -b -a foo; do + echo "myvar: ${myvar}; OPTARG: ${OPTARG-UNSET}; OPTIND: ${OPTIND}" + done + echo "--- reset ---" + OPTIND=1 + while getopts "a:b" myvar -b -a bar; do + echo "myvar: ${myvar}; OPTARG: ${OPTARG-UNSET}; OPTIND: ${OPTIND}" + done + + - name: "getopts: empty optstring" + ignore_stderr: true + stdin: | + getopts "" myvar -a + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG-UNSET}" + echo "OPTIND: ${OPTIND}" + + - name: "getopts: OPTIND=0" + stdin: | + OPTIND=0 + getopts "a:" myvar -a value + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG}" + echo "OPTIND: ${OPTIND}" + + - name: "getopts: negative OPTIND treated as reset" + stdin: | + OPTIND=-1 + getopts "a:" myvar -a value + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG}" + echo "OPTIND: ${OPTIND}" + + - name: "getopts: bare dash among other args" + stdin: | + while getopts "ab" myvar -a - -b; do + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG-UNSET}" + echo "OPTIND: ${OPTIND}" + echo "----------------" + done + echo "Done; result: $?" + echo "myvar: ${myvar}" + echo "OPTIND: ${OPTIND}" + + - name: "getopts: OPTIND past end of args" + stdin: | + OPTIND=100 + getopts "a:b:" myvar -a value + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG-UNSET}" + echo "OPTIND: ${OPTIND}" + + - name: "getopts: missing arg with leading colon in combined flags" + stdin: | + while getopts ":a:b" myvar -ba; do + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG-UNSET}" + echo "OPTIND: ${OPTIND}" + echo "----------------" + done + echo "Done; result: $?" + + - name: "getopts: non-numeric OPTIND treated as reset" + stdin: | + OPTIND=abc + getopts "a:" myvar -a value + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG}" + echo "OPTIND: ${OPTIND}" + + - name: "getopts: option value is empty string" + stdin: | + getopts "a:" myvar -a "" + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: '${OPTARG}'" + echo "OPTIND: ${OPTIND}" + + - name: "getopts: option value looks like another option" + stdin: | + getopts "a:b" myvar -a -b + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG}" + echo "OPTIND: ${OPTIND}" + + - name: "getopts: OPTIND=0 resets mid-parse of combined options" + stdin: | + getopts "abc" myvar -abc + echo "first: ${myvar}" + OPTIND=0 + getopts "abc" myvar -abc + echo "after reset: ${myvar}" + + - name: "getopts: OPTERR non-numeric treated as enabled" + ignore_stderr: true + stdin: | + OPTERR=abc + getopts "a:" myvar -x + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG-UNSET}" + echo "OPTIND: ${OPTIND}" + + - name: "getopts: invalid variable name rejected" + ignore_stderr: true + stdin: | + getopts "a" "123abc" -a + echo "Result: $?" + + - name: "getopts: duplicate option char in optstring uses first definition" + stdin: | + getopts "aa:" myvar -a val + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG-UNSET}" + echo "OPTIND: ${OPTIND}" + + - name: "getopts: optstring is only a colon" + ignore_stderr: true + stdin: | + getopts ":" myvar -a + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTARG: ${OPTARG-UNSET}" + echo "OPTIND: ${OPTIND}" + + - name: "getopts: empty string argument is not an option" + stdin: | + getopts "a" myvar "" + echo "Result: $?" + echo "myvar: ${myvar}" + echo "OPTIND: ${OPTIND}" + + - name: "getopts writes through nameref" + known_failure: true + stdin: | + declare -n ref=result + getopts "a:b" ref -a value + echo "Result: $?" + echo "result: ${result}" + echo "ref: ${ref}" + echo "OPTARG: ${OPTARG}" + + - name: "getopts writes through subscripted nameref" + known_failure: true + stdin: | + arr=(x x x) + declare -n ref='arr[1]' + getopts "a:b" ref -a value + echo "Result: $?" + echo "arr[1]: ${arr[1]}" + echo "ref: ${ref}" diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/hash.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/hash.yaml new file mode 100644 index 0000000000..61f3410d7c --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/hash.yaml @@ -0,0 +1,69 @@ +name: "Builtins: hash" +cases: + - name: "Non-existent program" + ignore_stderr: true + stdin: | + hash non-existent-program + echo "Result: $?" + + - name: "Re-hash non-existent program" + ignore_stderr: true + stdin: | + hash -p /some/path non-existent-program + hash -t non-existent-program && echo "1. Result: $?" + hash non-existent-program && echo "2. Result: $?" + hash -t non-existent-program && echo "3. Result: $?" + + - name: "Existent program" + stdin: | + hash ls + echo "Result: $?" + + - name: "Display not-yet-hashed program" + ignore_stderr: true + stdin: | + hash -t ls + echo "1. Result: $?" + ls >/dev/null 2>&1 + hash -t ls + echo "2. Result: $?" + + - name: "Display hashed program" + stdin: | + hash -p /some/path somecmd && echo "1. Result: $?" + hash -t somecmd && echo "2. Result: $?" + + - name: "Display multiple hashed programs" + stdin: | + hash -p /some/path somecmd1 && echo "1. Result: $?" + hash -p /some/path somecmd2 && echo "2. Result: $?" + hash -t somecmd1 somecmd2 && echo "3. Result: $?" + + - name: "Display -l path" + stdin: | + hash -p "/some/spaces path" somecmd && echo "1. Result: $?" + hash -t somecmd && echo "2. Result: $?" + hash -t -l somecmd && echo "3. Result: $?" + + - name: "Remove" + ignore_stderr: true + stdin: | + hash -p /some/path somecmd && echo "1. Result: $?" + hash -t somecmd && echo "2. Result: $?" + hash -d somecmd && echo "3. Result: $?" + hash -t somecmd && echo "4. Result: $?" + + - name: "Remove all" + ignore_stderr: true + stdin: | + hash -p /some/path somecmd1 && echo "1. Result: $?" + hash -p /some/path somecmd2 && echo "2. Result: $?" + hash -r && echo "3. Result: $?" + hash -t somecmd1 && echo "4. Result: $?" + hash -t somecmd2 && echo "5. Result: $?" + + - name: "Hash file path" + ignore_stderr: true + stdin: | + hash /dev/null && echo "1. Result: $?" + hash /dev/nulll && echo "2. Result: $?" diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/help.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/help.yaml new file mode 100644 index 0000000000..e0151f5983 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/help.yaml @@ -0,0 +1,25 @@ +name: "Builtins: help" +cases: + - name: "Help" + ignore_stdout: true + ignore_stderr: true + stdin: | + help + + - name: "Topic-specific help" + ignore_stdout: true + ignore_stderr: true + stdin: | + help echo + + - name: "Help for unknown topic returns non-zero" + ignore_stdout: true + ignore_stderr: true + stdin: | + help this-is-not-a-real-builtin + + - name: "Help for known and unknown topics returns zero" + ignore_stdout: true + ignore_stderr: true + stdin: | + help echo this-is-not-a-real-builtin diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/history.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/history.yaml new file mode 100644 index 0000000000..67b99610fd --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/history.yaml @@ -0,0 +1,193 @@ +name: "Builtins: history" +cases: + - name: "basic history saving" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + echo something + echo else + + - name: "existing history file" + pty: true + env: + HISTFILE: "history-file.txt" + test_files: + - path: "history-file.txt" + contents: | + a b c + 1 2 3 + ignore_stdout: true # Prompts and other don't quite line up + stdin: | + #expect-prompt + history >history-output.txt + #send:Enter + #expect-prompt + #send:Ctrl+D + + - name: "existing history file with timestamps" + pty: true + env: + HISTFILE: "history-file.txt" + test_files: + - path: "history-file.txt" + contents: | + #1750000000 + a b c + #1750000001 + 1 2 3 + ignore_stdout: true # Prompts and other don't quite line up + stdin: | + #expect-prompt + history >history-output.txt + #send:Enter + #expect-prompt + #send:Ctrl+D + + - name: "existing history file with timestamps and HISTTIMEFORMAT" + pty: true + env: + HISTFILE: "history-file.txt" + HISTTIMEFORMAT: "%C " + test_files: + - path: "history-file.txt" + contents: | + #1750000000 + a b c + #1750000001 + 1 2 3 + ignore_stdout: true # Prompts and other don't quite line up + stdin: | + #expect-prompt + #send:Ctrl+D + + - name: "history N" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + : cmd1 + : cmd2 + history 2 + + - name: "history with duplicates" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + : cmd1 + : cmd1 + : cmd1 + : cmd2 + : cmd1 + history + + - name: "history -c" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + : cmd1 + history -c + history + + - name: "history -d" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + : cmd1 + history -d 1 + history + + - name: "history -d: negative offset" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + : cmd1 + : cmd2 + history -d -2 + history + + - name: "history -a" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + echo "[histfile]" + cat $HISTFILE + + history -a + + echo "[histfile after -a]" + cat $HISTFILE + + history -a + + echo "[histfile after second -a]" + cat $HISTFILE + + - name: "history -w" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + echo "[histfile]" + cat $HISTFILE + + history -w + + echo "[histfile after -w]" + cat $HISTFILE + + history -w + + echo "[histfile after second -w]" + cat $HISTFILE + + - name: "history -w with explicit file" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + : cmd1 + history -w some-file.txt + + - name: "history -s" + known_failure: true # NOTE: bash excludes the history command itself from history + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + history -s a b c + history + + - name: "HISTCMD" + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + stdin: | + : cmd1 + echo $HISTCMD + : cmd2 + echo $HISTCMD + : cmd3 + echo $HISTCMD + + - name: "HISTTIMEFORMAT" + known_failure: true # TODO(history): needs triage + args: ["-o", "history"] + env: + HISTFILE: "history-file.txt" + HISTTIMEFORMAT: "century=%C " + stdin: | + : cmd1 + : cmd2 + history + + - name: "history file with unreadable lines" + ignore_stderr: true # Ignore brush's warnings about unreadable lines + stdin: | + bash -c 'printf "\xff\xfe\xfd" >history-file.txt' + HISTFILE=history-file.txt $0 -o history -c 'echo hi' diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/jobs.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/jobs.yaml new file mode 100644 index 0000000000..fe7d006dbe --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/jobs.yaml @@ -0,0 +1,6 @@ +name: "Builtins: job-related builtins" +cases: + - name: "Basic async job" + stdin: | + echo hi & + wait diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/kill.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/kill.yaml new file mode 100644 index 0000000000..abbf7beced --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/kill.yaml @@ -0,0 +1,41 @@ +name: "Builtins: kill" +cases: + - name: "kill -l" + ignore_stderr: true + stdin: | + for i in $(seq 1 31); do kill -l $i; done + # limit the number of signals to 31. Realtime signals are not implemented yet. + for i in $(kill -l | sed -e "s/[[:digit:]]*)//g"); do echo $i; done | head -31 + # invalid option + kill -l 9999 + kill -l HUP + kill -l iNt + kill -l int + kill -l SIGHUP + kill -l EXIT + + - name: "kill -s" + stdin: | + kill -s USR1 $$ + + - name: "kill -n" + stdin: | + kill -n 9 $$ + + - name: "kill -sigspec" + stdin: | + kill -USR1 $$ + + - name: "kill -sigspec (numeric)" + stdin: | + kill -9 $$ + + - name: "kill -sigspec (numeric, full name resolution)" + stdin: | + kill -l $(kill -l KILL) + + - name: "kill -sigspec (numeric, invalid)" + ignore_stderr: true + stdin: | + kill -9999 $$ + echo "exit code: $?" diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/let.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/let.yaml new file mode 100644 index 0000000000..05c20c8185 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/let.yaml @@ -0,0 +1,28 @@ +name: "Builtins: let" +cases: + - name: "Basic let usage" + stdin: | + let 0; echo "0 => $?" + let 1; echo "1 => $?" + + let 0==0; echo "0==0 => $?" + let 0!=0; echo "0!=0 => $?" + + let 1 0; echo "1 0 => $?" + let 0 1; echo "0 1 => $?" + + - name: "let with assignment" + stdin: | + let x=10; echo "x=10 => $?; x==${x}" + + - name: "let assignment through nameref" + known_failure: true + stdin: | + target=0 + declare -n ref=target + let ref=42 + echo "target: $target" + let ref+=8 + echo "target: $target" + let ref++ + echo "target: $target" diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/local.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/local.yaml new file mode 100644 index 0000000000..05e4141be1 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/local.yaml @@ -0,0 +1,21 @@ +name: "Builtins: local" +cases: + - name: "Basic local usage" + stdin: | + myfunc() { + local x=10 + echo "in myfunc: x==$x" + } + x=5 + echo "before call: x==$x" + myfunc + echo "after call: x==$x" + + - name: "Local with empty array" + stdin: | + myfunc() { + local x=() + declare -p x + echo "x[0]: ${x[0]}" + } + myfunc diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/mapfile.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/mapfile.yaml new file mode 100644 index 0000000000..28a641d7e0 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/mapfile.yaml @@ -0,0 +1,202 @@ +name: "Builtins: mapfile" +cases: + # + # Basic functionality + # + - name: "mapfile basic" + stdin: | + mapfile arr < <(echo -e "a\nb\nc") + declare -p arr + + - name: "mapfile -t" + stdin: | + mapfile -t arr < <(echo -e "a\nb\nc") + declare -p arr + + - name: "readarray -t" + stdin: | + readarray -t arr < <(echo -e "a\nb\nc") + declare -p arr + + - name: "mapfile default MAPFILE" + stdin: | + mapfile -t < <(echo -e "a\nb") + declare -p MAPFILE + + # + # -O flag + # + - name: "mapfile -O starts at offset" + stdin: | + mapfile -t -O 3 arr < <(echo -e "a\nb\nc") + declare -p arr + + - name: "mapfile -O 0 preserves existing" + stdin: | + arr=(x y z w) + mapfile -t -O 0 arr < <(echo -e "a\nb") + declare -p arr + + - name: "mapfile without -O clears existing" + stdin: | + arr=(x y z w) + mapfile -t arr < <(echo -e "a\nb") + declare -p arr + + - name: "mapfile -O preserves sparse array" + stdin: | + declare -a arr + arr[0]=x + arr[5]=y + arr[10]=z + mapfile -t -O 2 arr < <(echo -e "a\nb") + declare -p arr + + - name: "mapfile -O large offset" + stdin: | + mapfile -t -O 100 arr < <(echo -e "a\nb") + declare -p arr + + - name: "mapfile -O negative" + ignore_stderr: true + stdin: | + mapfile -O -1 arr < /dev/null + echo "exit: $?" + + # + # Variable state edge cases + # + - name: "mapfile into nonexistent variable" + stdin: | + mapfile -t newvar < <(echo -e "a\nb") + declare -p newvar + + - name: "mapfile into string variable" + stdin: | + myvar="hello" + mapfile -t myvar < <(echo -e "a\nb") + declare -p myvar + + - name: "mapfile -O into string variable" + stdin: | + myvar="hello" + mapfile -t -O 2 myvar < <(echo -e "a\nb") + declare -p myvar + + - name: "mapfile into associative array" + ignore_stderr: true + stdin: | + declare -A myassoc + mapfile -t myassoc < <(echo -e "a\nb") + echo "exit: $?" + + # + # -d delimiter + # + - name: "mapfile -d custom delimiter" + stdin: | + mapfile -t -d 'Z' arr < <(echo -ne "helloZthereZgeneralZkenobi") + declare -p arr + + - name: "mapfile -d empty string (NUL)" + stdin: | + mapfile -t -d '' arr < <(printf 'a\0b\0c') + declare -p arr + + # + # -n count + # + - name: "mapfile -n limits count" + stdin: | + mapfile -t -n 2 arr < <(echo -e "a\nb\nc\nd") + declare -p arr + + - name: "mapfile -n 0 no limit" + stdin: | + mapfile -t -n 0 arr < <(echo -e "a\nb\nc") + declare -p arr + + # + # -s skip + # + - name: "mapfile -s" + stdin: | + mapfile -t -s 2 arr < <(echo -e "a\nb\nc\nd") + declare -p arr + + - name: "mapfile -s with -n" + stdin: | + mapfile -t -s 1 -n 2 arr < <(echo -e "a\nb\nc\nd\ne") + declare -p arr + + # + # -u file descriptor + # + - name: "mapfile -u reads from fd" + stdin: | + exec 3< <(echo -e "a\nb\nc") + mapfile -t -u 3 arr + exec 3<&- + declare -p arr + + - name: "mapfile -u invalid fd" + ignore_stderr: true + stdin: | + mapfile -u 99 arr + echo "exit: $?" + + # + # Combinations + # + - name: "mapfile -O with -s and -n" + stdin: | + mapfile -t -O 10 -s 1 -n 2 arr < <(echo -e "a\nb\nc\nd\ne") + declare -p arr + + - name: "mapfile multiple invocations without -O" + stdin: | + mapfile -t arr < <(echo -e "a\nb") + mapfile -t arr < <(echo -e "x\ny\nz") + declare -p arr + + - name: "mapfile multiple invocations with -O" + stdin: | + mapfile -t arr < <(echo -e "a\nb") + mapfile -t -O 2 arr < <(echo -e "x\ny\nz") + declare -p arr + + # + # Empty input + # + - name: "mapfile empty input" + stdin: | + mapfile -t arr < /dev/null + declare -p arr 2>/dev/null || echo "arr not set" + + - name: "mapfile empty input clears existing" + stdin: | + arr=(x y z) + mapfile -t arr < /dev/null + declare -p arr 2>/dev/null || echo "arr not set" + + - name: "mapfile -O empty input preserves" + stdin: | + arr=(x y z) + mapfile -t -O 0 arr < /dev/null + declare -p arr + + - name: "mapfile into nameref variable" + known_failure: true + stdin: | + declare -a target + declare -n ref=target + mapfile -t ref < <(echo -e "a\nb\nc") + declare -p target + + - name: "mapfile -O into nameref variable" + known_failure: true + stdin: | + target=(x y) + declare -n ref=target + mapfile -t -O 2 ref < <(echo -e "a\nb") + declare -p target diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/printf.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/printf.yaml new file mode 100644 index 0000000000..765dd5ca78 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/printf.yaml @@ -0,0 +1,397 @@ +name: "Builtins: printf" + +cases: + # Basic functionality tests + - name: "One-variable printf" + stdin: | + printf "%s" "0" + + - name: "Basic printf" + stdin: | + printf "%s, %s" "Hello" "world" + + - name: "printf -v" + stdin: | + printf -v myvar "%s, %s" "Hello" "world" + echo "myvar: '${myvar}'" + + - name: "printf -v with array index" + stdin: | + declare -a myarray=() + printf -v 'myarray[5]' "%s, %s" "Hello" "world" + declare | grep myarray + + - name: "printf with -v as a format arg" + stdin: | + printf "%s\n" "-v" + + - name: "printf -v with attached value" + stdin: | + printf -va "" + echo "a: '${a}'" + printf -vmyvar "%s, %s" "Hello" "world" + echo "myvar: '${myvar}'" + + - name: "printf -vv with attached value" + stdin: | + printf -vva "value" + echo "va: '${va}'" + + - name: "printf with no-consuming format and extra arguments" + stdin: | + printf "foo" bar baz qux + echo + printf "no specifiers\n" 1 2 3 + + - name: "printf format string starting with hyphen via --" + stdin: | + printf -- "-5\n" + printf -- "%s\n" "-v" + + - name: "printf with hyphen-prefixed format arguments" + stdin: | + printf "%s\n" -5 + printf "%d\n" -5 + printf "%s %s\n" -a -b + printf "%s\n" -v + printf "%s\n" --foo + printf "%s\n" - + printf "x%sy\n" -abc + printf "%d %d\n" -1 -2 -3 -4 + + - name: "printf with option-like format arguments and attached values" + stdin: | + printf "%s\n" "-v" "-va" "-vva" + + - name: "printf with -- among format arguments" + stdin: | + printf "%s\n" -- -x + printf "%s|%s\n" a -- b + printf -- "%s\n" -- x + + - name: "printf with hyphen-prefixed format string (invalid option)" + ignore_stderr: true + stdin: | + printf -x + echo "Result: $?" + + # Bash-specific %q format (shell-safe quoting) + - name: "printf %q" + stdin: | + echo "[1]" + printf "%q" 'TEXT'; echo + + echo "[2]" + printf "%q" '$VAR'; echo + + echo "[3]" + printf "%q" '"'; echo + + - name: "printf ~%q" + stdin: | + echo "[1]" + printf "~%q" 'TEXT'; echo + + echo "[2]" + printf "~%q" '$VAR'; echo + + echo "[3]" + printf "~%q" '"'; echo + + # ================================================================= + # STANDARD C PRINTF FORMAT SPECIFIERS + # These should work with external printf and bash builtin printf + # ================================================================= + + # Integer formatting tests + - name: "printf %d with integers" + stdin: | + printf "%d\n" 42 + printf "%d\n" -17 + printf "%d\n" 0 + + - name: "printf %o (octal)" + stdin: | + printf "%o\n" 42 + printf "%o\n" 8 + printf "%o\n" 255 + + - name: "printf %x and %X (hex)" + stdin: | + printf "%x\n" 255 + printf "%X\n" 255 + printf "%x\n" 42 + + # Floating point tests + - name: "printf %f (float)" + stdin: | + printf "%f\n" 3.14159 + printf "%.2f\n" 3.14159 + printf "%6.2f\n" 3.14159 + + - name: "printf %e and %E (scientific)" + stdin: | + printf "%e\n" 1234.5 + printf "%E\n" 1234.5 + + - name: "printf %g and %G (general)" + stdin: | + printf "%g\n" 1234.5 + printf "%G\n" 0.00012345 + + # Character formatting + - name: "printf %c (character)" + stdin: | + printf "%c\n" 65 + printf "%c\n" 97 + printf "%c%c%c\n" 72 101 121 + + # Width and precision tests + - name: "printf width formatting" + stdin: | + printf "%5s|\n" "hi" + printf "%-5s|\n" "hi" + printf "%05d|\n" 42 + + - name: "printf precision with strings" + stdin: | + printf "%.3s\n" "hello" + printf "%10.3s\n" "hello" + + # Multiple arguments and reuse + - name: "printf argument reuse" + stdin: | + printf "%s %s %s\n" "one" "two" + printf "%d %d %d\n" 1 2 + + # Special characters and escapes + - name: "printf with escape sequences" + stdin: | + printf "hello\nworld\n" + printf "tab\there\n" + printf "quote: \"\n" + + - name: "printf with backslash escapes" + stdin: | + printf "\\n\\t\\r\\\\\n" + + # Missing arguments (bash handles gracefully) + - name: "printf missing arguments" + stdin: | + printf "%s %s\n" "only_one" + + # Empty and edge cases + - name: "printf with empty format" + stdin: | + printf "" + + - name: "printf with just format string" + stdin: | + printf "just text\n" + + # Type conversion tests (safe cases only) + - name: "printf valid string to number conversion" + stdin: | + printf "%d\n" "42" + printf "%d\n" "0" + + - name: "printf with percent literal" + stdin: | + printf "100%% complete\n" + + - name: "printf %b (backslash escape expansion)" + known_failure: true + stdin: | + printf "%b\n" "hello\\nworld" + printf "%b\n" "tab\\there" + printf "%b\n" "quote\\\"here" + + - name: "printf %b with field width" + known_failure: true + stdin: | + printf "%10b|\n" "hello\\nworld" + printf "%-10b|\n" "tab\\there" + + - name: "printf %T (date/time formatting)" + known_failure: true # Not supported by uucore + stdin: | + printf "%(%Y-%m-%d)T\n" 1234567890 + + # Test argument reuse with bash-specific formats + - name: "printf %q argument reuse" + known_failure: true + stdin: | + printf "%q %q %q\n" "one with spaces" "two" + + - name: "printf %b argument reuse" + stdin: | + printf "%b %b %b\n" "one\\ntwo" "three" + + # Test C-style constant interpretation + - name: "printf with quoted character arguments" + known_failure: true + stdin: | + printf "%d\n" "'A" + printf "%d\n" '"' + + - name: "printf with plus/minus signs in numeric arguments" + stdin: | + printf "%d\n" "+42" + printf "%d\n" "-17" + + # ================================================================= + # ADDITIONAL STANDARD FORMAT SPECIFIERS + # More comprehensive coverage of printf(3) man page features + # ================================================================= + + # Additional format specifiers from man page + - name: "printf %i (signed integer)" + stdin: | + printf "%i\n" 42 + printf "%i\n" -17 + + - name: "printf %u (unsigned integer)" + stdin: | + printf "%u\n" 42 + printf "%u\n" 4294967295 + + # Advanced width and precision + - name: "printf with asterisk width and precision" + stdin: | + printf "%*s|\n" 10 "hello" + printf "%.*s|\n" 3 "hello world" + printf "%*.*s|\n" 10 3 "hello world" + + - name: "printf with zero precision and zero arg" + known_failure: true + stdin: | + printf "%.0d\n" 0 + + - name: "printf with zero precision" + stdin: | + printf "%.0d\n" 42 + + # Flag characters + - name: "printf with flag characters" + stdin: | + printf "%+d\n" 42 + printf "% d\n" 42 + printf "%#x\n" 255 + printf "%#o\n" 64 + + # Left justification + - name: "printf left justification" + stdin: | + printf "%-10s|\n" "left" + printf "%-10d|\n" 42 + + # Zero padding + - name: "printf zero padding" + stdin: | + printf "%010d\n" 42 + printf "%010.2f\n" 3.14 + + # Combining flags + - name: "printf combining flags" + stdin: | + printf "%+010d\n" 42 + printf "%-+10d|\n" 42 + + # More precision tests + - name: "printf precision with integers" + stdin: | + printf "%.5d\n" 42 + printf "%.5o\n" 42 + printf "%.5x\n" 42 + + # Large numbers and edge cases + - name: "printf with large numbers" + stdin: | + printf "%d\n" 2147483647 + printf "%u\n" 4294967295 + printf "%x\n" 4294967295 + + # More character and escape testing + - name: "printf with special characters" + stdin: | + printf "%c\n" 0 + printf "%c\n" 32 + printf "%c\n" 127 + + # Negative numbers with different formats + - name: "printf negative numbers" + stdin: | + printf "%d\n" -1 + printf "%o\n" -1 + printf "%x\n" -1 + printf "%u\n" -1 + + # Empty string handling + - name: "printf with empty strings" + stdin: | + printf "%s|\n" "" + printf "%5s|\n" "" + printf "%.5s|\n" "" + + # More empty string handling + - name: "printf with empty strings for numbers" + known_failure: true + stdin: | + printf "%d" "" + printf "%x" "" + + # Scientific notation edge cases + - name: "printf scientific notation edge cases" + stdin: | + printf "%e\n" 0.0 + printf "%E\n" 0.0 + printf "%g\n" 0.0000001 + printf "%G\n" 1000000.0 + + # Mixed format strings + - name: "printf mixed formats in one string" + stdin: | + printf "Dec: %d, Hex: %x, Oct: %o, Char: %c\n" 65 65 65 65 + + # Field width larger than content + - name: "printf wide fields" + stdin: | + printf "%20s|\n" "short" + printf "%20d|\n" 42 + + # Return value testing (should always be 0 for successful printf) + - name: "printf return value" + stdin: | + printf "test" + echo " exit: $?" + + # Error handling tests - marked as known failures due to external printf differences + - name: "printf with invalid format" + ignore_stderr: true + stdin: | + printf "%z\n" "test" + + - name: "printf with no arguments" + ignore_stderr: true + stdin: | + printf + echo "Result: $?" + + - name: "printf string to number conversion with invalid input" + known_failure: true # Need to investigate + stdin: | + printf "%d\n" "abc" + echo "Result: $?" + + - name: "printf %q with empty string" + stdin: | + printf '%q\n' '' + printf '~%q\n' '' + + - name: "printf -v through nameref" + known_failure: true + stdin: | + declare -n ref=target + printf -v ref "formatted %d" 42 + echo "target: $target" + echo "ref: $ref" diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/ps4.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/ps4.yaml new file mode 100644 index 0000000000..19b6bcdbd4 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/ps4.yaml @@ -0,0 +1,8 @@ +name: "Builtins: PS4" +cases: + - name: "PS4 expansion" + stdin: | + PS4='+$FUNCNAME ' + bar() { true; } + foo() { set -x; bar; set +x; } + foo diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/pushd_popd_dirs.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/pushd_popd_dirs.yaml new file mode 100644 index 0000000000..928127b678 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/pushd_popd_dirs.yaml @@ -0,0 +1,65 @@ +name: "Builtins: pushd/popd/dirs" +incompatible_configs: ["sh"] +cases: + - name: "Basic pushd usage" + stdin: | + cd / + pushd / + echo $? + echo $PWD + pushd /usr + dirs + echo $? + echo $PWD + popd + echo $? + echo $PWD + popd + echo $? + + - name: "pushd without dir change" + stdin: | + cd / + pushd -n /usr + dirs + + - name: "popd without dir change" + stdin: | + cd / + pushd / + pushd /usr + popd -n + dirs + + - name: "popd with empty stack" + ignore_stderr: true + stdin: popd + + - name: "pushd to non-existent dir" + ignore_stderr: true + stdin: pushd /non-existent-dir + + - name: "basic dirs usage" + stdin: | + cd / + dirs + + - name: "dirs with tilde replacement" + env: + HOME: /usr + stdin: | + echo "HOME: $HOME" + + cd ~ + echo "New PWD: $PWD" + + dirs + dirs -l + + - name: "dirs to clear" + stdin: | + cd /usr + pushd /usr + pushd / + dirs -c + dirs diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/pwd.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/pwd.yaml new file mode 100644 index 0000000000..7a517b8748 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/pwd.yaml @@ -0,0 +1,70 @@ +name: "Builtins: pwd" +cases: + - name: "Basic pwd usage" + stdin: | + cd / + pwd + echo "Result: $?" + # + cd usr + pwd + echo "Result: $?" + + - name: "pwd -LP" + stdin: | + mkdir -p ./level1/level2/level3 + cd level1 + ln -s ./level2/level3 ./symlink + + ( + cd ./symlink + basename $(pwd) + basename $(pwd -L) + basename $(pwd -P) + ) + ( + cd ./symlink + export PWD= + basename $(pwd) + basename $(pwd -L) + basename $(pwd -P) + ) + ( + cd ./symlink + export PWD= + # start a shell without $PWD + ( + basename $(pwd) + basename $(pwd -L) + basename $(pwd -P) + ) + ) + + cd ~ + pwd + pwd -L + pwd -P + + - name: "pwd with moved dir" + known_failure: true # Needs investigation + stdin: | + root=$(pwd) + + mkdir -p ${root}/subdir + cd ${root}/subdir + mv ${root}/subdir ${root}/renamed + + echo "pwd -L: $(basename $(pwd -L))" + echo "pwd -P: $(basename $(pwd -P))" + + - name: "pwd with removed dir" + known_failure: true # Needs investigation + stdin: | + root=$(pwd) + + mkdir -p ${root}/subdir + cd ${root}/subdir + rmdir ${root}/subdir + + echo "pwd -L: $(basename $(pwd -L))" + echo "pwd -P: $(basename $(pwd -P))" diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/read.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/read.yaml new file mode 100644 index 0000000000..8dc0e358a6 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/read.yaml @@ -0,0 +1,392 @@ +name: "Builtins: read" +cases: + - name: "Basic read usage from file" + test_files: + - path: "data.txt" + contents: | + a 1 + b 2 + stdin: | + while read name num; do echo "Hello, $name => $num"; done < data.txt + echo "Exited loop; name='$name', num='$num'" + + - name: "Basic read usage from file and no var name" + test_files: + - path: "data.txt" + contents: | + a 1 + b 2 + stdin: | + while read; do echo "reply: '$REPLY'"; done < data.txt + echo "Exited loop; reply='$REPLY'" + + - name: "Basic read usage with empty file" + stdin: | + while read name num; do echo "Hello, $name => $num"; done < /dev/null + declare -p name num + + while read; do echo "reply: '$REPLY'"; done < /dev/null + declare -p REPLY + + while read -a arr; do declare -p arr; done < /dev/null + declare -p arr + + - name: "Basic read usage from pipe" + stdin: | + echo "1." + (echo a; echo b) | while read name; do echo "Hello, $name"; done + + echo "2." + (echo "a b") | while read name; do echo "Hello, 1:$name REPLY:$REPLY"; done + + echo "3." + (echo "a b") | while read -a arr; do declare -p arr; done + + - name: "read with closed stdin" + ignore_stderr: true + stdin: | + echo 1 | read <&- + echo "result: $?" + + - name: "read from here string" + stdin: | + read myvar <<< "hello" + echo "myvar: ${myvar}" + + - name: "read from process substitution" + stdin: | + read myvar < <(echo hello) + echo "myvar: ${myvar}" + + - name: "read with custom IFS" + stdin: | + content=" a b c " + while IFS= read line; do + echo "LINE: '$line'" + done <<<"${content}" + + - name: "read text with tabs and custom IFS" + stdin: | + while IFS="" read myvar; do + echo "myvar1: |${myvar}|" + done < <(printf "a\tb\nc d\te\n") + + - name: "read with empty entries" + stdin: | + (echo "x"; echo ""; echo "y"; echo ""; echo "") | while read line; do echo "LINE: '$line'"; done + echo 'x,,y,z' | (IFS=',' read READ1 READ2 READ3 READ4; declare -p READ1; declare -p READ2; declare -p READ3; declare -p READ4) + + - name: "read -a with empty lines" + stdin: | + (echo "hi"; echo ""; echo "there"; echo ""; echo "you") | (read -a READ; declare -p READ) + (echo -e "hi\t\tthere\t\tyou") | (read -a READ -d $'\t'; declare -p READ) + + - name: "read -a with empty interior field" + stdin: | + echo -e -n "hi||there||you" | (IFS='|' read -a READ; declare -p READ) + echo -e -n "hi there you " | (IFS=' ' read -a READ; declare -p READ) + echo -e -n "hi there you " | (read -a READ; declare -p READ) + + - name: "read -a with empty leading field" + stdin: | + echo -e -n "|hi|there|you" | (IFS='|' read -a READ; declare -p READ) + + - name: "read -a with empty trailing field" + stdin: | + echo -e -n "hi|there|you|" | (IFS='|' read -a READ; declare -p READ) + + - name: "read -a with empty entries + empty delimiter" + stdin: | + (echo "hi"; echo ""; echo "there"; echo ""; echo "you") | (read -a READ -d ''; declare -p READ) + echo -e -n "hi\n\nthere\n\nyou\n" | (read -a READ -d ''; declare -p READ) + + - name: "read -a with empty entries + empty delimiter + custom IFS" + stdin: | + (echo 'x,,y,z'; echo 'w,v'; echo ''; echo ''; echo 'm') | (IFS=',' read -d "" -a READ; declare -p READ) + + - name: "read with empty delimiter" + min_oracle_version: 5.2 # \n renders differently in older shell versions + stdin: | + echo x | (read -d ""; declare -p REPLY) + + # Timeout tests (read -t) + - name: "read -t 0 with data available in pipe" + # TODO(read): + # This test is racy - poll(0) may check the pipe before echo's data arrives. + # Bash consistently wins this race; our implementation sometimes doesn't. + # Worth investigating. + skip: true + stdin: | + echo "test" | { read -t 0; echo "exit: $?"; } + + - name: "read -t 0 with no data (empty pipe)" + skip: true # TODO(read): see above + stdin: | + # Closed pipe with no data + : | { read -t 0; echo "exit: $?"; } + + - name: "read -t with data immediately available" + stdin: | + echo "hello world" | { read -t 1 line; echo "exit: $?, line: '$line'"; } + + - name: "read -t with fractional timeout and data available" + stdin: | + echo "test data" | { read -t 0.5 var; echo "exit: $?, var: '$var'"; } + + - name: "read -t with regular file (always ready)" + test_files: + - path: "data.txt" + contents: | + line1 + line2 + stdin: | + read -t 0 < data.txt; echo "exit: $?" + read -t 1 line < data.txt; echo "exit: $?, line: '$line'" + + - name: "read -t negative timeout is invalid" + ignore_stderr: true + stdin: | + read -t -1 + echo "exit: $?" + + - name: "read -t with -n option" + stdin: | + echo "abcdef" | { read -t 1 -n 3 var; echo "exit: $?, var: '$var'"; } + + - name: "read -t 0 with here string" + stdin: | + read -t 0 <<< "hello"; echo "exit: $?" + read -t 1 var <<< "hello"; echo "exit: $?, var: '$var'" + + # Raw mode tests (read -r) + - name: "read -r preserves backslashes" + stdin: | + echo 'hello\nworld' | { read -r line; echo "line: '$line'"; } + echo 'path\\to\\file' | { read -r line; echo "line: '$line'"; } + + - name: "read without -r processes escape sequences" + stdin: | + echo 'hello\nworld' | { read line; echo "line: '$line'"; } + echo 'a\\b' | { read line; echo "line: '$line'"; } + + - name: "read -r with trailing backslash" + stdin: | + printf 'line with trailing\\' | { read -r line; echo "line: '$line'"; } + + - name: "read without -r line continuation" + stdin: | + # Backslash-newline continues to next line + printf 'first\\\nsecond' | { read line; echo "line: '$line'"; } + + - name: "read -r does not do line continuation" + stdin: | + printf 'first\\\nsecond' | { read -r line; echo "line: '$line'"; } + + # -N vs -n option tests + - name: "read -n respects delimiter" + stdin: | + # -n stops at delimiter even before char count + echo "ab:cd" | { read -n 5 var; echo "var: '$var'"; } + + - name: "read -N ignores delimiter" + skip: true # TODO(read): test sometimes results in printf error + stdin: | + # -N reads exactly N chars, delimiter treated as regular char + printf "ab:cd" | { read -N 4 var; echo "var: '$var'"; } + + - name: "read -N does not split by IFS" + stdin: | + # -N should not split result by IFS + printf "a b c" | { read -N 5 var; echo "var: '$var'"; } + + - name: "read -N with array" + stdin: | + # With -a and -N, entire input goes to single array element + printf "a b c" | { read -N 5 -a arr; declare -p arr; } + + # Exit code tests + - name: "read exit code on success" + stdin: | + echo "data" | { read var; echo "exit: $?"; } + + - name: "read exit code on EOF" + stdin: | + # EOF returns non-zero + read var < /dev/null; echo "exit: $?" + + - name: "read exit code on EOF with partial data" + stdin: | + # EOF with partial data still returns non-zero but assigns + printf "partial" | { read var; echo "exit: $?, var: '$var'"; } + + # IFS edge cases + - name: "read with IFS containing both whitespace and non-whitespace" + stdin: | + echo "a:b c:d" | (IFS=': ' read v1 v2 v3 v4; echo "v1='$v1' v2='$v2' v3='$v3' v4='$v4'") + + - name: "read with consecutive non-whitespace delimiters" + stdin: | + echo "a::b" | (IFS=':' read v1 v2 v3; echo "v1='$v1' v2='$v2' v3='$v3'") + + - name: "read assignment to fewer variables than fields" + stdin: | + echo "a b c d e" | { read v1 v2; echo "v1='$v1' v2='$v2'"; } + + - name: "read assignment to more variables than fields" + stdin: | + echo "a b" | { read v1 v2 v3 v4; echo "v1='$v1' v2='$v2' v3='$v3' v4='$v4'"; } + + - name: "read last variable preserves original delimiters" + stdin: | + # Last variable should get remainder with original delimiters, not re-joined with space + echo "x:y:z:w" | (IFS=':' read a b; echo "a='$a' b='$b'") + echo "x:y::z:w" | (IFS=':' read a b; echo "a='$a' b='$b'") + echo "a:b c:d e" | (IFS=': ' read v1 v2; echo "v1='$v1' v2='$v2'") + + - name: "read with consecutive delimiters at field boundary" + stdin: | + # When field limit causes boundary at consecutive delimiters, + # extra delimiters should be preserved in remainder. + echo "x::y" | (IFS=':' read a b; echo "a='$a' b='$b'") + echo "x:::y" | (IFS=':' read a b; echo "a='$a' b='$b'") + # Also test with more than 2 variables + echo "a::b:c" | (IFS=':' read v1 v2 v3; echo "v1='$v1' v2='$v2' v3='$v3'") + + - name: "read with empty IFS preserves entire line" + stdin: | + # Empty IFS means no splitting - entire line goes to first variable + echo "a b c" | (IFS='' read v1 v2 v3; echo "v1='$v1' v2='$v2' v3='$v3'") + echo "a:b:c" | (IFS='' read v1 v2; echo "v1='$v1' v2='$v2'") + # With array + echo "a b c" | (IFS='' read -a arr; declare -p arr) + + - name: "read REPLY preserves leading and trailing whitespace" + stdin: | + # When no variable names given, REPLY should preserve whitespace + read <<< ' hello world ' + echo "REPLY='$REPLY'" + read <<< ' tab separated ' + echo "REPLY='$REPLY'" + + - name: "read -a clears pre-existing array elements" + stdin: | + # Array should be cleared before assignment, not merged + arr=(old1 old2 old3 old4 old5) + read -a arr <<< 'new1 new2' + declare -p arr + + - name: "read -N still processes backslashes without -r" + stdin: | + # -N ignores delimiter but still does backslash processing + printf 'a\\nb' | { read -N 4 var; echo "var='$var'"; } + printf 'a\\\\b' | { read -N 4 var; echo "var='$var'"; } + + - name: "read -r -N for truly raw fixed-length reads" + stdin: | + # -r -N should preserve backslashes exactly + printf 'a\\nb' | { read -r -N 4 var; echo "var='$var'"; } + printf 'a\\\\b' | { read -r -N 4 var; echo "var='$var'"; } + + - name: "read -u with invalid file descriptor" + ignore_stderr: true + stdin: | + read -u 99 var + echo "exit: $?" + + - name: "read into readonly variable" + ignore_stderr: true + stdin: | + readonly myvar="original" + echo "test" | read myvar + echo "exit: $?, myvar='$myvar'" + + - name: "read with mixed IFS into fewer variables" + stdin: | + # Complex IFS with fewer variables - verify remainder handling + echo 'x: y : z : w' | (IFS=': ' read a b; echo "a='$a' b='$b'") + + # Backslash escape counting tests (bash counts OUTPUT chars, not INPUT) + - name: "read -n counts output chars after escape processing" + stdin: | + # Bash counts OUTPUT characters toward -n limit (after escape processing). + # Input: a, \, b, c = 4 input bytes. With -n 3: + # 'a' (output 1), '\b' → 'b' (output 2), 'c' (output 3) = "abc" + printf 'a\\bc' | { read -n 3 var; echo "var='$var'"; } + # Input: \, \, a, b = 4 input bytes. With -n 3: + # '\\' → '\' (output 1), 'a' (output 2), 'b' (output 3) = "\ab" + printf '\\\\ab' | { read -n 3 var; echo "var='$var'"; } + + - name: "read -n with backslash-newline continuation" + stdin: | + # Backslash-newline is line continuation (consumes both, outputs nothing). + # Input: a, b, \, \n, c = 5 input bytes. With -n 3: + # 'a' (output 1), 'b' (output 2), '\n' → continuation, 'c' (output 3) = "abc" + printf 'ab\\\nc' | { read -n 3 var; echo "var='$var'"; } + + - name: "read trailing backslash discarded on EOF" + stdin: | + # Bash discards a pending backslash when EOF is reached + printf 'ab\\' | { read var; echo "var='$var'"; } + printf 'ab\\' | { read -n 5 var; echo "var='$var'"; } + + # Prompt tests (read -p) + - name: "read -p reads input correctly" + stdin: | + # Test that -p option reads input correctly (prompt display behavior + # differs between bash and brush when stdin is not a terminal) + read -p "Enter: " var <<< "hello" + echo "var='$var'" + + # File descriptor tests (read -u) + - name: "read -u with valid file descriptor" + stdin: | + # Read from a specific file descriptor + exec 3<<< "from fd 3" + read -u 3 var + echo "var='$var'" + exec 3<&- + + - name: "read -u combined with other options" + stdin: | + exec 4<<< "hello world test" + read -u 4 -a arr + declare -p arr + exec 4<&- + + # Silent mode tests (read -s) + - name: "read -s with pipe input" + stdin: | + # Silent mode should still read input correctly (just not echo it) + echo "secret" | { read -s var; echo "var='$var'"; } + + - name: "read -s with multiple variables" + stdin: | + echo "a b c" | { read -s v1 v2 v3; echo "v1='$v1' v2='$v2' v3='$v3'"; } + + - name: "read into nameref variable" + known_failure: true + stdin: | + declare -n ref=result + read ref <<< "input_value" + echo "result: $result" + echo "ref: $ref" + + - name: "read -a into nameref array" + known_failure: true + stdin: | + declare -n ref=myarr + read -a ref <<< "one two three" + echo "myarr[0]: ${myarr[0]}" + echo "myarr[1]: ${myarr[1]}" + echo "myarr[2]: ${myarr[2]}" + echo "ref[1]: ${ref[1]}" + + - name: "read into subscripted nameref" + known_failure: true + stdin: | + arr=(a b c) + declare -n ref='arr[1]' + read ref <<< "replaced" + echo "arr[0]: ${arr[0]}" + echo "arr[1]: ${arr[1]}" + echo "arr[2]: ${arr[2]}" diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/readonly.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/readonly.yaml new file mode 100644 index 0000000000..1905a244ff --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/readonly.yaml @@ -0,0 +1,53 @@ +name: "Builtins: readonly" +cases: + - name: "making var readonly" + stdin: | + my_var="value" + readonly my_var + + echo "Invoking declare -p..." + declare -p my_var + + - name: "using readonly with value" + stdin: | + readonly my_var="my_value" + + echo "Invoking declare -p..." + declare -p my_var + + - name: "readonly array after append" + stdin: | + ARR=() + f() { + ARR+=("a" "b") + readonly ARR + echo "${#ARR[@]}" + } + f + + - name: "readonly on nameref makes nameref readonly" + known_failure: true + ignore_stderr: true + stdin: | + target="value" + declare -n ref=target + readonly ref + declare -p ref + declare -p target + # Changing what ref points to should fail + declare -n ref=other 2>/dev/null + echo "exit: $?" + # But target should still be writable + target="new_value" + echo "target: $target" + + - name: "readonly target through nameref assignment" + known_failure: true + ignore_stderr: true + stdin: | + target="value" + readonly target + declare -n ref=target + ref="new_value" 2>/dev/null + echo "exit: $?" + echo "target: $target" diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/return.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/return.yaml new file mode 100644 index 0000000000..5e2bc7fc53 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/return.yaml @@ -0,0 +1,158 @@ +name: "Builtins: return" +cases: + - name: "Return outside function or script" + ignore_stderr: true + stdin: | + return 42 + + - name: "Return outside function with stderr to /dev/full" + ignore_stderr: true + stdin: | + # /dev/full may not be available on all platforms. + [[ -e /dev/full ]] || exit 0 + return 2>/dev/full + echo "exit: $?" + + - name: "Return in directly invoked script" + ignore_stderr: true + test_files: + - path: "script.sh" + contents: | + return 42 + echo "Got past return" + args: ["./script.sh"] + + - name: "Return from sourced script" + test_files: + - path: "script.sh" + contents: | + return 42 + echo "Got past return" + stdin: | + source script.sh + + - name: "Return from subshell" + ignore_stderr: true + stdin: | + (return) + echo "Got past subshell" + exit 42 + + - name: "Return from nested sourced script" + test_files: + - path: "inner.sh" + contents: | + return 42 + echo "Got past inner return" + - path: "outer.sh" + contents: | + source inner.sh + echo "Got to end of outer script" + stdin: | + source outer.sh + + - name: "Return in function" + test_files: + - path: "script.sh" + contents: | + myfunc() { + echo "In myfunc()" + return 5 + echo "Should not get here" + } + echo "Calling myfunc()..." + myfunc + echo "Returned: $?" + args: ["./script.sh"] + + - name: "Return in for loop in function" + stdin: | + myfunc() { + for i in 1 2 3; do + echo "In myfunc: $i" + return 5 + done + } + + myfunc + echo "Returned: $?" + + - name: "Return in arithmetic for loop in function" + stdin: | + myfunc() { + for ((i=0; i < 5; i++)); do + echo "In myfunc: $i" + return 5 + done + } + + myfunc + echo "Returned: $?" + + - name: "Return in while loop in function" + stdin: | + myfunc() { + i=0 + while [[ $i -lt 5 ]]; do + echo "In myfunc: $i" + return 33 + i=$((i+1)) + done + } + + myfunc + echo "Returned: $?" + + - name: "Return in case" + stdin: | + myfunc() { + case 1 in + 1) + echo "In case" + return 5 + echo "Should not get here" + ;; + esac + } + + myfunc + echo "Returned: $?" + + - name: "Return in brace group" + stdin: | + myfunc() { + { + echo "In brace group" + return 5 + echo "Should not get here" + } + } + + myfunc + echo "Returned: $?" + + - name: "Return in and/or" + stdin: | + myfunc() { + echo "In and/or" && return 5 && echo "Should not get here" + } + + myfunc + echo "Returned: $?" + + - name: "Return from nested clauses" + stdin: | + myfunc() { + while (($#)); do + case $1 in + *) + shift 2 || { + echo "Returning" + return 5 + } + echo "Shifted and fell through" + esac + done + } + + myfunc a b c diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/set.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/set.yaml new file mode 100644 index 0000000000..3d1f55705a --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/set.yaml @@ -0,0 +1,103 @@ +name: "Builtins: set" +cases: + - name: "set with no args" + stdin: | + MYVARIABLE=VALUE + set > set-output.txt + grep MYVARIABLE set-output.txt + + # Remove set-output.txt to avoid it being byte-for-byte compared. + rm set-output.txt + + - name: "Basic set usage" + stdin: | + set a b c d + echo ${*} + + - name: "set with options" + stdin: | + function dumpopts { + # Dump the options + echo "[Options: $1]" + echo "set options: " $- + shopt -p -o pipefail + } + + set -e -u -o pipefail + dumpopts enabled + echo '*: ' $* + + set +e +u +o pipefail + dumpopts disabled + echo '*: ' $* + + - name: "set with multiple combined options" + stdin: | + function dumpopts { + # Dump the options + echo "[Options: $1]" + echo "set options: " $- + shopt -p -o pipefail + } + + set -euo pipefail + dumpopts enabled + echo '$*: ' $* + + set +euo pipefail + dumpopts disabled + echo '$*: ' $* + + - name: "set clearing args" + stdin: | + set a b c + echo ${*} + set a + echo ${*} + + - name: "set with -" + stdin: | + set - a b c + echo "args: " ${*} + set - + echo "args: " ${*} + + - name: "set with --" + stdin: | + set -- a b c + echo "args: " ${*} + set -- + echo "args: " ${*} + + - name: "set with option-looking args" + stdin: | + set -- a -v + echo ${*} + + set - a -v + echo ${*} + + set a -v + echo ${*} + + set -- a +x + echo ${*} + + set - a +x + echo ${*} + + set a +x + echo ${*} + + - name: "function-local set" + known_failure: true + stdin: | + function f() { + local - + set -x + echo "In func" + } + + echo "Before func" + f + echo "After func" diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/shopt.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/shopt.yaml new file mode 100644 index 0000000000..0ae0dbe97c --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/shopt.yaml @@ -0,0 +1,77 @@ +name: "Builtins: shopt" +cases: + - name: "shopt defaults" + ignore_whitespace: true # Spacing changes across versions + min_oracle_version: 5.3 # Options changed in bash 5.3 + incompatible_os: ["fedora", "azurelinux"] # Fedora enables "syslog-history" option + stdin: | + shopt | sort | grep -v extglob + + - name: "shopt interactive defaults" + ignore_whitespace: true # Spacing changes across versions + min_oracle_version: 5.3 # Options changed in bash 5.3 + incompatible_os: ["fedora", "azurelinux"] # Fedora enables "syslog-history" option + pty: true + args: ["-i", "-c", "shopt | sort | grep -v extglob"] + + - name: "shopt -o defaults" + ignore_whitespace: true # Spacing changes across versions + min_oracle_version: 5.3 # Options changed in bash 5.3 + stdin: | + shopt -o | sort + + - name: "shopt -o interactive defaults" + ignore_whitespace: true # Spacing changes across versions + min_oracle_version: 5.3 # Options changed in bash 5.3 + pty: true + args: ["-i", "-c", "shopt -o | sort | grep -v monitor"] + + - name: "extglob defaults" + known_failure: true # TODO(patterns): we force this setting on in our shell + stdin: | + shopt extglob + + - name: "extglob interactive defaults" + pty: true + args: ["-i", "-c", "shopt extglob"] + known_failure: true + + - name: "shopt -o interactive monitor default" + ignore_whitespace: true # Spacing changes across versions + min_oracle_version: 5.3 # Options changed in bash 5.3 + pty: true + args: ["-i", "-c", "shopt -o monitor"] + + - name: "shopt toggle" + ignore_whitespace: true # Spacing changes across versions + stdin: | + echo "Setting checkwinsize" + shopt -s checkwinsize + + echo "Displaying checkwinsize" + shopt checkwinsize + shopt -p checkwinsize + + echo "Unsetting checkwinsize" + shopt -u checkwinsize + + echo "Displaying checkwinsize" + shopt checkwinsize + shopt -p checkwinsize + + - name: "shopt -o usage" + ignore_whitespace: true # Spacing changes across versions + stdin: | + echo "Setting emacs" + shopt -o -s emacs + + echo "Displaying emacs" + shopt -o emacs + shopt -o -p emacs + + echo "Unsetting emacs" + shopt -o -u emacs + + echo "Displaying emacs" + shopt -o emacs + shopt -o -p emacs diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/test.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/test.yaml new file mode 100644 index 0000000000..0543e3f1c8 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/test.yaml @@ -0,0 +1,357 @@ +name: "Builtins: test" +cases: + - name: "test: = operator" + stdin: | + shopt -u nocasematch + test "ab" = "ab" && echo "ab = ab" + test "ab" = "AB" && echo "ab = AB" + test "ab" = "cd" && echo "ab = cd" + test "ab" = "a?" && echo "ab = a?" + + shopt -s nocasematch + test "ab" = "ab" && echo "ab = ab" + test "ab" = "AB" && echo "ab = AB" + test "ab" = "cd" && echo "ab = cd" + test "ab" = "a?" && echo "ab = a?" + + - name: "test: == operator" + stdin: | + shopt -u nocasematch + test "ab" == "ab" && echo "ab == ab" + test "ab" == "AB" && echo "ab == AB" + test "ab" == "cd" && echo "ab == cd" + test "ab" == "a?" && echo "ab == a?" + + shopt -s nocasematch + test "ab" == "ab" && echo "ab == ab" + test "ab" == "AB" && echo "ab == AB" + test "ab" == "cd" && echo "ab == cd" + test "ab" == "a?" && echo "ab == a?" + + - name: "test: files refer to same device and inode" + stdin: | + [ /bin/sh -ef /bin/sh ] && echo "-ef correctly identified device and inode numbers" + + [ ! /etc/os-release -ef /bin/sh ] && echo "-ef correctly identified device and inode numbers that do not match" + + - name: "test: file is newer" + stdin: | + touch -d "2 hours ago" bar + touch foo + + [ foo -nt bar ] && echo "-nt correctly identified newer file" + [ foo -nt foo ] && echo "-nt incorrectly identified file as newer than itself" + [ foo -nt file_no_exists ] && echo "-nt correctly identified when file2 does not exist" + + - name: "test: file is older" + stdin: | + touch -d "2 hours ago" foo + touch bar + + [ foo -ot bar ] && echo "-ot correctly identified older file" + [ foo -ot foo ] && echo "-ot incorrectly identified file as older than itself" + [ file_no_exists -ot foo ] && echo "-ot correctly identified when file1 does not exist" + + - name: "test: parens" + stdin: | + test \( "a" = "a" \) && echo "Parens work" + + - name: "test: -- string" + stdin: | + test -- + echo "Result(test): $?" + + [ -- ] + echo "Result([): $?" + + - name: "test: invalid operators" + ignore_stderr: true + stdin: | + test - - + echo "Result: $?" + + # String operators + - name: "test: != operator" + stdin: | + test "ab" != "cd" && echo "ab != cd" + test "ab" != "ab" && echo "ab != ab" + test "" != "x" && echo "empty != x" + + - name: "test: -z operator (zero length)" + stdin: | + test -z "" && echo "-z empty" + test -z "x" && echo "-z x" + unset VAR + test -z "$VAR" && echo "-z unset" + + - name: "test: -n operator (non-zero length)" + stdin: | + test -n "x" && echo "-n x" + test -n "" && echo "-n empty" + VAR="hello" + test -n "$VAR" && echo "-n set" + + - name: "test: < operator (string sort before)" + stdin: | + test "a" \< "b" && echo "a < b" + test "b" \< "a" && echo "b < a" + test "abc" \< "abd" && echo "abc < abd" + test "a" \< "a" && echo "a < a" + + - name: "test: > operator (string sort after)" + stdin: | + test "b" \> "a" && echo "b > a" + test "a" \> "b" && echo "a > b" + test "abd" \> "abc" && echo "abd > abc" + test "a" \> "a" && echo "a > a" + + # Arithmetic comparison operators + - name: "test: -eq operator" + stdin: | + test 5 -eq 5 && echo "5 -eq 5" + test 5 -eq 6 && echo "5 -eq 6" + test -1 -eq -1 && echo "-1 -eq -1" + test 0 -eq 0 && echo "0 -eq 0" + + - name: "test: -ne operator" + stdin: | + test 5 -ne 6 && echo "5 -ne 6" + test 5 -ne 5 && echo "5 -ne 5" + test -1 -ne 1 && echo "-1 -ne 1" + + - name: "test: -lt operator" + stdin: | + test 3 -lt 5 && echo "3 -lt 5" + test 5 -lt 3 && echo "5 -lt 3" + test 5 -lt 5 && echo "5 -lt 5" + test -2 -lt -1 && echo "-2 -lt -1" + + - name: "test: -le operator" + stdin: | + test 3 -le 5 && echo "3 -le 5" + test 5 -le 5 && echo "5 -le 5" + test 6 -le 5 && echo "6 -le 5" + test -2 -le -2 && echo "-2 -le -2" + + - name: "test: -gt operator" + stdin: | + test 5 -gt 3 && echo "5 -gt 3" + test 3 -gt 5 && echo "3 -gt 5" + test 5 -gt 5 && echo "5 -gt 5" + test -1 -gt -2 && echo "-1 -gt -2" + + - name: "test: -ge operator" + stdin: | + test 5 -ge 3 && echo "5 -ge 3" + test 5 -ge 5 && echo "5 -ge 5" + test 3 -ge 5 && echo "3 -ge 5" + test -1 -ge -1 && echo "-1 -ge -1" + + # File existence and type operators + - name: "test: -e operator (file exists)" + stdin: | + touch testfile + test -e testfile && echo "-e existing file" + test -e nonexistent && echo "-e nonexistent" + mkdir testdir + test -e testdir && echo "-e existing dir" + + - name: "test: -a operator (file exists, deprecated)" + stdin: | + touch testfile + test -a testfile && echo "-a existing file" + test -a nonexistent && echo "-a nonexistent" + + - name: "test: -f operator (regular file)" + stdin: | + touch testfile + test -f testfile && echo "-f regular file" + mkdir testdir + test -f testdir && echo "-f directory" + test -f nonexistent && echo "-f nonexistent" + + - name: "test: -d operator (directory)" + stdin: | + mkdir testdir + test -d testdir && echo "-d directory" + touch testfile + test -d testfile && echo "-d regular file" + test -d nonexistent && echo "-d nonexistent" + + - name: "test: -h and -L operators (symbolic link)" + stdin: | + touch target + ln -s target symlink + test -h symlink && echo "-h symlink" + test -L symlink && echo "-L symlink" + test -h target && echo "-h regular file" + test -L target && echo "-L regular file" + test -h nonexistent && echo "-h nonexistent" + + - name: "test: -p operator (named pipe/FIFO)" + stdin: | + touch testfile + test -p testfile && echo "-p regular file" + test -p nonexistent && echo "-p nonexistent" + + - name: "test: -S operator (socket)" + stdin: | + touch testfile + test -S testfile && echo "-S regular file" + test -S nonexistent && echo "-S nonexistent" + + - name: "test: -b operator (block device)" + stdin: | + touch testfile + test -b testfile && echo "-b regular file" + test -b nonexistent && echo "-b nonexistent" + # Block devices exist in /dev but may vary by system + + - name: "test: -c operator (character device)" + stdin: | + touch testfile + test -c testfile && echo "-c regular file" + test -c nonexistent && echo "-c nonexistent" + + # File permission operators + - name: "test: -r operator (readable)" + stdin: | + touch testfile + test -r testfile && echo "-r readable" + test -r nonexistent && echo "-r nonexistent" + + - name: "test: -w operator (writable)" + stdin: | + touch testfile + test -w testfile && echo "-w writable" + test -w nonexistent && echo "-w nonexistent" + + - name: "test: -x operator (executable)" + stdin: | + touch testfile + test -x testfile && echo "-x executable" + + - name: "test: -s operator (size greater than zero)" + stdin: | + echo "content" > nonempty + test -s nonempty && echo "-s nonempty" + touch empty + test -s empty && echo "-s empty" + test -s nonexistent && echo "-s nonexistent" + + # Variable and option operators + - name: "test: -v operator (variable is set)" + stdin: | + VAR="hello" + test -v VAR && echo "-v set variable" + unset VAR + test -v VAR && echo "-v unset variable" + VAR="" + test -v VAR && echo "-v empty variable" + + - name: "test: -R operator (nameref variable)" + stdin: | + VAR="value" + declare -n REF=VAR + test -R REF && echo "-R nameref" + test -R VAR && echo "-R regular var" + unset REF VAR + + - name: "test: -o operator (shell option)" + stdin: | + set -e + test -o errexit && echo "-o errexit enabled" + set +e + test -o errexit && echo "-o errexit disabled" + test -o nonexistentoption && echo "-o nonexistent" + + # Logical operators + - name: "test: ! operator (negation)" + stdin: | + test ! "a" = "b" && echo "! a=b" + test ! "a" = "a" && echo "! a=a" + test ! -e nonexistent && echo "! -e nonexistent" + test ! -e / && echo "! -e /" + + - name: "test: -a operator (logical AND)" + stdin: | + test "a" = "a" -a "b" = "b" && echo "true -a true" + test "a" = "a" -a "b" = "c" && echo "true -a false" + test "a" = "b" -a "c" = "c" && echo "false -a true" + test "a" = "b" -a "c" = "d" && echo "false -a false" + + - name: "test: -o operator (logical OR)" + stdin: | + test "a" = "a" -o "b" = "b" && echo "true -o true" + test "a" = "a" -o "b" = "c" && echo "true -o false" + test "a" = "b" -o "c" = "c" && echo "false -o true" + test "a" = "b" -o "c" = "d" && echo "false -o false" + + - name: "test: complex logical expressions" + stdin: | + test \( "a" = "a" -o "b" = "c" \) -a "d" = "d" && echo "complex 1" + test ! \( "a" = "b" \) && echo "complex 2" + test "a" = "a" -a \( "b" = "b" -o "c" = "d" \) && echo "complex 3" + + # Edge cases + - name: "test: single argument (non-empty string is true)" + stdin: | + test "hello" && echo "non-empty is true" + test "" && echo "empty is true" + test "0" && echo "zero string is true" + + - name: "test: no arguments" + stdin: | + test + echo "no args: $?" + + - name: "test: special characters in strings" + stdin: | + test "hello world" = "hello world" && echo "spaces match" + test "a b" = "a b" && echo "tabs match" + test 'a"b' = 'a"b' && echo "quotes match" + test "a'b" = "a'b" && echo "single quotes match" + + - name: "test: arithmetic comparison with spaces/tabs in operand" + stdin: | + # Leading/trailing spaces + test 0 -eq ' 0' + echo "leading space: $?" + + test 0 -eq '0 ' + echo "trailing space: $?" + + test 0 -eq ' 0 ' + echo "both spaces: $?" + + # Tabs + test 0 -eq ' 0' + echo "leading tab: $?" + + test 0 -eq '0 ' + echo "trailing tab: $?" + + - name: "test: arithmetic comparison with newline in operand" + min_oracle_version: "5.3" + stdin: | + # Newline trimming seems to have changed in or around bash 5.3 + test 0 -eq ' 0 + ' + echo "newline trailing: $?" + + - name: "test: -t operator with whitespace in fd" + stdin: | + # -t operator should trim whitespace before parsing fd number + # These should all behave identically (false since not a terminal) + test -t 0 + r1=$? + test -t ' 0' + r2=$? + test -t '0 ' + r3=$? + test -t ' 0 ' + r4=$? + + # All should have the same result + [ $r1 -eq $r2 ] && [ $r2 -eq $r3 ] && [ $r3 -eq $r4 ] && echo "All -t results match" + diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/times.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/times.yaml new file mode 100644 index 0000000000..a351226017 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/times.yaml @@ -0,0 +1,6 @@ +name: "Builtins: times" +cases: + - name: "Basic usage" + ignore_stdout: true + stdin: | + times diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/trap.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/trap.yaml new file mode 100644 index 0000000000..48b7d50f05 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/trap.yaml @@ -0,0 +1,724 @@ +name: "Builtins: trap" +cases: + # + # trap -l signal listing + # + - name: "trap -l - lists all signal names" + stdin: | + # Just verify it outputs something reasonable - check common signals exist + trap -l | grep -qE 'HUP|SIGHUP' && echo "has HUP" + trap -l | grep -qE 'INT|SIGINT' && echo "has INT" + trap -l | grep -qE 'TERM|SIGTERM' && echo "has TERM" + + # + # Basic trap registration + # + - name: "trap registration" + stdin: | + trap "echo 1" SIGINT + trap "echo 2" SIGINT + trap -p INT + + trap "echo 3" int + trap -p INT + + trap "echo 4" 2 + trap -p INT + + - name: "trap unregistering" + stdin: | + echo "[Case 1]" + trap "echo 1" SIGINT + trap SIGINT + trap -p INT + + echo "[Case 2]" + trap "echo 2" SIGINT + trap - SIGINT + trap -p INT + + - name: "trap EXIT" + stdin: | + trap 'echo "[exit]"' EXIT + trap -p EXIT + + - name: "trap EXIT: status code handling" + stdin: | + trap 'echo "[exit]: \$?: $?"' EXIT + trap -p EXIT + false + + - name: "trap DEBUG" + stdin: | + trap 'echo [command: ${BASH_COMMAND}]' DEBUG + trap -p DEBUG + + echo First + echo Second + false + + - name: "trap ERR - registration only" + stdin: | + trap "echo [err]" ERR + trap -p ERR + + - name: "trap ERR - basic invocation" + stdin: | + trap 'echo "ERR: $?"' ERR + false + echo "continues" + false + echo "continues again" + + - name: "trap ERR - not triggered in conditional" + stdin: | + trap 'echo "ERR trapped"' ERR + if false; then + echo "then" + else + echo "else" + fi + echo "after" + + - name: "trap ERR - not triggered with && short-circuit" + stdin: | + trap 'echo "ERR trapped"' ERR + false && echo "not printed" + echo "after" + + - name: "trap ERR - not triggered with ||" + stdin: | + trap 'echo "ERR trapped"' ERR + false || echo "fallback" + echo "after" + + - name: "trap ERR - not triggered with negation" + stdin: | + trap 'echo "ERR trapped"' ERR + ! false + echo "after" + + - name: "trap ERR - not triggered in while condition" + stdin: | + trap 'echo "ERR"' ERR + while false; do + echo "loop body" + done + echo "after while" + + - name: "trap ERR - not triggered in until condition" + stdin: | + trap 'echo "ERR"' ERR + count=0 + until false; do + ((count++)) + if ((count > 2)); then break; fi + done + echo "after until" + + - name: "trap ERR - not triggered as part of || list" + stdin: | + trap 'echo "ERR"' ERR + false || true || false + echo "after" + + - name: "trap ERR - not triggered for negated command" + stdin: | + trap 'echo "ERR"' ERR + if ! true; then + echo "then" + fi + ! false + echo "after" + + - name: "trap ERR - triggered after && chain completes then fails" + stdin: | + trap 'echo "ERR trapped"' ERR + true && true + false + echo "after" + + - name: "trap ERR - execution doesn't affect control flow" + stdin: | + trap 'echo "ERR"' ERR + result=0 + false || result=$? + echo "result: $result" + + - name: "trap ERR - multiple failures trigger multiple traps" + stdin: | + count=0 + trap '((count++)); echo "ERR $count"' ERR + false + false + false + echo "total: $count" + + - name: "trap ERR - has access to exit status" + stdin: | + trap 'echo "ERR with status: $?"' ERR + (exit 42) + echo "after" + + - name: "trap ERR - has access to BASH_COMMAND" + known_failure: true # TODO(traps): ERR trap basic execution not implemented + stdin: | + trap 'echo "ERR for: $BASH_COMMAND"' ERR + nonexistent_command_xyz 2>/dev/null + echo "after" + + - name: "trap ERR - has access to LINENO" + known_failure: true # TODO(traps): ERR trap basic execution not implemented + stdin: | + trap 'echo "ERR at line: $LINENO"' ERR + false + echo "after" + + - name: "return in EXIT trap within function" + known_failure: true # TODO(traps): return in trap handler behavior differs + stdin: | + myfunc() { + trap 'return 0' EXIT + false + echo "should not print in func" + } + myfunc + echo "after func" + + - name: "subshell inherits signal traps (for trap -p display)" + stdin: | + trap 'echo "[parent int]"' INT + trap 'echo "[parent term]"' TERM + trap -p INT + echo "---" + (trap -p INT; trap -p TERM) + + - name: "subshell can override inherited trap" + stdin: | + trap 'echo "[parent int]"' INT + trap -p INT + echo "---" + (trap 'echo "[subshell int]"' INT; trap -p INT) + + - name: "subshell inherits EXIT trap" + stdin: | + trap 'echo "[exit]"' EXIT + trap -p EXIT + echo "---" + (trap -p EXIT) + + - name: "subshell inherits DEBUG trap" + stdin: | + trap 'echo "[debug]"' DEBUG + trap -p DEBUG + echo "---" + (trap -p DEBUG) + + - name: "subshell does not execute inherited DEBUG trap" + stdin: | + trap 'echo "[debug]"' DEBUG + echo "parent" + echo "---" + (echo "subshell") + echo "back in parent" + + - name: "subshell can set its own EXIT trap" + known_failure: true # TODO(traps): EXIT trap in subshell + stdin: | + trap 'echo "[parent exit]"' EXIT + echo "parent" + (trap 'echo "[subshell exit]"' EXIT; echo "subshell") + echo "back in parent" + + - name: "subshell inherited EXIT trap does not fire" + stdin: | + trap 'echo "[exit]"' EXIT + echo "parent" + (echo "subshell") + echo "back in parent" + + # + # Subshell trap inheritance in command substitution + # + - name: "command substitution inherits signal trap (trap -p display)" + stdin: | + trap 'echo "[int]"' INT + result=$(trap -p INT) + echo "$result" + + - name: "command substitution does not fire inherited signal trap" + known_failure: true # TODO(signals): signal handling in command substitution + stdin: | + trap 'echo "[int]" >&2' INT + result=$(kill -INT $$; echo "after kill") + echo "result: $result" + + - name: "command substitution can set its own trap" + stdin: | + trap 'echo "[parent int]"' INT + result=$(trap 'echo "[subshell int]"' INT; trap -p INT) + echo "$result" + + - name: "command substitution EXIT trap not inherited from parent" + stdin: | + trap 'echo "[parent exit]"' EXIT + result=$(trap -p EXIT; echo "inside") + echo "result: $result" + + - name: "command substitution can have its own EXIT trap" + known_failure: true # TODO: EXIT trap in command substitution subshell + stdin: | + trap 'echo "[parent exit]"' EXIT + result=$(trap 'echo "[sub exit]"' EXIT; echo "inside") + echo "result: $result" + + - name: "nested command substitution trap inheritance" + stdin: | + trap 'echo "[int]"' INT + outer=$(inner=$(trap -p INT); echo "inner: $inner") + echo "outer: $outer" + + # + # Subshell trap inheritance in pipelines + # + - name: "pipeline component inherits signal trap (trap -p display)" + stdin: | + trap 'echo "[int]"' INT + trap -p INT | cat + + - name: "pipeline component can override trap" + stdin: | + trap 'echo "[parent int]"' INT + { trap 'echo "[pipe int]"' INT; trap -p INT; } | cat + + - name: "pipeline EXIT trap behavior" + stdin: | + trap 'echo "[exit]"' EXIT + { trap -p EXIT; echo "in pipe"; } | cat + echo "after pipe" + + - name: "multiple pipeline stages trap inheritance" + stdin: | + trap 'echo "[int]"' INT + trap -p INT | cat | cat + + # + # Subshell trap inheritance in process substitution + # + - name: "process substitution inherits signal trap" + known_failure: true # TODO: process substitution trap inheritance + stdin: | + trap 'echo "[int]"' INT + cat <(trap -p INT) + + - name: "process substitution output trap inheritance" + skip: true # TODO: process substitution trap inheritance / inconsistent across distros + stdin: | + trap 'echo "[int]"' INT + echo "data" > >(trap -p INT; cat > /dev/null) + # Give time for the process substitution to complete + wait + + - name: "process substitution can set own EXIT trap" + known_failure: true # TODO: EXIT trap in process substitution + stdin: | + trap 'echo "[parent exit]"' EXIT + cat <(trap 'echo "[sub exit]"' EXIT; echo "inside") + echo "after" + + # + # Subshell trap inheritance in background jobs + # + - name: "background job inherits signal trap (trap -p display)" + stdin: | + trap 'echo "[int]"' INT + trap -p INT & + wait + + - name: "background job does not fire inherited signal trap" + known_failure: true # TODO(signals): background job signal handling + stdin: | + trap 'echo "[int]" >&2' INT + { kill -INT $$; echo "after kill"; } & + wait + echo "main done" + + - name: "background job EXIT trap behavior" + stdin: | + trap 'echo "[exit]"' EXIT + { trap -p EXIT; echo "bg job"; } & + wait + echo "main continues" + + - name: "background job can set own traps" + stdin: | + trap 'echo "[parent int]"' INT + { trap 'echo "[bg int]"' INT; trap -p INT; } & + wait + trap -p INT + + # + # Coproc trap inheritance (all coproc tests consolidated here) + # + # Note: "coproc - inherits signal trap" test is skipped due to bash version + # differences. Older bash resets traps in coproc, newer bash (5.3.9) inherits them. + # This causes inconsistent test results across CI runners. + - name: "coproc - EXIT trap behavior" + skip: true # TODO: this test is too inconsistent + min_oracle_version: "5.3.0" + incompatible_os: + - fedora + - arch + stdin: | + trap 'echo "[exit]"' EXIT + coproc { trap -p EXIT; echo "coproc"; } + cat <&${COPROC[0]} + wait + echo "main continues" + + - name: "coproc - DEBUG trap inheritance" + skip: true # TODO: this test is too inconsistent + min_oracle_version: "5.3.0" + incompatible_os: + - fedora + stdin: | + trap 'echo "[debug]"' DEBUG + coproc { trap -p DEBUG; echo "coproc"; } + cat <&${COPROC[0]} + wait + + - name: "coproc - ERR trap with errtrace" + skip: true # TODO: this test is too inconsistent + min_oracle_version: "5.3.0" + incompatible_os: + - fedora + - arch + stdin: | + set -E + trap 'echo "[err]"' ERR + coproc { trap -p ERR; false; echo "after false"; } + cat <&${COPROC[0]} + wait + + # + # RETURN trap - basic behavior + # + - name: "RETURN trap - fires on function return" + stdin: | + trap 'echo "[return]"' RETURN + myfunc() { + echo "in func" + } + myfunc + echo "after func" + + - name: "RETURN trap - fires with return value" + stdin: | + trap 'echo "[return: $?]"' RETURN + myfunc() { + echo "in func" + return 42 + } + myfunc + echo "after func" + + - name: "RETURN trap - fires on sourced script completion" + known_failure: true # TODO(traps): RETURN trap not firing for sourced scripts + test_files: + - path: "sourced.sh" + contents: | + echo "in sourced" + stdin: | + trap 'echo "[return]"' RETURN + . ./sourced.sh + echo "after source" + + - name: "RETURN trap - not inherited in function without functrace" + stdin: | + set +T + trap 'echo "[return]"' RETURN + outer() { + inner() { + echo "in inner" + } + inner + echo "after inner" + } + outer + echo "done" + + - name: "RETURN trap - inherited with functrace" + known_failure: true # TODO(traps): RETURN trap inheritance with functrace not implemented + stdin: | + set -T + trap 'echo "[return: ${FUNCNAME[0]:-main}]"' RETURN + outer() { + inner() { + echo "in inner" + } + inner + echo "after inner" + } + outer + echo "done" + + - name: "RETURN trap - in explicit subshell" + stdin: | + trap 'echo "[return]"' RETURN + ( + myfunc() { echo "in func"; } + myfunc + ) + echo "after subshell" + + - name: "RETURN trap - does not fire for regular commands" + stdin: | + trap 'echo "[return]"' RETURN + echo "cmd1" + echo "cmd2" + true + echo "done" + + - name: "RETURN trap - does not fire for subshell completion" + stdin: | + trap 'echo "[return]"' RETURN + (echo "in subshell") + echo "after subshell" + + # + # Signal trap execution verification + # + - name: "trap SIGUSR1" + known_failure: true # TODO(signals): signal handling + stdin: | + trap 'echo "[usr1 received]"' USR1 + trap -p USR1 + kill -USR1 $$ + echo "after signal" + + - name: "trap SIGUSR2" + known_failure: true # TODO(signals): signal handling + stdin: | + trap 'echo "[usr2 received]"' SIGUSR2 + trap -p USR2 + kill -USR2 $$ + echo "after signal" + + - name: "trap multiple signals with same handler" + known_failure: true # TODO(signals): signal handling + stdin: | + trap 'echo "[signal received]"' USR1 USR2 + kill -USR1 $$ + kill -USR2 $$ + echo "done" + + - name: "trap handler can inspect signal" + known_failure: true # TODO(signals): signal handling + stdin: | + handler() { + echo "handler called" + } + trap handler USR1 + kill -USR1 $$ + echo "after" + + # + # Trap reset and unset + # + - name: "trap - clears trap" + stdin: | + trap 'echo "[int]"' INT + trap -p INT + trap - INT + trap -p INT + + - name: "trap '' ignores signal" + known_failure: true # TODO(signals): signal handling + stdin: | + trap '' INT + trap -p INT + kill -INT $$ + echo "survived" + + - name: "trap with empty string can be cleared" + stdin: | + trap '' INT + trap -p INT + trap - INT + trap -p INT + + # + # trap -p edge cases + # + - name: "trap -p - with no args shows all traps" + stdin: | + trap 'echo a' INT + trap 'echo b' TERM + trap 'echo c' EXIT + trap -p | sort + + - name: "trap -p - with specific signal" + stdin: | + trap 'echo "[int]"' INT + trap 'echo "[term]"' TERM + trap -p INT + trap -p TERM + + - name: "trap -p - with multiple signals" + stdin: | + trap 'echo "[int]"' INT + trap 'echo "[term]"' TERM + trap 'echo "[usr1]"' USR1 + trap -p INT TERM USR1 + + - name: "trap -p - for unset trap shows nothing" + stdin: | + trap -p INT + echo "done" + + - name: "trap -p - output is re-executable" + stdin: | + trap 'echo "hello world"' INT + eval "$(trap -p INT)" + trap -p INT + + # + # Trap handler quoting + # + - name: "trap handler - single quotes preserved" + known_failure: true # TODO(traps): quote escaping in trap -p output differs + stdin: | + trap 'echo '\''quoted'\''' INT + trap -p INT + + - name: "trap handler - double quotes in handler" + stdin: | + trap 'echo "double quoted"' INT + trap -p INT + + - name: "trap handler - special characters" + stdin: | + trap 'echo $HOME; echo "done"' INT + trap -p INT + + - name: "trap handler - newlines in handler" + stdin: | + trap 'echo line1 + echo line2' INT + trap -p INT + + # + # DEBUG trap - basic behavior + # + - name: "DEBUG trap - sees BASH_COMMAND" + known_failure: true # TODO(BASH_COMMAND): not showing assignment, quotes differ + stdin: | + trap 'echo "cmd: $BASH_COMMAND"' DEBUG + x=1 + echo "x is $x" + + - name: "DEBUG trap - sees LINENO" + known_failure: true # TODO(traps): LINENO in DEBUG trap not accurate + stdin: | + trap 'echo "line: $LINENO"' DEBUG + echo first + echo second + + - name: "DEBUG trap - fires before each simple command" + known_failure: true # TODO(traps): DEBUG trap not firing for trap command itself + stdin: | + count=0 + trap '((count++))' DEBUG + echo a + echo b + echo c + echo "count: $count" + + - name: "DEBUG trap - in loop" + known_failure: true # TODO(BASH_COMMAND): quotes differ + stdin: | + trap 'echo "[debug: $BASH_COMMAND]"' DEBUG + for i in 1 2; do + echo "iter $i" + done + + # + # EXIT trap - advanced cases + # + - name: "EXIT trap - has access to exit status" + stdin: | + trap 'echo "exiting with: $?"' EXIT + exit 42 + + - name: "EXIT trap - runs even after errexit" + stdin: | + set -e + trap 'echo "[exit trap ran]"' EXIT + false + echo "not reached" + + - name: "EXIT trap - can modify exit status" + known_failure: true # TODO(traps): EXIT trap exit doesn't modify status + stdin: | + trap 'exit 0' EXIT + exit 99 + + - name: "EXIT trap - multiple assignments last one wins" + stdin: | + trap 'echo "first"' EXIT + trap 'echo "second"' EXIT + echo "done" + + # + # EXIT trap in -c mode + # + - name: "EXIT trap fires in -c mode" + args: + - "-c" + - "trap 'echo exiting' EXIT; echo hello" + expected_stdout: | + hello + exiting + + - name: "EXIT trap in -c mode has access to exit status" + args: + - "-c" + - "trap 'echo \"status: $?\"' EXIT; exit 42" + expected_stdout: "status: 42\n" + + - name: "EXIT trap in -c mode runs after errexit" + args: + - "-c" + - "set -e; trap 'echo trapped' EXIT; false" + expected_stdout: "trapped\n" + + - name: "EXIT trap in -c mode with multiple assignments last one wins" + args: + - "-c" + - "trap 'echo first' EXIT; trap 'echo second' EXIT; echo done" + expected_stdout: | + done + second + + # + # Trap with multiple signals + # + - name: "trap - clear multiple signals at once" + stdin: | + trap 'echo "[handler]"' INT TERM USR1 + trap -p INT + trap -p TERM + trap -p USR1 + echo "---" + trap - INT TERM USR1 + trap -p INT + trap -p TERM + trap -p USR1 + echo "done" + + - name: "trap - ignore multiple signals" + stdin: | + trap '' INT TERM + trap -p INT + trap -p TERM + echo "done" diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/true_false.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/true_false.yaml new file mode 100644 index 0000000000..eec5baaba7 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/true_false.yaml @@ -0,0 +1,7 @@ +name: "true and false" +cases: + - name: "true and false ignore all args" + stdin: | + builtin false --help; echo "builtin false --help => $?" + builtin true --help; echo "builtin true --help => $?" + builtin true s87tien5; echo "builtin true s87tien5 => $?" diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/type.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/type.yaml new file mode 100644 index 0000000000..b5fd135dfc --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/type.yaml @@ -0,0 +1,86 @@ +name: "Builtins: type" +cases: + - name: "Test type with no arguments" + stdin: type + + - name: "Test type with a valid command" + stdin: type ls + + - name: "Test type with an invalid command" + ignore_stderr: true + stdin: type invalid_command + + - name: "Test type with -t option and a builtin command" + stdin: type -t cd + + - name: "Test type with -t option and an external command" + stdin: type -t ls + + - name: "Test type with -t option and non-existent command" + ignore_stderr: true + stdin: type -t non-existent + + - name: "Test type with -a option and non-existent command" + ignore_stderr: true + stdin: type -a non-existent + + - name: "Test type with -a option and a command with multiple definitions" + stdin: type -a true + + - name: Test type with -p option and a builtin command + stdin: type -p cd + + - name: Test type with -p option and an external command + stdin: type -p ls + + - name: Test type with -p option and non-existent command + stdin: type -p non-existent + + - name: Test type with -P option and a builtin command + stdin: type -P cd + + - name: Test type with -P option and an external command + stdin: type -P ls + + - name: Test type with -P option and non-existent command + stdin: type -P non-existent + + - name: Test type with -f option and a function + ignore_stderr: true + stdin: | + function myfunc() { echo "Hello, world!"; } + type -f myfunc + + - name: Test type with -f option and a command + stdin: type -f ls + + - name: Test type with -a option and a function + stdin: | + function myfunc() { echo "Hello, world!"; } + type -a myfunc + + - name: Test type with hashed path + stdin: | + hash -p /some/ls ls + type ls + + - name: Test type -a with hashed path + stdin: | + hash -p /some/ls ls + type -a ls + + - name: Test type -P with hashed path + stdin: | + hash -p /some/ls ls + type -P ls + + - name: Test type -P -a with hashed path + min_oracle_version: 5.3 # Behavior changed in bash 5.3 + stdin: | + hash -p /some/ls ls + type -P -a ls + + - name: Test type -p -a with hashed path + stdin: | + hash -p /some/ls ls + type -p -a ls diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/typeset.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/typeset.yaml new file mode 100644 index 0000000000..794b46db6c --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/typeset.yaml @@ -0,0 +1,10 @@ +name: "Builtins: typeset" + +cases: + - name: "Display vars" + stdin: | + typeset myvar=something + typeset -p myvar + + typeset myarr=(a b c) + typeset -p myarr diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/ulimit.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/ulimit.yaml new file mode 100644 index 0000000000..bb64fface6 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/ulimit.yaml @@ -0,0 +1,37 @@ +name: "Builtins: ulimit" +cases: + - name: "ulimit -c" + stdin: | + ulimit -c + + - name: "ulimit -d" + stdin: | + ulimit -d + + - name: "ulimit -f" + stdin: | + ulimit -f + + - name: "ulimit -l" + stdin: | + ulimit -l + + - name: "ulimit -m" + stdin: | + ulimit -m + + - name: "ulimit -n" + stdin: | + ulimit -n + + - name: "ulimit -d unlimited" + stdin: | + ulimit -d unlimited + + - name: "ulimit -f unlimited" + stdin: | + ulimit -f unlimited + + - name: "ulimit -m unlimited" + stdin: | + ulimit -m unlimited diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/unalias.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/unalias.yaml new file mode 100644 index 0000000000..5f834de58e --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/unalias.yaml @@ -0,0 +1,15 @@ +name: "Builtins: unalias" +cases: + - name: "Unalias basic usage" + stdin: | + shopt -s expand_aliases + alias echo='echo prefixed' + echo 'something' + unalias echo + echo 'something' + + - name: "Unalias non-existent alias" + ignore_stderr: true # Slightly different error messages + stdin: | + shopt -s expand_aliases + unalias not_an_alias diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/unset.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/unset.yaml new file mode 100644 index 0000000000..91fe630a6f --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/unset.yaml @@ -0,0 +1,261 @@ +name: "Builtins: unset" +cases: + - name: "Basic unset usage" + stdin: | + var=value + unset var + echo "var: ${var}" + + - name: "Unset multiple variables" + stdin: | + var1=value1 + var2=value2 + unset var1 var2 + echo "var1: ${var1}, var2: ${var2}" + + - name: "Unset function" + ignore_stderr: true + stdin: | + function myfunc() { + echo "Hello, world!" + } + unset myfunc + myfunc + + - name: "Unset -f function" + ignore_stderr: true + stdin: | + function myfunc() { + echo "Hello, world!" + } + unset -f myfunc + myfunc + + - name: "Unset indexed array element" + stdin: | + declare -a myarray=(a b c) + + unset myarray[0] + declare -p myarray + + unset myarray[3] + declare -p myarray + + unset myarray[2] + declare -p myarray + + unset myarray[1] + declare -p myarray + + - name: "Unset associative array element" + stdin: | + echo "[1]" + + declare -A myarray=([key]=value) + + unset myarray[key] + declare -p myarray + + echo "[2]" + + declare -A myarray=([key]=value) + + unset "myarray[key]" + declare -p myarray + + echo "[3]" + + declare -A myarray=([key]=value) + key="x:y:z" + + unset "myarray[key]" + declare -p myarray + + - name: "Unset array element with interesting expression" + stdin: | + declare -a myarray=(a b c d e) + + echo "Initial array:" + declare -p myarray + echo + + i=4 + unset myarray[i] + echo "After removing element at index 4:" + declare -p myarray + echo + + unset myarray[6/2] + echo "After removing element at index 3:" + declare -p myarray + echo + + - name: "Unset array element with negative index" + stdin: | + declare -a myarray=(a b c d e) + + echo "Initial array:" + declare -p myarray + echo + + unset "myarray[-1]" + echo "After removing last element (index -1):" + declare -p myarray + echo + + unset "myarray[-1]" + echo "After removing new last element (index -1 again):" + declare -p myarray + echo + + unset "myarray[-2]" + echo "After removing second-to-last element (index -2):" + declare -p myarray + echo + + - name: "Unset array element with negative index out of bounds" + ignore_stderr: true + stdin: | + declare -a myarray=(a b c) + + echo "Initial array:" + declare -p myarray + echo + + unset "myarray[-4]" + echo "After attempting to remove with index -4 (out of bounds):" + declare -p myarray + echo "Return code: $?" + + - name: "Unset array element with variable negative index" + stdin: | + declare -a myarray=(10 20 30 40 50) + + echo "Initial array:" + declare -p myarray + echo + + i=-1 + unset "myarray[i]" + echo "After removing with variable index i=-1:" + declare -p myarray + echo + + i=-2 + unset "myarray[i]" + echo "After removing with variable index i=-2:" + declare -p myarray + echo + + - name: "Unset local in same function" + stdin: | + var="global" + + myfunc() { + echo "In myfunc" + local -i var="10" + declare -p var + unset var + echo "After unset" + declare -p var + var="20" + } + + echo "Before call: var=${var}" + myfunc + echo "After call: var=${var}" + + - name: "Unset locals in callers" + stdin: | + firstfunc() { + local var="first" + echo "entered firstfunc: var=${var}" + secondfunc + echo "leaving firstfunc: var=${var}" + } + + secondfunc() { + local var="second" + echo "entered secondfunc: var=${var}" + thirdfunc + echo "leaving secondfunc: var=${var}" + } + + thirdfunc() { + echo "entered thirdfunc: var=${var}" + unset -v var + echo "after first unset: var=${var}" + var+=":updated" + echo " ...and updated: var=${var}" + unset -v var + echo "after second unset: var=${var}" + var+=":updated" + echo " ...and updated: var=${var}" + unset -v var + echo "after third unset: var=${var}" + var+=":updated" + echo " ...and updated: var=${var}" + echo "leaving firstfunc: var=${var}" + } + + var="global" + echo "before calls: var=${var}" + firstfunc + echo "after calls: var=${var}" + + - name: "Unset with nameref" + known_failure: true + stdin: | + declare -n ref=var + var="value" + + echo "[Before unset]" + echo "ref: ${ref}" + echo "var: ${var}" + + unset ref + + echo "[After unset]" + echo "ref: ${ref}" + echo "var: ${var}" + + - name: "Unset -n removes nameref itself" + known_failure: true + stdin: | + target="value" + declare -n ref=target + echo "before: target=$target ref=$ref" + unset -n ref + echo "after unset -n: target=$target" + declare -p ref 2>/dev/null || echo "ref is gone" + declare -p target 2>/dev/null && echo "target still declared" + + - name: "Unset array element through nameref" + known_failure: true + stdin: | + target=(a b c d e) + declare -n ref=target + unset 'ref[2]' + declare -p target + unset 'ref[0]' + declare -p target + + - name: "Unset odd function names" + stdin: | + echo "[Checking existing]" + type [ + + echo "[Defining function]" + function [() { + : + } + + echo "[Checking defined]" + type [ + + echo "[Unsetting function]" + unset [ + echo "unset result: $?" + + echo "[Checking after unset]" + type [ diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/wait.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/wait.yaml new file mode 100644 index 0000000000..3c6b9ab877 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/builtins/wait.yaml @@ -0,0 +1,52 @@ +name: "Builtins: wait" +cases: + - name: "wait for specific failed PID" + known_failure: true # TODO(jobs): wait should return job's exit status + stdin: | + (exit 5) & + pid=$! + wait $pid + echo "status: $?" + + - name: "wait for successful PID" + stdin: | + (exit 0) & + pid=$! + wait $pid + echo "status: $?" + + - name: "wait in conditional for failed PID" + known_failure: true # TODO(jobs): wait should return job's exit status + stdin: | + (exit 5) & + pid=$! + if wait $pid; then + echo "then" + else + echo "else" + fi + + - name: "wait with no arguments" + stdin: | + (exit 0) & + (exit 0) & + wait + echo "after wait all" + + - name: "wait -n not implemented" + known_failure: true # TODO(builtins): wait -n not implemented + stdin: | + (exit 0) & + (exit 5) & + wait -n + echo "first done, status: $?" + wait -n + echo "second done, status: $?" + + - name: "Background job failure with wait" + known_failure: true # TODO(jobs): wait should return job's exit status + stdin: | + { sleep 0.01; exit 3; } & + pid=$! + wait $pid + echo "status: $?" diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/callstack.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/callstack.yaml new file mode 100644 index 0000000000..0574accb53 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/callstack.yaml @@ -0,0 +1,637 @@ +name: "Call stack" +common_test_files: + - path: "callstack_helpers.sh" + contents: | + # Helper to dump frame-independent call stack state. + # Optionally takes a prefix string to use in all output. + dump_source_info() { + local prefix="${1:-}" + + # Normalize shell name to make sure it matches. + # TODO: Once all tests are run on 5.3, this will need to be revisited. + echo "${prefix} caller: $(caller 2>&1 || echo '')" | sed -e "s|$0|main|g" | sed -e "s/environment/main/g" + + echo "${prefix} LINENO: ${LINENO}" + echo "${prefix} FUNCNAME[@]: ${FUNCNAME[*]}" + + # Normalize shell name to make sure it matches. + # TODO: Once all tests are run on 5.3, this will need to be revisited. + echo "${prefix} BASH_SOURCE[@]: ${BASH_SOURCE[*]}" | sed -e "s|$0|main|g" | sed -e "s/environment/main/g" + + # TODO(BASH_LINENO): implement correct BASH_LINENO tracking + # echo "${prefix} BASH_LINENO[@]: ${BASH_LINENO[*]}" + } + + # Helper to dump frame-specific call stack state + dump_frame() { + local prefix="${1:-}" + local frame="${2:-0}" + + echo "${prefix}[Frame $frame]" + + # Normalize shell name to make sure it matches. + # TODO: Once all tests are run on 5.3, this will need to be revisited. + echo "${prefix} caller N: $(caller $frame 2>&1 || echo '')" | sed -e "s|$0|main|g" | sed -e "s/environment/main/g" + } + + # Helper to dump all stack info + dump_stack() { + local prefix="${1:-}" + + dump_source_info "$prefix" + + local max_frames="${2:-20}" + local i + for ((i=0; i/dev/null 2>&1; then + break + fi + dump_frame "$prefix" $i + done + } + +cases: + - name: "No calls - top level" + stdin: | + source callstack_helpers.sh + + echo "=== Top level ===" + dump_stack + + - name: "Simple function call" + stdin: | + source callstack_helpers.sh + + my_function() { + echo "=== In my_function ===" + dump_stack " " + } + + my_function + + - name: "Nested function calls" + stdin: | + source callstack_helpers.sh + + level3() { + echo "=== In level3 ===" + dump_stack " " + } + + level2() { + echo "=== In level2 ===" + dump_stack " " + level3 + } + + level1() { + echo "=== In level1 ===" + dump_stack " " + level2 + } + + level1 + + - name: "Function call from sourced script" + test_files: + - path: "sourced.sh" + contents: | + sourced_function() { + echo "=== In sourced_function ===" + dump_stack " " + } + stdin: | + source callstack_helpers.sh + + source sourced.sh + sourced_function + + - name: "Nested sourcing" + test_files: + - path: "outer.sh" + contents: | + echo "=== In outer.sh top level ===" + dump_stack " " + source inner.sh + - path: "inner.sh" + contents: | + echo "=== In inner.sh top level ===" + dump_stack " " + + inner_func() { + echo "=== In inner_func ===" + dump_stack " " + } + + inner_func + stdin: | + source callstack_helpers.sh + + command source outer.sh + + - name: "Command substitution in top level" + stdin: | + source callstack_helpers.sh + + result=$(dump_stack " [CmdSub] ") + echo "$result" + + - name: "Command substitution in function" + stdin: | + source callstack_helpers.sh + + my_function() { + echo "=== In my_function ===" + result=$(dump_stack " [CmdSub] ") + echo "$result" + } + + my_function + + - name: "Command substitution invoking function" + stdin: | + source callstack_helpers.sh + + my_function() { + echo "=== In my_function via cmdSub ===" + dump_stack " " + } + + result=$(my_function) + echo "$result" + + - name: "Nested command substitutions" + stdin: | + source callstack_helpers.sh + + inner_func() { + echo "=== In inner_func ===" + dump_stack " " + } + + outer_func() { + echo "=== In outer_func ===" + result=$(inner_func) + echo "$result" + } + + final=$(outer_func) + echo "$final" + + - name: "eval at top level" + stdin: | + source callstack_helpers.sh + + eval 'echo "=== In eval ==="; dump_stack " "' + + - name: "eval in function" + stdin: | + source callstack_helpers.sh + + my_function() { + echo "=== In my_function ===" + eval 'echo "=== In eval ==="; dump_stack " "' + } + + my_function + + - name: "eval calling function" + stdin: | + source callstack_helpers.sh + + my_function() { + echo "=== In my_function via eval ===" + dump_stack " " + } + + eval 'my_function' + + - name: "Nested evals" + stdin: | + source callstack_helpers.sh + + my_function() { + echo "=== In my_function ===" + dump_stack " " + } + + eval 'eval "my_function"' + + - name: "eval with command substitution" + stdin: | + source callstack_helpers.sh + + my_function() { + echo "=== In my_function ===" + dump_stack " " + } + + result=$(eval 'my_function') + echo "$result" + + - name: "Trap handler - DEBUG" + stdin: | + source callstack_helpers.sh + + trap_handler() { + echo "=== In DEBUG trap handler ===" + dump_stack " " + } + + trap 'trap_handler' DEBUG + + echo "Command 1" + echo "Command 2" + + - name: "Trap handler - DEBUG in function" + stdin: | + source callstack_helpers.sh + + trap_handler() { + echo "=== In DEBUG trap handler ===" + dump_stack " " + } + + trap 'trap_handler' DEBUG + + my_function() { + echo "In my_function" + echo "Still in my_function" + } + + my_function + + - name: "Trap handler - EXIT" + stdin: | + source callstack_helpers.sh + + trap 'echo "=== In EXIT trap ==="; dump_stack " "' EXIT + + echo "Main script" + + - name: "Trap handler calling function" + stdin: | + source callstack_helpers.sh + + trap_function() { + echo "=== In trap_function ===" + dump_stack " " + } + + trap 'trap_function' EXIT + + echo "Main script" + + - name: "LINENO tracking across contexts" + stdin: | + echo "Line 1: LINENO=${LINENO}" + echo "Line 2: LINENO=${LINENO}" + + my_function() { + echo "In function line 1: LINENO=${LINENO}" + echo "In function line 2: LINENO=${LINENO}" + } + + my_function + + echo "Line 3: LINENO=${LINENO}" + + - name: "LINENO in eval" + stdin: | + echo "Before eval: LINENO=${LINENO}" + eval 'echo "In eval: LINENO=${LINENO}"' + echo "After eval: LINENO=${LINENO}" + + - name: "LINENO in command substitution" + stdin: | + echo "Before cmdSub: LINENO=${LINENO}" + result=$(echo "In cmdSub: LINENO=${LINENO}") + echo "$result" + echo "After cmdSub: LINENO=${LINENO}" + + - name: "LINENO in sourced script" + test_files: + - path: "script.sh" + contents: | + echo "Sourced line 1: LINENO=${LINENO}" + echo "Sourced line 2: LINENO=${LINENO}" + + sourced_func() { + echo "In sourced_func: LINENO=${LINENO}" + } + + sourced_func + stdin: | + echo "Main line 1: LINENO=${LINENO}" + source script.sh + echo "Main line 2: LINENO=${LINENO}" + + - name: "different depths" + stdin: | + source callstack_helpers.sh + + level3() { + dump_stack + } + + level2() { + level3 + } + + level1() { + level2 + } + + level1 + + - name: "BASH_SOURCE and FUNCNAME arrays" + test_files: + - path: "outer.sh" + contents: | + outer_func() { + echo "=== In outer_func ===" + echo " FUNCNAME[@]: ${FUNCNAME[*]}" + echo " BASH_SOURCE[@]: ${BASH_SOURCE[*]}" + source inner.sh + } + - path: "inner.sh" + contents: | + inner_func() { + echo "=== In inner_func ===" + echo " FUNCNAME[@]: ${FUNCNAME[*]}" + echo " BASH_SOURCE[@]: ${BASH_SOURCE[*]}" + } + + inner_func + stdin: | + source outer.sh + outer_func + + - name: "Mixed: eval in sourced function with command substitution" + test_files: + - path: "script.sh" + contents: | + script_func() { + echo "=== In script_func ===" + result=$(eval 'dump_stack " [Eval->CmdSub] "') + echo "$result" + } + stdin: | + source callstack_helpers.sh + + source script.sh + script_func + + - name: "Complex nesting: function -> eval -> cmdSub -> function" + stdin: | + source callstack_helpers.sh + + inner_func() { + echo "=== In inner_func ===" + dump_stack " " + } + + outer_func() { + echo "=== In outer_func ===" + eval 'result=$(inner_func); echo "$result"' + } + + outer_func + + # Tests with -c flag + - name: "Call stack with -c flag" + args: + ["-c", "source callstack_helpers.sh; echo '=== Via -c ==='; dump_stack"] + + - name: "Function call with -c flag" + args: + [ + "-c", + "my_func() { echo '=== In my_func via -c ==='; dump_stack; }; source callstack_helpers.sh; my_func", + ] + + - name: "Nested functions with -c flag" + args: + [ + "-c", + "f2() { echo 'In f2'; dump_stack; }; f1() { echo 'In f1'; f2; }; source callstack_helpers.sh; f1", + ] + + - name: "eval with -c flag" + args: + [ + "-c", + "eval 'source callstack_helpers.sh; echo In eval via -c; dump_stack'", + ] + + - name: "Command substitution with -c flag" + args: + [ + "-c", + "source callstack_helpers.sh; result=$(echo LINENO=${LINENO}; dump_stack); echo $result", + ] + + # Interactive mode tests + - name: "Interactive mode - simple function" + known_failure: true # TODO(LINENO): implement accurate interactive mode tracking + args: ["-i"] + ignore_stderr: true + stdin: | + source callstack_helpers.sh + + f() { dump_stack; } + f + + - name: "Interactive mode - FUNCNAME and BASH_SOURCE" + args: ["-i"] + ignore_stderr: true + stdin: | + f() { echo "FUNCNAME: ${FUNCNAME[@]}"; echo "BASH_SOURCE: ${BASH_SOURCE[@]}"; } + f + + # PROMPT_COMMAND tests + - name: "PROMPT_COMMAND with call stack" + known_failure: true # TODO(LINENO): implement accurate PROMPT_COMMAND line tracking + args: ["-i"] + ignore_stderr: true + stdin: | + source callstack_helpers.sh + + PROMPT_COMMAND='dump_stack "[PC] "' + echo test + + - name: "PROMPT_COMMAND calling function" + known_failure: true # TODO(LINENO): implement accurate PROMPT_COMMAND line tracking + args: ["-i"] + ignore_stderr: true + stdin: | + source callstack_helpers.sh + + pc_func() { echo "[PC_FUNC] In function"; dump_stack; } + PROMPT_COMMAND='pc_func' + echo test + + - name: "PROMPT_COMMAND with nested calls" + known_failure: true # TODO(LINENO): implement accurate PROMPT_COMMAND line tracking + args: ["-i"] + ignore_stderr: true + stdin: | + source callstack_helpers.sh + + inner() { dump_stack; } + outer() { inner; } + PROMPT_COMMAND='outer' + echo test + + # Additional edge cases + - name: "Recursive function call stack" + stdin: | + source callstack_helpers.sh + + recursive_func() { + local depth=$1 + echo "=== Depth $depth ===" + + if [ $depth -gt 0 ]; then + recursive_func $((depth - 1)) + else + dump_stack " " + fi + } + + recursive_func 3 + + - name: "Call stack with process substitution" + stdin: | + source callstack_helpers.sh + + my_func() { + echo "=== In my_func ===" + dump_stack " " + } + + # Process substitution context + cat <(my_func) + + - name: "Call stack across pipe" + stdin: | + source callstack_helpers.sh + + my_func() { + echo "=== In my_func ===" + dump_stack " " + } + + my_func | cat + + - name: "Call stack in subshell" + stdin: | + source callstack_helpers.sh + + my_func() { + echo "=== In my_func (subshell) ===" + dump_stack " " + } + + (my_func) + + - name: "Call stack with background job" + stdin: | + source callstack_helpers.sh + + my_func() { + echo "=== In my_func (background) ===" + dump_stack " " + } + + my_func & + wait + + - name: "BASH_SOURCE indexing" + test_files: + - path: "script.sh" + contents: | + echo "In script.sh" + echo " BASH_SOURCE[0]: ${BASH_SOURCE[0]}" + echo " BASH_SOURCE[1]: ${BASH_SOURCE[1]}" + + script_func() { + echo "In script_func" + echo " BASH_SOURCE[0]: ${BASH_SOURCE[0]}" + echo " BASH_SOURCE[1]: ${BASH_SOURCE[1]}" + echo " BASH_SOURCE[2]: ${BASH_SOURCE[2]}" + } + + script_func + stdin: | + echo "Main script" + echo " BASH_SOURCE[0]: ${BASH_SOURCE[0]}" + source script.sh + + - name: "FUNCNAME at top level" + stdin: | + echo "Top level FUNCNAME: '${FUNCNAME}'" + echo "Top level FUNCNAME[@]: '${FUNCNAME[@]}'" + echo "Top level FUNCNAME[*]: '${FUNCNAME[*]}'" + + - name: "Empty BASH_LINENO at top level" + stdin: | + echo "Top level BASH_LINENO[@]: '${BASH_LINENO[@]}'" + echo "Top level BASH_LINENO[*]: '${BASH_LINENO[*]}'" + + - name: "Call stack in trap with function call" + stdin: | + source callstack_helpers.sh + + trap_func() { + echo "=== In trap_func ===" + dump_stack " " + } + + my_func() { + echo "=== In my_func ===" + echo "About to trigger trap" + } + + trap 'trap_func' DEBUG + my_func + + - name: "Call stack: source within eval within function" + test_files: + - path: "script.sh" + contents: | + echo "=== In sourced script ===" + dump_stack " " + stdin: | + source callstack_helpers.sh + + my_func() { + echo "=== In my_func ===" + eval 'source script.sh' + } + + my_func + + - name: "Multiple eval levels" + stdin: | + source callstack_helpers.sh + + my_func() { + echo "=== In my_func ===" + dump_stack " " + } + + eval 'eval "eval \"my_func\""' + + - name: "LINENO persistence in function" + stdin: | + my_func() { + local line1=${LINENO} + local line2=${LINENO} + local line3=${LINENO} + echo "line1: $line1" + echo "line2: $line2" + echo "line3: $line3" + } + + my_func diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/complete_commands.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/complete_commands.yaml new file mode 100644 index 0000000000..a084e645ef --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/complete_commands.yaml @@ -0,0 +1,9 @@ +name: "Complete commands" +cases: + - name: "Multi-command sequence" + stdin: | + echo 1; echo 2; echo 3 + + - name: "Semicolon-terminated sequence" + stdin: | + echo 1; echo 2; diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/compound_cmds/arithmetic.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/compound_cmds/arithmetic.yaml new file mode 100644 index 0000000000..455fb7bfad --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/compound_cmds/arithmetic.yaml @@ -0,0 +1,16 @@ +name: "Compound commands: arithmetic" +cases: + - name: "Basic arithmetic statements" + stdin: | + ((0 == 0)) && echo "0 == 0" + ((0 != 0)) && echo "0 != 0" + + - name: "Arithmetic statements with parens" + stdin: | + (( (0) )) && echo "0" + (( (1) )) && echo "1" + + - name: "Arithmetic statements with parens and operators" + stdin: | + (( (0) == 0 )) && echo "0 == 0" + (( (1) != 0 )) && echo "1 != 0" diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/compound_cmds/arithmetic_for.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/compound_cmds/arithmetic_for.yaml new file mode 100644 index 0000000000..139f280a13 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/compound_cmds/arithmetic_for.yaml @@ -0,0 +1,110 @@ +name: "Compound commands: arithmetic for" +cases: + - name: "Single-line arithmetic for loop" + stdin: | + for ((i = 0; i < 5; i++)); do echo $i; done + echo "Result: $?" + + - name: "Arithmetic for loop without separator" + stdin: | + for ((i = 0; i < 5; i++)) do echo $i; done + echo "Result: $?" + + - name: "Arithmetic for loop with empty condition" + stdin: | + for (( ; ; )); do + echo "In loop; status: $?" + break + done + echo "Result: $?" + + - name: "Arithmetic for loop with ;;" + known_failure: true + stdin: | + for ((;;)); do + echo "In loop; status: $?" + break + done + echo "Result: $?" + + - name: "Break in arithmetic for loop" + stdin: | + for ((i = 0; i < 5; i++)); do + echo $i + break + done + echo "Result: $?" + + - name: "Continue in arithmetic for loop" + stdin: | + for ((i = 0; i < 5; i++)); do + continue + echo "Should not print" + done + echo "Result: $?" + + - name: "Arithmetic for with break in condition inside outer loop" + stdin: | + for j in 1 2 3; do + echo "Outer iteration $j" + for ((i = 0; break; i++)); do + echo "Inner body" + done + echo "After inner for" + done + echo "After outer loop" + + - name: "Arithmetic for with continue in condition inside outer loop" + stdin: | + for j in 1 2 3; do + echo "Outer iteration $j" + for ((i = 0; continue; i++)); do + echo "Inner body" + done + echo "After inner for" + done + echo "After outer loop" + + - name: "Arithmetic for with return in body" + stdin: | + f() { + for ((i = 0; i < 5; i++)); do + echo "Iteration $i" + if [ $i -eq 2 ]; then + return 42 + fi + done + echo "After for" + } + f + echo "Exit code: $?" + + - name: "Arithmetic for with exit in body" + stdin: | + for ((i = 0; i < 5; i++)); do + echo "Iteration $i" + if [ $i -eq 2 ]; then + exit 42 + fi + done + echo "After for" + + - name: "Arithmetic for with alternate syntax" + stdin: | + for ((i = 0; i < 5; i++)) { + echo "Iteration $i" + } + + - name: "Arithmetic for with alternate syntax (one line)" + stdin: | + for ((i = 0; i < 5; i++)) { echo "Iteration $i"; } + + - name: "Arithmetic for loop with nameref" + known_failure: true + stdin: | + target=0 + declare -n ref=target + for ((ref=1; ref<=3; ref++)); do + echo "ref=$ref target=$target" + done + echo "final target=$target" diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/compound_cmds/brace.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/compound_cmds/brace.yaml new file mode 100644 index 0000000000..63735601ac --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/compound_cmds/brace.yaml @@ -0,0 +1,5 @@ +name: "Compound commands: brace" +cases: + - name: "Brace command" + stdin: | + { echo 'hi'; } diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/compound_cmds/case.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/compound_cmds/case.yaml new file mode 100644 index 0000000000..0857e4c1fb --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/compound_cmds/case.yaml @@ -0,0 +1,185 @@ +name: "Compound commands: case" +cases: + - name: "Basic case statement with double semi" + test_files: + - path: "script.sh" + contents: | + case x in + x) echo hi;; + esac + args: ["./script.sh"] + + - name: "Basic catch-all pattern against multi-line input" + stdin: | + case "$(echo hi; echo there)" in + "not-it") echo "did not work" ;; + *) echo "caught it" ;; + esac + + - name: "One-line case statement with double semi" + stdin: | + case x in x) echo "hi";; esac + + - name: "Interesting characters in cases" + stdin: | + case "{" in + {) echo "curly brace" ;; + *) echo "unhandled case" ;; + esac + + - name: "Case with case insensitive pattern" + stdin: | + shopt -s nocasematch + case "A" in + a) echo "matched" ;; + *) echo "did not match" ;; + esac + + - name: "Interesting patterns in cases" + stdin: | + for word in "-a" "!b" "*c" "(d" "{e" ":f" "'g"; do + case "${word}" in + \!*) echo "starts with exclamation" ;; + -*) echo "starts with hyphen" ;; + \**) echo "starts with asterisk" ;; + \(*) echo "starts with open parenthesis" ;; + \{*) echo "starts with open curly brace" ;; + :*) echo "starts with colon" ;; + \'*) echo "starts with single quote" ;; + *) echo "unhandled case" ;; + esac + done + + - name: "Empty case" + stdin: | + myfunc() { + case abc in + *b*) ;; + *) return 33;; + esac + + echo "Dropped out" + } + + myfunc + + - name: "Case with non-dsemi" + stdin: | + case "b" in + a) echo "a";; + b) echo "b" + esac + + - name: "Case with fall-through" + stdin: | + case "b" in + a) echo "a";; + b) echo "b";& + c) echo "c";; + d) echo "d";; + esac + + - name: "Case with resuming switch" + stdin: | + case "b" in + a) echo "a";; + b) echo "b";;& + c) echo "c";; + *) echo "*";; + d) echo "d";; + esac + + - name: "Case status values" + stdin: | + function yield() { + return $1 + } + + case "a" in + x) yield 10;; + a) yield 11;; + b) yield 12;; + esac + echo "1: $?" + + case "a" in + b) yield 10;; + c) yield 11;; + esac + echo "2: $?" + + case "a" in + a) yield 10;& + b) yield 11;; + c) yield 12;; + esac + echo "3: $?" + + case "a" in + a) yield 10;;& + *) yield 11;; + x) yield 12;; + esac + echo "4: $?" + + - name: "Case with reserved words" + stdin: | + case case in + case) echo "saw case";; + esac + + - name: "Case with return in function" + stdin: | + f() { + case "a" in + a) echo "matched a"; return 42;; + b) echo "matched b";; + esac + echo "After case" + } + f + echo "Exit code: $?" + + - name: "Case with exit" + stdin: | + case "a" in + a) echo "matched a"; exit 42;; + b) echo "matched b";; + esac + echo "After case" + + - name: "Case with break in loop" + stdin: | + for i in 1 2 3; do + case "$i" in + 1) echo "one";; + 2) echo "two"; break;; + 3) echo "three";; + esac + echo "After case iteration $i" + done + echo "After loop" + + - name: "Case with continue in loop" + stdin: | + for i in 1 2 3; do + case "$i" in + 1) echo "one";; + 2) echo "two"; continue;; + 3) echo "three";; + esac + echo "After case iteration $i" + done + echo "After loop" + + - name: "Case with return and fall-through" + stdin: | + f() { + case "a" in + a) echo "matched a"; return 42;& + b) echo "matched b";; + esac + echo "After case" + } + f + echo "Exit code: $?" diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/compound_cmds/coproc.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/compound_cmds/coproc.yaml new file mode 100644 index 0000000000..5074d7032b --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/compound_cmds/coproc.yaml @@ -0,0 +1,839 @@ +name: "Compound commands: coproc" + +# NOTE: Coproc tests must defend against TWO race conditions caused by bash's +# SIGCHLD handler: +# +# 1. The handler clears the COPROC array when the coproc child exits, so +# ${COPROC[0]} can become empty before the parent captures it. +# 2. The handler ALSO closes the underlying pipe FDs, so even after capturing +# the integer FD, reading from it returns EOF immediately. +# +# We defend against both by: +# (a) Having the coproc body block on `read -r _ < _sync` first, so it can't +# exit before the parent dups the FD (no SIGCHLD can fire yet). +# (b) Using `exec 9<&${COPROC[0]}` to dup the pipe to a fresh FD that bash +# does NOT manage as part of the coproc, so it survives SIGCHLD cleanup. +# (c) Then `echo x > _sync` releases the coproc to do its real work. +# +# We use explicit FD 9 (rather than `{fd}<&...` auto-assignment) for portability. + +cases: + # + # Basic invocation + # + - name: "Unnamed coproc with brace group" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc { read -r _ < _sync; echo "hello from coproc"; } + exec 9<&${COPROC[0]} + echo x > _sync + cat <&9 + wait + args: ["./test.sh"] + + - name: "Unnamed coproc with subshell" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc ( read -r _ < _sync; echo "hello from subshell coproc" ) + exec 9<&${COPROC[0]} + echo x > _sync + cat <&9 + wait + args: ["./test.sh"] + + - name: "Named coproc with brace group" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc MYCOPROC { read -r _ < _sync; echo "named coproc"; } + exec 9<&${MYCOPROC[0]} + echo x > _sync + cat <&9 + wait + args: ["./test.sh"] + + - name: "Named coproc with subshell" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc MYSUB ( read -r _ < _sync; echo "named subshell coproc" ) + exec 9<&${MYSUB[0]} + echo x > _sync + cat <&9 + wait + args: ["./test.sh"] + + - name: "Coproc with simple command" + skip: true # TODO(coproc): hangs - wait never completes after bidirectional coproc I/O + test_files: + - path: "test.sh" + contents: | + coproc cat + rfd=${COPROC[0]} + wfd=${COPROC[1]} + echo "input line" >&$wfd + exec {wfd}>&- + read -r line <&$rfd + echo "got: $line" + wait + args: ["./test.sh"] + + - name: "Coproc with multi-statement brace group" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc { + read -r _ < _sync + echo "line1" + echo "line2" + echo "line3" + } + exec 9<&${COPROC[0]} + echo x > _sync + cat <&9 + wait + args: ["./test.sh"] + + # + # COPROC and COPROC_PID variable validation via functional use + # + - name: "COPROC array read FD is functional" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc { read -r _ < _sync; echo "via read fd"; } + exec 9<&${COPROC[0]} + echo x > _sync + read -r line <&9 + echo "got: $line" + wait + args: ["./test.sh"] + + - name: "Named coproc FDs are functional" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc MYPROC { read -r _ < _sync; echo "named fd test"; } + exec 9<&${MYPROC[0]} + echo x > _sync + read -r line <&9 + echo "got: $line" + wait + args: ["./test.sh"] + + # + # Reading from coproc stdout + # + - name: "Read coproc output with cat" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc { read -r _ < _sync; echo "output from coproc"; } + exec 9<&${COPROC[0]} + echo x > _sync + cat <&9 + wait + args: ["./test.sh"] + + - name: "Read coproc output with read builtin" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc { read -r _ < _sync; echo "readline test"; } + exec 9<&${COPROC[0]} + echo x > _sync + read -r line <&9 + echo "got: $line" + wait + args: ["./test.sh"] + + - name: "Read multiple lines from coproc" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc { + read -r _ < _sync + echo "first" + echo "second" + echo "third" + } + exec 9<&${COPROC[0]} + echo x > _sync + cat <&9 + wait + args: ["./test.sh"] + + - name: "Read multiple lines with read builtin" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc { + read -r _ < _sync + echo "line1" + echo "line2" + } + exec 9<&${COPROC[0]} + echo x > _sync + { + read -r first + read -r second + } <&9 + echo "first=$first" + echo "second=$second" + wait + args: ["./test.sh"] + + - name: "Named coproc read" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc READER { read -r _ < _sync; echo "from named"; } + exec 9<&${READER[0]} + echo x > _sync + cat <&9 + wait + args: ["./test.sh"] + + # + # Writing to coproc stdin + # + - name: "Write to coproc stdin and read back" + skip: true # TODO(coproc): hangs - wait never completes after bidirectional coproc I/O + test_files: + - path: "test.sh" + contents: | + coproc cat + rfd=${COPROC[0]} + wfd=${COPROC[1]} + echo "hello coproc" >&$wfd + exec {wfd}>&- + read -r line <&$rfd + echo "got: $line" + wait + args: ["./test.sh"] + + - name: "Bidirectional communication" + skip: true # TODO(coproc): hangs - wait never completes after bidirectional coproc I/O + test_files: + - path: "test.sh" + contents: | + coproc { + while read -r line; do + echo "processed: $line" + done + } + rfd=${COPROC[0]} + wfd=${COPROC[1]} + echo "msg1" >&$wfd + echo "msg2" >&$wfd + exec {wfd}>&- + { + read -r out1 + read -r out2 + } <&$rfd + echo "$out1" + echo "$out2" + wait + args: ["./test.sh"] + + - name: "Named coproc bidirectional" + skip: true # TODO(coproc): hangs - wait never completes after bidirectional coproc I/O + test_files: + - path: "test.sh" + contents: | + coproc BIDIR { + while read -r line; do + echo "echo: $line" + done + } + rfd=${BIDIR[0]} + wfd=${BIDIR[1]} + echo "test1" >&$wfd + echo "test2" >&$wfd + exec {wfd}>&- + { + read -r out1 + read -r out2 + } <&$rfd + echo "$out1" + echo "$out2" + wait + args: ["./test.sh"] + + - name: "Close write FD to signal EOF" + skip: true # TODO(coproc): hangs - wait never completes after bidirectional coproc I/O + test_files: + - path: "test.sh" + contents: | + coproc { + count=0 + while read -r line; do + count=$((count + 1)) + done + echo "read $count lines" + } + rfd=${COPROC[0]} + wfd=${COPROC[1]} + echo "a" >&$wfd + echo "b" >&$wfd + echo "c" >&$wfd + exec {wfd}>&- + read -r result <&$rfd + echo "$result" + wait + args: ["./test.sh"] + + - name: "Bidirectional with cat for EOF" + skip: true # TODO(coproc): hangs - stdout pipe not fully closed after coproc exits + test_files: + - path: "test.sh" + contents: | + coproc { + while read -r line; do + echo "processed: $line" + done + } + rfd=${COPROC[0]} + wfd=${COPROC[1]} + echo "msg1" >&$wfd + echo "msg2" >&$wfd + exec {wfd}>&- + cat <&$rfd + wait + echo "DONE" + args: ["./test.sh"] + + # + # Coproc with compound command bodies + # + - name: "Coproc running a for loop" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc { + read -r _ < _sync + for i in 1 2 3; do + echo "iteration $i" + done + } + exec 9<&${COPROC[0]} + echo x > _sync + cat <&9 + wait + args: ["./test.sh"] + + - name: "Coproc running a while loop" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc { + read -r _ < _sync + n=1 + while [ $n -le 3 ]; do + echo "count $n" + n=$((n + 1)) + done + } + exec 9<&${COPROC[0]} + echo x > _sync + cat <&9 + wait + args: ["./test.sh"] + + - name: "Coproc with conditionals" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc { + read -r _ < _sync + if true; then + echo "true branch" + else + echo "false branch" + fi + } + exec 9<&${COPROC[0]} + echo x > _sync + cat <&9 + wait + args: ["./test.sh"] + + - name: "Coproc with case statement" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc { + read -r _ < _sync + val="hello" + case $val in + hello) echo "matched hello";; + *) echo "no match";; + esac + } + exec 9<&${COPROC[0]} + echo x > _sync + cat <&9 + wait + args: ["./test.sh"] + + - name: "Coproc body with pipeline" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc { + read -r _ < _sync + echo -e "banana\napple\ncherry" | sort + } + exec 9<&${COPROC[0]} + echo x > _sync + cat <&9 + wait + args: ["./test.sh"] + + - name: "Coproc body with command substitution" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc { + read -r _ < _sync + result=$(echo "hello" | tr a-z A-Z) + echo "$result" + } + exec 9<&${COPROC[0]} + echo x > _sync + cat <&9 + wait + args: ["./test.sh"] + + - name: "Coproc with arithmetic operations" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc { + read -r _ < _sync + a=5 + b=3 + echo $((a + b)) + echo $((a * b)) + } + exec 9<&${COPROC[0]} + echo x > _sync + cat <&9 + wait + args: ["./test.sh"] + + # + # Exit status and lifecycle + # + - name: "Coproc statement returns 0" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc { read -r _ < _sync; echo done; } + status=$? + exec 9<&${COPROC[0]} + echo x > _sync + cat <&9 + wait + echo "exit status: $status" + args: ["./test.sh"] + + - name: "Coproc with failing body returns 0 immediately" + test_files: + - path: "test.sh" + contents: | + coproc { exit 42; } + echo "coproc start status: $?" + wait + args: ["./test.sh"] + + - name: "Wait with no args waits for coproc" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc { read -r _ < _sync; echo "coproc done"; } + exec 9<&${COPROC[0]} + echo x > _sync + cat <&9 + wait + echo "after wait" + args: ["./test.sh"] + + # + # Error cases + # + - name: "Invalid coproc name" + ignore_stderr: true + test_files: + - path: "test.sh" + contents: | + coproc 123bad { echo x; } + echo "exit: $?" + args: ["./test.sh"] + + # + # Coproc in different contexts + # + - name: "Coproc inside a function" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + start_coproc() { + coproc { read -r _ < _sync; echo "from function coproc"; } + } + start_coproc + exec 9<&${COPROC[0]} + echo x > _sync + cat <&9 + wait + args: ["./test.sh"] + + - name: "Coproc inside a conditional" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + if true; then + coproc { read -r _ < _sync; echo "conditional coproc"; } + fi + exec 9<&${COPROC[0]} + echo x > _sync + cat <&9 + wait + args: ["./test.sh"] + + - name: "Coproc inside a loop body" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + for i in 1; do + coproc { read -r _ < _sync; echo "loop coproc $i"; } + done + exec 9<&${COPROC[0]} + echo x > _sync + cat <&9 + wait + args: ["./test.sh"] + + - name: "Named coproc inside a function" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + f() { + coproc FUNCPROC { read -r _ < _sync; echo "named in func"; } + } + f + exec 9<&${FUNCPROC[0]} + echo x > _sync + cat <&9 + wait + args: ["./test.sh"] + + # + # Multiple coprocs and variable lifecycle + # + - name: "Second unnamed coproc replaces first" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc { read -r _ < _sync; echo "first"; } + exec 9<&${COPROC[0]} + echo x > _sync + cat <&9 + exec 9<&- + wait + coproc { read -r _ < _sync; echo "second"; } + exec 9<&${COPROC[0]} + echo x > _sync + cat <&9 + wait + args: ["./test.sh"] + + - name: "Sequential named coprocs with different names" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc PROC1 { read -r _ < _sync; echo "proc1"; } + exec 9<&${PROC1[0]} + echo x > _sync + cat <&9 + exec 9<&- + wait + coproc PROC2 { read -r _ < _sync; echo "proc2"; } + exec 9<&${PROC2[0]} + echo x > _sync + cat <&9 + wait + args: ["./test.sh"] + + # + # Environment and variable scope + # + - name: "Coproc inherits parent environment" + test_files: + - path: "test.sh" + contents: | + export MY_VAR="inherited" + mkfifo _sync + coproc { read -r _ < _sync; echo "MY_VAR=$MY_VAR"; } + exec 9<&${COPROC[0]} + echo x > _sync + cat <&9 + wait + args: ["./test.sh"] + + - name: "Coproc inherits non-exported variables" + test_files: + - path: "test.sh" + contents: | + LOCAL_VAR="visible" + mkfifo _sync + coproc { read -r _ < _sync; echo "LOCAL_VAR=$LOCAL_VAR"; } + exec 9<&${COPROC[0]} + echo x > _sync + cat <&9 + wait + args: ["./test.sh"] + + - name: "Coproc cannot modify parent variables" + test_files: + - path: "test.sh" + contents: | + PARENT_VAR="original" + mkfifo _sync + coproc { read -r _ < _sync; PARENT_VAR="modified"; echo "in coproc: $PARENT_VAR"; } + exec 9<&${COPROC[0]} + echo x > _sync + cat <&9 + wait + echo "in parent: $PARENT_VAR" + args: ["./test.sh"] + + - name: "Coproc inherits functions" + test_files: + - path: "test.sh" + contents: | + greet() { echo "hello $1"; } + export -f greet + mkfifo _sync + coproc { read -r _ < _sync; greet "world"; } + exec 9<&${COPROC[0]} + echo x > _sync + cat <&9 + wait + args: ["./test.sh"] + + - name: "Coproc inherits aliases" + test_files: + - path: "test.sh" + contents: | + shopt -s expand_aliases + alias myecho='echo ALIASED:' + mkfifo _sync + coproc { read -r _ < _sync; myecho "test"; } + exec 9<&${COPROC[0]} + echo x > _sync + cat <&9 + wait + args: ["./test.sh"] + + # + # Coproc with redirections in body + # + - name: "Coproc body with stderr redirection" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc { + read -r _ < _sync + echo "stdout msg" + echo "stderr msg" >&2 + } + exec 9<&${COPROC[0]} + echo x > _sync + cat <&9 + wait + args: ["./test.sh"] + + - name: "Coproc body writing to a file" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc { + read -r _ < _sync + echo "file content" > /tmp/coproc_test_$$ + echo "wrote file" + } + exec 9<&${COPROC[0]} + echo x > _sync + cat <&9 + wait + cat /tmp/coproc_test_$$ + rm -f /tmp/coproc_test_$$ + args: ["./test.sh"] + + # + # Coproc with various data patterns + # + - name: "Coproc producing empty output" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc { read -r _ < _sync; true; } + exec 9<&${COPROC[0]} + echo x > _sync + cat <&9 + wait + echo "after empty coproc" + args: ["./test.sh"] + + - name: "Coproc producing multiple lines of output" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc { + read -r _ < _sync + for i in 1 2 3 4 5 6 7 8 9 10; do + echo "line $i" + done + } + exec 9<&${COPROC[0]} + echo x > _sync + count=0 + while read -r line; do + count=$((count + 1)) + done <&9 + echo "read $count lines" + wait + args: ["./test.sh"] + + - name: "Coproc with multiline string output" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc { + read -r _ < _sync + cat <<'HEREDOC' + first line + second line + third line + HEREDOC + } + exec 9<&${COPROC[0]} + echo x > _sync + cat <&9 + wait + args: ["./test.sh"] + + # + # Coproc output transformation + # + - name: "Transform coproc output to uppercase" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc { + read -r _ < _sync + echo "hello" + echo "world" + } + exec 9<&${COPROC[0]} + echo x > _sync + while read -r line; do + echo "$line" | tr a-z A-Z + done <&9 + wait + args: ["./test.sh"] + + - name: "Coproc output filtered with case" + test_files: + - path: "test.sh" + contents: | + mkfifo _sync + coproc { + read -r _ < _sync + echo "apple" + echo "banana" + echo "avocado" + echo "cherry" + } + exec 9<&${COPROC[0]} + echo x > _sync + while read -r line; do + case $line in + a*) echo "starts with a: $line";; + esac + done <&9 + wait + args: ["./test.sh"] + + # + # Tests requiring wait with job specs (known failures) + # + - name: "Wait for specific coproc PID" + known_failure: true # TODO(wait): wait with job specs not implemented + test_files: + - path: "test.sh" + contents: | + coproc { echo "done"; } + fd=${COPROC[0]} + cat <&$fd + wait $COPROC_PID + echo "wait status: $?" + args: ["./test.sh"] + + - name: "Coproc with failing body - wait captures exit status" + known_failure: true # TODO(wait): wait with job specs not implemented + test_files: + - path: "test.sh" + contents: | + coproc { exit 42; } + wait $COPROC_PID + echo "exit status: $?" + args: ["./test.sh"] + + - name: "Wait for named coproc PID" + known_failure: true # TODO(wait): wait with job specs not implemented + test_files: + - path: "test.sh" + contents: | + coproc MYPROC { echo "done"; } + fd=${MYPROC[0]} + cat <&$fd + wait $MYPROC_PID + echo "status: $?" + args: ["./test.sh"] diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/compound_cmds/for.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/compound_cmds/for.yaml new file mode 100644 index 0000000000..0eedf9321d --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/compound_cmds/for.yaml @@ -0,0 +1,195 @@ +name: "Compound commands: for" +cases: + - name: "Single-line for loop" + stdin: | + for f in 1 2 3; do echo $f; done + + - name: "Multi-line for loop" + stdin: | + for f in 1 2 3; do + echo $f + done + + - name: "Empty for loop" + stdin: | + for f in; do echo $f; done + + - name: "for loop without in" + stdin: | + set -- a b c + + echo "Loop 1" + for f; do echo $f; done + + echo "Loop 2: no semicolon" + for f do echo $f; done + + - name: "for loop without in but spaces" + stdin: | + set -- a "b c" d + + echo "Loop 1" + for f; do echo $f; done + + echo "Loop 2: no semicolon" + for f do echo $f; done + + - name: "Break in for loop" + stdin: | + for f in 1 2 3; do + echo $f + break + done + + - name: "Break in nested for loop" + stdin: | + for f in 1 2 3; do + for g in a b c; do + echo "f=$f g=$g" + true && break + done + echo "Left inner loop" + done + + - name: "Break 1 in nested for loops" + stdin: | + for f in 1 2 3; do + for g in a b c; do + echo "f=$f g=$g" + break 1 + done + done + + - name: "Break 2 in nested for loops" + stdin: | + for f in 1 2 3; do + for g in a b c; do + echo "f=$f g=$g" + break 2 + done + done + + - name: "Break out of nested for/while loops" + stdin: | + for f in 1 2 3; do + while true; do + echo "f=$f" + break 2 + done + done + + - name: "Continue in for loop" + stdin: | + for f in 1 2 3; do + echo $f + continue + echo $f + done + + - name: "Continue with bad N" + known_failure: true # Fails differently + stdin: | + for f in 1 2 3; do + echo $f + continue 0 + echo result: $? + done + + - name: "Continue in nested for loop" + stdin: | + for f in a b c; do + for g in 1 2 3; do + echo "f=$f g=$g" + continue + echo "Should not print" + done + echo "Left inner loop" + done + + - name: "Continue in nested for loop with explicit N=1" + stdin: | + for f in a b c; do + for g in 1 2 3; do + echo "f=$f g=$g" + continue 1 + echo "Should not print" + done + echo "Left inner loop" + done + + - name: "Continue in nested for loop with explicit N=2" + stdin: | + for f in a b c; do + for g in 1 2 3; do + echo "f=$f g=$g" + continue 2 + echo "Should not print" + done + echo "Left inner loop" + done + + - name: "Continue in nested for loop with too large N" + known_failure: true + stdin: | + for f in a b c; do + for g in 1 2 3; do + echo "f=$f g=$g" + continue 3 + echo "Should not print" + done + echo "Left inner loop" + done + + - name: "Multi-line for loop" + test_files: + - path: "script.sh" + contents: | + for f in 1 2 3; do + echo $f + done + args: ["./script.sh"] + + - name: "For loop piped" + stdin: | + for f in ab ac bd ef; do echo $f; done | grep b + + - name: "For loop output redirection" + stdin: | + for f in a b c; do echo $f; done > ./out.txt + + - name: "For reserved word handling" + stdin: | + for for in for; do echo $for; done + + - name: "For with return in body" + stdin: | + f() { + for i in 1 2 3; do + echo "Iteration $i" + if [ "$i" = "2" ]; then + return 42 + fi + done + echo "After for" + } + f + echo "Exit code: $?" + + - name: "For with exit in body" + stdin: | + for i in 1 2 3; do + echo "Iteration $i" + if [ "$i" = "2" ]; then + exit 42 + fi + done + echo "After for" + + - name: "For loop with nameref variable" + stdin: | + target="" + declare -n ref=target + for ref in a b c; do + echo "loop: target=$target" + done + echo "final: target=$target" diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/compound_cmds/if.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/compound_cmds/if.yaml new file mode 100644 index 0000000000..3fb74474d0 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/compound_cmds/if.yaml @@ -0,0 +1,99 @@ +name: "Compound commands: if" +cases: + - name: "Basic if" + stdin: | + if false; then echo 1; else echo 2; fi + if false; then echo 3; elif false; then echo 4; else echo 5; fi + if true; then echo 6; else echo 7; fi + if false; then echo 8; elif true; then echo 10; else echo 11; fi + + - name: "Multi-line if" + test_files: + - path: "script.sh" + contents: | + if false; then + echo 1 + else + echo 2 + fi + + if false; then + echo 3 + elif false; then + echo 4 + else + echo 5 + fi + + if true; then + echo 6 + else + echo 7 + fi + + if false; then + echo 8 + elif true; then + echo 10 + else + echo 11 + fi + args: ["./script.sh"] + + - name: "If with return in condition" + stdin: | + f() { + if return 42; then + echo "Then branch" + else + echo "Else branch" + fi + echo "After if" + } + f + echo "Exit code: $?" + + - name: "If with exit in condition" + stdin: | + if exit 42; then + echo "Then branch" + else + echo "Else branch" + fi + echo "After if" + + - name: "If with return in elif condition" + stdin: | + f() { + if false; then + echo "Then branch" + elif return 42; then + echo "Elif then branch" + else + echo "Else branch" + fi + echo "After if" + } + f + echo "Exit code: $?" + + - name: "If with break in condition inside loop" + stdin: | + for i in 1 2 3; do + if break; then + echo "Then branch" + fi + echo "After if" + done + echo "After loop" + + - name: "If with continue in condition inside loop" + stdin: | + for i in 1 2 3; do + echo "Iteration $i" + if continue; then + echo "Then branch" + fi + echo "After if" + done + echo "After loop" diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/compound_cmds/select.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/compound_cmds/select.yaml new file mode 100644 index 0000000000..bab2e4cf8a --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/compound_cmds/select.yaml @@ -0,0 +1,10 @@ +name: "Select statement" +cases: + - name: "basic" + known_failure: true # TODO(select): select statement not implemented + stdin: | + echo "1" | select opt in a b c; do + echo "selected: $opt" + break + done + echo "after select" diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/compound_cmds/subshell.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/compound_cmds/subshell.yaml new file mode 100644 index 0000000000..d51fdc93d8 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/compound_cmds/subshell.yaml @@ -0,0 +1,38 @@ +name: "Compound commands: subshell" +cases: + - name: "Basic subshell usage" + stdin: | + (echo hi) + + - name: "Subshells in sequence" + ignore_stderr: true + stdin: | + (echo hi)(echo there) + + - name: "Subshell env usage" + stdin: | + (subshell_var=value && echo "subshell_var: ${subshell_var}") + echo "subshell_var: ${subshell_var}" + + - name: "Subshell output redirection" + stdin: | + (echo Hello; echo world) >out.txt + echo "Dumping out.txt..." + cat out.txt + + - name: "Piped subshell usage" + stdin: | + (echo hi) | wc -l + + - name: "Breaks in subshell" + stdin: | + for i in 1 2 3; do + echo $i + (for i in 1 2 3; do break 2; done) + done + + - name: "Exec in subshell" + stdin: | + echo "Before subshell" + (exec cat <<<"Hello from exec in subshell") + echo "After subshell" diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/compound_cmds/until.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/compound_cmds/until.yaml new file mode 100644 index 0000000000..222b4ad5c1 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/compound_cmds/until.yaml @@ -0,0 +1,101 @@ +name: "Compound commands: until" +cases: + - name: "Single-line until loop" + stdin: | + until true; do echo 'In loop'; done + + - name: "Arithmetic in until loop" + incompatible_configs: ["sh"] + stdin: | + i=5 + until ((i == 0)); do echo $i; i=$((i - 1)); done + + - name: "Until with return in condition" + stdin: | + f() { + until return 42; do + echo "Body" + done + echo "After until" + } + f + echo "Exit code: $?" + + - name: "Until with exit in condition" + stdin: | + until exit 42; do + echo "Body" + done + echo "After until" + + - name: "Until with break in condition inside outer loop" + stdin: | + for i in 1 2 3; do + echo "Outer iteration $i" + until break; do + echo "Until body" + done + echo "After until" + done + echo "After outer loop" + + - name: "Until with break 2 in condition inside outer loop" + stdin: | + for i in 1 2 3; do + echo "Outer iteration $i" + until break 2; do + echo "Until body" + done + echo "After until" + done + echo "After outer loop" + + - name: "Until with continue 2 in condition inside outer loop" + stdin: | + for i in 1 2 3; do + echo "Outer iteration $i" + until continue 2; do + echo "Until body" + done + echo "After until" + done + echo "After outer loop" + + - name: "Until with continue in condition inside outer loop" + stdin: | + for i in 1 2 3; do + echo "Outer iteration $i" + until continue; do + echo "Until body" + done + echo "After until" + done + echo "After outer loop" + + - name: "Until with return in body" + stdin: | + f() { + i=0 + until [ $i -ge 3 ]; do + echo "Iteration $i" + if [ $i -eq 1 ]; then + return 42 + fi + i=$((i + 1)) + done + echo "After until" + } + f + echo "Exit code: $?" + + - name: "Until with exit in body" + stdin: | + i=0 + until [ $i -ge 3 ]; do + echo "Iteration $i" + if [ $i -eq 1 ]; then + exit 42 + fi + i=$((i + 1)) + done + echo "After until" diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/compound_cmds/while.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/compound_cmds/while.yaml new file mode 100644 index 0000000000..d7ec69fea6 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/compound_cmds/while.yaml @@ -0,0 +1,127 @@ +name: "Compound commands: while" +cases: + - name: "Single-line while loop" + stdin: | + while false; do echo 'In loop'; done + + - name: "break in while loop" + stdin: | + while true; do + echo 'In loop' + break + done + + - name: "break 2 in nested loops" + stdin: | + while false; do + echo 'Starting inner loop' + while true; do + echo 'In loop' + break 2 + done + echo 'Finished inner loop' + done + + - name: "Arithmetic in while loop" + stdin: | + i=5 + while ((i > 0)); do echo $i; i=$((i - 1)); done + + - name: "Alternative arithmetic in while loop" + stdin: | + c=0 + limit=4 + while [ $c -lt $limit ]; do + case "$c" in + 0) + echo "0" + ;; + 1) + echo "1" + ;; + *) + break + ;; + esac + ((c++)) + done + echo "Done" + + - name: "While with return in condition" + stdin: | + f() { + while return 42; do + echo "Body" + done + echo "After while" + } + f + echo "Exit code: $?" + + - name: "While with exit in condition" + stdin: | + while exit 42; do + echo "Body" + done + echo "After while" + + - name: "While with break in condition inside outer loop" + stdin: | + for i in 1 2 3; do + echo "Outer iteration $i" + while break; do + echo "While body" + done + echo "After while" + done + echo "After outer loop" + + - name: "While with break 2 in condition inside outer loop" + stdin: | + for i in 1 2 3; do + echo "Outer iteration $i" + while break 2; do + echo "While body" + done + echo "After while" + done + echo "After outer loop" + + - name: "While with continue 2 in condition inside outer loop" + stdin: | + for i in 1 2 3; do + echo "Outer iteration $i" + while continue 2; do + echo "While body" + done + echo "After while" + done + echo "After outer loop" + + - name: "While with return in body" + stdin: | + f() { + i=0 + while [ $i -lt 3 ]; do + echo "Iteration $i" + if [ $i -eq 1 ]; then + return 42 + fi + i=$((i + 1)) + done + echo "After while" + } + f + echo "Exit code: $?" + + - name: "While with exit in body" + stdin: | + i=0 + while [ $i -lt 3 ]; do + echo "Iteration $i" + if [ $i -eq 1 ]; then + exit 42 + fi + i=$((i + 1)) + done + echo "After while" diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/errors.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/errors.yaml new file mode 100644 index 0000000000..5d0a148a06 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/errors.yaml @@ -0,0 +1,399 @@ +name: "Error handling" +cases: + - name: "Fatal error (interactive/subshell)" + ignore_stderr: true + args: ["-i"] + stdin: | + echo "before expansion" + (echo "${non_existent_var:?missing parameter}") + echo "after expansion" + + - name: "Fatal error (non-interactive/subshell)" + ignore_stderr: true + stdin: | + echo "before expansion" + (echo "${non_existent_var:?missing parameter}") + echo "after expansion" + + - name: "Fatal error (interactive/command substitution)" + ignore_stderr: true + args: ["-i"] + stdin: | + echo "before expansion" + x=$(echo "${non_existent_var:?missing parameter}") + echo "after expansion" + + - name: "Fatal error (non-interactive/command substitution)" + ignore_stderr: true + stdin: | + echo "before expansion" + x=$(echo "${non_existent_var:?missing parameter}") + echo "after expansion" + + - name: "Fatal error (interactive/sourced file)" + ignore_stderr: true + args: ["-i"] + test_files: + - path: error.sh + contents: | + echo "before expansion" + echo "${non_existent_var:?missing parameter}" + echo "after expansion" + stdin: | + source error.sh + echo "after sourcing script with error" + + - name: "Fatal error (non-interactive/sourced file)" + ignore_stderr: true + test_files: + - path: error.sh + contents: | + echo "before expansion" + echo "${non_existent_var:?missing parameter}" + echo "after expansion" + stdin: | + source error.sh + echo "after sourcing script with error" + + - name: "Fatal error (interactive/executed script)" + ignore_stderr: true + args: ["-i", "error.sh"] + test_files: + - path: error.sh + contents: | + echo "before expansion" + echo "${non_existent_var:?missing parameter}" + echo "after expansion" + + - name: "Fatal error (non-interactive/executed script)" + ignore_stderr: true + args: ["error.sh"] + test_files: + - path: error.sh + contents: | + echo "before expansion" + echo "${non_existent_var:?missing parameter}" + echo "after expansion" + + - name: "Shell language syntax error (command line)" + incompatible_os: ["azurelinux"] # Needs investigation + ignore_stderr: true + args: + - "-c" + - | + [[ -% 3 ]] + echo "after syntax error" + + - name: "Shell language syntax error (non-interactive)" + incompatible_os: ["azurelinux"] # Needs investigation + ignore_stderr: true + stdin: | + [[ -% 3 ]] + echo "after syntax error" + + - name: "Shell language syntax error (interactive)" + args: ["-i"] + ignore_stderr: true + stdin: | + [[ -% 3 ]] + echo "after syntax error" + + - name: "Special built-in error (command line)" + ignore_stderr: true + args: + - "-c" + - | + echo "before shift" + set -- foo + shift 2 + echo "after shift" + + - name: "Special built-in error (non-interactive)" + ignore_stderr: true + stdin: | + echo "before shift" + set -- foo + shift 2 + echo "after shift" + + - name: "Special built-in error (interactive)" + args: ["-i"] + ignore_stderr: true + stdin: | + echo "before shift" + set -- foo + shift 2 + echo "after shift" + + - name: "Regular utility error (command line)" + ignore_stderr: true + args: + - "-c" + - | + echo "before cat" + cat /no/such/utility/path 2>/dev/null + echo "after cat (status $?)" + + - name: "Regular utility error (non-interactive)" + ignore_stderr: true + stdin: | + echo "before cat" + cat /no/such/utility/path 2>/dev/null + echo "after cat (status $?)" + + - name: "Regular utility error (interactive)" + args: ["-i"] + ignore_stderr: true + stdin: | + echo "before cat" + cat /no/such/utility/path 2>/dev/null + echo "after cat (status $?)" + + - name: "Special built-in redirection error (command line)" + ignore_stderr: true + args: + - "-c" + - | + echo "before special redirection" + : > missing_special_dir/out.txt + echo "after special redirection" + + - name: "Special built-in redirection error (non-interactive)" + ignore_stderr: true + stdin: | + echo "before special redirection" + : > missing_special_dir/out.txt + echo "after special redirection" + + - name: "Special built-in redirection error (interactive)" + args: ["-i"] + ignore_stderr: true + stdin: | + echo "before special redirection" + : > missing_special_dir/out.txt + echo "after special redirection" + + - name: "Compound command redirection error (command line)" + ignore_stderr: true + args: + - "-c" + - | + echo "before compound redirection" + if true; then echo inside; fi > missing_compound_dir/out.txt + echo "after compound redirection" + + - name: "Compound command redirection error (non-interactive)" + ignore_stderr: true + stdin: | + echo "before compound redirection" + if true; then echo inside; fi > missing_compound_dir/out.txt + echo "after compound redirection" + + - name: "Compound command redirection error (interactive)" + args: ["-i"] + ignore_stderr: true + stdin: | + echo "before compound redirection" + if true; then echo inside; fi > missing_compound_dir/out.txt + echo "after compound redirection" + + - name: "Function redirection error (command line)" + ignore_stderr: true + args: + - "-c" + - | + demo_func() { echo inside; } + echo "before function redirection" + demo_func > missing_function_dir/out.txt + echo "after function redirection" + + - name: "Function redirection error (non-interactive)" + ignore_stderr: true + stdin: | + demo_func() { echo inside; } + echo "before function redirection" + demo_func > missing_function_dir/out.txt + echo "after function redirection" + + - name: "Function redirection error (interactive)" + args: ["-i"] + ignore_stderr: true + stdin: | + demo_func() { echo inside; } + echo "before function redirection" + demo_func > missing_function_dir/out.txt + echo "after function redirection" + + - name: "Utility redirection error (command line)" + ignore_stderr: true + args: + - "-c" + - | + echo "before cat redirection" + cat < missing_utility_dir/input.txt + echo "after cat redirection" + + - name: "Utility redirection error (non-interactive)" + ignore_stderr: true + stdin: | + echo "before cat redirection" + cat < missing_utility_dir/input.txt + echo "after cat redirection" + + - name: "Utility redirection error (interactive)" + args: ["-i"] + ignore_stderr: true + stdin: | + echo "before cat redirection" + cat < missing_utility_dir/input.txt + echo "after cat redirection" + + - name: "Variable assignment error (command line)" + ignore_stderr: true + args: + - "-c" + - | + readonly value_guard=1 + echo "before readonly assignment" + value_guard=2 + echo "after readonly assignment" + + - name: "Variable assignment error (non-interactive)" + ignore_stderr: true + stdin: | + readonly value_guard=1 + echo "before readonly assignment" + value_guard=2 + echo "after readonly assignment" + + - name: "Variable assignment error (interactive)" + args: ["-i"] + ignore_stderr: true + stdin: | + readonly value_guard=1 + echo "before readonly assignment" + value_guard=2 + echo "after readonly assignment" + + - name: "Expansion error (command line)" + known_failure: true # Needs investigation + ignore_stderr: true + args: + - "-c" + - | + echo "before expansion" + echo "${non_existent_var:?missing parameter}" + echo "after expansion" + + - name: "Expansion error (non-interactive)" + ignore_stderr: true + stdin: | + echo "before expansion" + echo "${UNSET_REQUIRED:?missing parameter}" + echo "after expansion" + + - name: "Expansion error (interactive)" + args: ["-i"] + ignore_stderr: true + stdin: | + echo "before expansion" + echo "${UNSET_REQUIRED:?missing parameter}" + echo "after expansion" + + - name: "Command not found (command line)" + ignore_stderr: true + args: + - "-c" + - | + missing_command + echo "after missing command (status $?)" + + - name: "Command not found (non-interactive)" + ignore_stderr: true + stdin: | + missing_command + echo "after missing command (status $?)" + + - name: "Command not found (interactive)" + args: ["-i"] + ignore_stderr: true + stdin: | + missing_command + echo "after missing command (status $?)" + + - name: "Unrecoverable read error (command line)" + ignore_stderr: true + args: + - "-c" + - | + echo "before closing stdin" + exec 0<&- + echo "after closing stdin" + + - name: "Unrecoverable read error (non-interactive)" + known_failure: true + ignore_stderr: true + stdin: | + echo "before closing stdin" + exec 0<&- + echo "after closing stdin" + + - name: "Unrecoverable read error (interactive)" + known_failure: true + args: ["-i"] + ignore_stderr: true + stdin: | + echo "before closing stdin" + exec 0<&- + echo "after closing stdin" + + - name: "Failing special built-in (non-POSIX mode, non-interactive)" + ignore_stderr: true + stdin: | + . ./non-existent-file + echo "after builtin call (status $?)" + + - name: "Failing special built-in (non-POSIX mode, interactive)" + args: ["-i"] + ignore_stderr: true + stdin: | + . ./non-existent-file + echo "after builtin call (status $?)" + + - name: "Failing special built-in (POSIX mode, non-interactive)" + ignore_stderr: true + stdin: | + set -o posix + . ./non-existent-file + echo "after builtin call (status $?)" + + - name: "Failing special built-in (POSIX mode, interactive)" + args: ["-i"] + ignore_stderr: true + stdin: | + set -o posix + . ./non-existent-file + echo "after builtin call (status $?)" + + - name: "errexit with failing command (non-interactive)" + stdin: | + set -e + false + echo "after false" + + - name: "errexit with failing command (interactive)" + args: ["-i"] + ignore_stderr: true + stdin: | + set -e + echo "before false" + false + echo "after false" + + - name: "errexit with unbound variable access (interactive)" + args: ["-i", "-e", "-u", "-o", "pipefail"] + ignore_stderr: true + stdin: | + echo "before false" + false + echo "after false" diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/extended_tests.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/extended_tests.yaml new file mode 100644 index 0000000000..6bdb0ad4c4 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/extended_tests.yaml @@ -0,0 +1,548 @@ +name: "Extended tests" +cases: + - name: "Extended test with redirect" + stdin: | + [[ $(echo hello >&2) ]] 2>/dev/null + echo "Result: $?" + + - name: "File extended tests" + stdin: | + [[ -a /tmp ]] && echo "-a correctly checked /tmp" + [[ -a /some/non/existent/path ]] || echo "-a correctly checked non-existent path" + + [[ -c /dev/null ]] && echo "-c correctly checked /dev/null" + [[ -c /tmp ]] || echo "-c correctly checked /tmp" + + - name: "Existence tests" + stdin: | + [[ -e '' ]] || echo "-e correctly identified empty string as non-existent" + + [[ -e non-existent ]] || echo "-e correctly identified non-existent path" + [[ ! -e non-existent ]] && echo "! -e correctly identified non-existent path" + + touch test-file + [[ -e test-file ]] && echo "-e correctly identified existing non-dir" + + mkdir test-dir + [[ -e test-dir ]] && echo "-d correctly identified existing dir" + + - name: "Directory tests" + stdin: | + [[ -d '' ]] || echo "-d correctly identified empty string as non-existent" + + [[ -d non-existent ]] || echo "-d correctly identified non-existent path" + [[ ! -d non-existent ]] && echo "! -d correctly identified non-existent path" + + touch test-file + [[ -d test-file ]] || echo "-d correctly identified non-dir file" + + mkdir test-dir + [[ -d test-dir ]] && echo "-d correctly identified existing dir" + + - name: "File regular tests" + stdin: | + [[ -f '' ]] || echo "-f correctly identified empty string as non-existent" + + [[ -f non-existent ]] || echo "-f correctly identified non-existent path" + [[ ! -f non-existent ]] && echo "! -f correctly identified non-existent path" + + touch test-file + [[ -f test-file ]] && echo "-f correctly identified regular file" + + mkdir test-dir + [[ -f test-dir ]] || echo "-f correctly identified directory as non-regular file" + + - name: "File symbolic link tests" + stdin: | + [[ -L non-existent ]] || echo "-L correctly identified non-existent path" + + touch test-file + [[ -L test-file ]] || echo "-L correctly identified non-link file" + + ln -s test-file valid-link + [[ -L valid-link ]] && echo "-L correctly identified valid symbolic link" + + ln -s non-existent-target dangling-link + [[ -L dangling-link ]] && echo "-L correctly identified dangling symbolic link" + + - name: "File sticky bit tests" + stdin: | + touch test-file + [[ -k test-file ]] || echo "-k correctly identified file without sticky bit" + + chmod 1600 test-file + [[ -k test-file ]] && echo "-k correctly identified file with sticky bit" + + - name: "Fifo test" + stdin: | + touch test-file + [[ -p test-file ]] || echo "-p correctly identified non-fifo file" + + mkfifo fifo-file + [[ -p test-file ]] && echo "-p correctly identified fifo file" + + - name: "File executable tests" + stdin: | + [[ -x non-existent ]] || echo "-x correctly identified non-existent path" + + touch test-file + ln -sf link test-file + [[ -x test-file ]] || echo "-x correctly identified non-executable file" + [[ -x link ]] || echo "-x correctly identified link to non-executable file" + + chmod o+x test-file + [[ -x test-file ]] || echo "-x correctly identified other-only-executable file" + [[ -x link ]] || echo "-x correctly identified link to other-only-executable file" + + chmod a+x test-file + [[ -x test-file ]] && echo "-x correctly identified executable file" + [[ -x link ]] && echo "-x correctly identified link to executable file" + + - name: "File writable tests" + stdin: | + [[ -w non-existent ]] || echo "-w correctly identified non-existent path" + + touch test-file + ln -sf link test-file + [[ -w test-file ]] || echo "-w correctly identified non-writable file" + [[ -w link ]] || echo "-w correctly identified link to non-writable file" + + chmod o+x test-file + [[ -w test-file ]] || echo "-w correctly identified other-only-writable file" + [[ -w link ]] || echo "-w correctly identified link to other-only-writable file" + + chmod a+x test-file + [[ -w test-file ]] && echo "-w correctly identified writable file" + [[ -w link ]] && echo "-w correctly identified link to writable file" + + - name: "Variable set and nameref tests" + stdin: | + foo="bar" + declare -n bang=foo + [[ -R bang ]] && echo "-R correctly identified nameref variable that is set" + + declare -n somevar + [[ -R somevar ]] || echo "-R correctly identified nameref variable that isn't set" + + - name: "Files refer to same device and inode tests" + stdin: | + [[ /bin/sh -ef /bin/sh ]] && echo "-ef correctly identified device and inode numbers" + + [[ ! /etc/os-release -ef /bin/sh ]] && echo "-ef correctly identified device and inode numbers that do not match" + + - name: "File is newer" + stdin: | + touch -d "2 hours ago" bar + touch foo + + [[ foo -nt bar ]] && echo "-nt correctly identified newer file" + [[ foo -nt foo ]] && echo "-nt incorrectly identified file as newer than itself" + [[ foo -nt file_no_exists ]] && echo "-nt correctly identified when file2 does not exist" + + - name: "File is older" + stdin: | + touch -d "2 hours ago" foo + touch bar + + [[ foo -ot bar ]] && echo "-ot correctly identified older file" + [[ foo -ot foo ]] && echo "-ot incorrectly identified file as older than itself" + [[ file_no_exists -ot foo ]] && echo "-ot correctly identified when file1 does not exist" + + - name: "Unary string extended tests" + stdin: | + [[ -z "" ]] && echo "-z: Pass" + [[ -z "something" ]] && echo "-z: Fail" + + [[ -n "something" ]] && echo "-n: Pass" + [[ -n "" ]] && echo "-n: Fail" + + - name: "Shell option extended tests" + stdin: | + set -o emacs + [[ -o emacs ]] && echo "1: option enabled" + + set +o emacs + [[ -o emacs ]] && echo "2: option enabled" + + - name: "Binary string extended tests" + stdin: | + [[ "" == "" ]] && echo "1. Pass" + [[ "" = "" ]] && echo "2. Pass" + [[ "" != "" ]] && echo "3. Fail" + + [[ "a" != "b" ]] && echo "4. Pass" + [[ "a" = "b" ]] && echo "5. Fail" + [[ "a" == "b" ]] && echo "6. Fail" + + [[ "a" < "b" ]] && echo "7. Pass" + [[ "a" < "a" ]] && echo "8. Fail" + [[ "b" < "a" ]] && echo "9. Fail" + [[ "a" > "b" ]] && echo "10. Fail" + [[ "a" > "a" ]] && echo "11. Fail" + [[ "b" > "a" ]] && echo "12. Pass" + + - name: "Binary string matching" + stdin: | + [[ "abc" == a* ]] && echo "1. Pass" + [[ "abc" != a* ]] && echo "2. Fail" + [[ a* != "abc" ]] && echo "3. Pass" + + - name: "Binary string matching with expansion" + stdin: | + exclude="0123456789" + [[ "clue" == +([$exclude]) ]] && echo "1. Fail" + [[ "8675309" == +([$exclude]) ]] && echo "2. Pass" + + - name: "Quoted pattern binary string matching" + stdin: | + [[ "abc" == "a*" ]] && echo "1. Matches" + [[ "abc" != "a*" ]] && echo "2. Matches" + + - name: "Tilde binary string matching" + stdin: | + x='~/' + [[ $x == ~* ]] && echo "1. Matches" + + - name: "Arithmetic extended tests" + stdin: | + [[ 0 -eq 0 ]] && echo "1. Pass" + [[ 0 -ne 0 ]] && echo "2. Fail" + [[ 0 -lt 0 ]] && echo "3. Fail" + [[ 0 -le 0 ]] && echo "4. Pass" + [[ 0 -gt 0 ]] && echo "5. Fail" + [[ 0 -ge 0 ]] && echo "6. Pass" + + [[ 0 -eq 1 ]] && echo "7. Fail" + [[ 0 -ne 1 ]] && echo "8. Pass" + [[ 0 -lt 1 ]] && echo "9. Pass" + [[ 0 -le 1 ]] && echo "10. Pass" + [[ 0 -gt 1 ]] && echo "11. Fail" + [[ 0 -ge 1 ]] && echo "12. Fail" + + [[ 1 -eq 0 ]] && echo "13. Fail" + [[ 1 -ne 0 ]] && echo "14. Pass" + [[ 1 -lt 0 ]] && echo "15. Fail" + [[ 1 -le 0 ]] && echo "16. Fail" + [[ 1 -gt 0 ]] && echo "17. Pass" + [[ 1 -ge 0 ]] && echo "18. Pass" + + - name: "Regex" + stdin: | + [[ "a" =~ ^a$ ]] && echo "1. Pass" + [[ "abc" =~ a* ]] && echo "2. Pass" + [[ a =~ ^(a)$ ]] && echo "3. Pass" + [[ a =~ ^(a|b)$ ]] && echo "4. Pass" + [[ a =~ c ]] && echo "5. Pass" + + - name: "Regex with case insensitivity" + stdin: | + shopt -u nocasematch + [[ "a" =~ A ]] && echo "1. Pass" + + shopt -s nocasematch + [[ "a" =~ A ]] && echo "1. Pass" + + - name: "Regex with capture" + stdin: | + pattern='(Hello), ([a-z]+)\.' + if [[ "Hello, world." =~ ${pattern} ]]; then + echo "Match found!" + for i in "${!BASH_REMATCH[@]}"; do + echo "$i: '${BASH_REMATCH[$i]}'" + done + fi + + - name: "Regex with capture including optional matches" + stdin: | + pattern='(Hello)(,?) ([a-z]+)\.' + if [[ "Hello world." =~ ${pattern} ]]; then + echo "Match found!" + for i in "${!BASH_REMATCH[@]}"; do + echo "$i: '${BASH_REMATCH[$i]}'" + done + fi + + - name: "Regex with quoting" + stdin: | + regex="^$" + + # TODO(test): The commented out lines appear differ in behavior between versions of bash. + # [[ "" =~ '' ]] && echo "1. Matched" + [[ "" =~ '^$' ]] && echo "2. Matched" + # [[ "" =~ "" ]] && echo "3. Matched" + [[ "" =~ "^$" ]] && echo "4. Matched" + [[ "" =~ ^$ ]] && echo "5. Matched" + [[ "" =~ ${regex} ]] && echo "6. Matched" + [[ "" =~ "${regex}" ]] && echo "7. Matched" + + - name: "Regex with escaping" + stdin: | + [[ '' =~ ^\$$ ]] && echo "1. Matched" + + - name: "Regex with double parens" + stdin: | + if [[ xy =~ x+((y)) ]]; then + echo "Matches" + fi + + - name: "Regex with special chars in parens" + stdin: | + [[ "<" =~ (<) ]] && echo "1. Matched" + [[ ">" =~ (<) ]] && echo "2. Matched" + + - name: "Regex with spaces inside parens" + stdin: | + [[ "x" =~ (a| *) ]] && echo "1. Matched" + [[ "a b" =~ ^(a |b) ]] && echo "2. Matched: [${BASH_REMATCH[0]}]" + [[ "a b" =~ ^(a |b)$ ]] || echo "3. No match" + [[ " " =~ ^( )$ ]] && echo "4. Matched: [${BASH_REMATCH[1]}]" + + - name: "Regex with unescaped open bracket in character class" + stdin: | + [[ "[" =~ ^([x[]) ]] && echo "Matched" + + - name: "Empty and space checks" + stdin: | + check() { + var="$1" + [[ ${var} && ! ${var//[[:space:]]/} ]] + } + + check "" && echo "1. Only space" + check " " && echo "2. Only space" + check $'\t' && echo "3. Only space" + check " a " && echo "4. Only space" + + - name: "Newlines in test expression" + stdin: | + [[ + "a" == "a" + && + "b" == "b" + ]] && echo "Succeeded" + + - name: "Variable set checks" + stdin: | + declare set_but_no_value + [[ -v set_but_no_value ]] && echo "1. Set but no value" + + declare set_with_value=xyz + [[ -v set_with_value ]] && echo "2. Set with value" + + [[ -v not_set ]] || echo "3. Not set" + + - name: "Variables in extended tests" + stdin: | + var=10 + + [[ $var -eq 10 ]] && echo "1. Pass" + [[ var -eq 10 ]] && echo "2. Pass" + + - name: "Regex with min/max counts" + stdin: | + [[ z =~ ^z{2,6}$ ]] && echo "1. Matches" + [[ zzzz =~ ^z{2,6}$ ]] && echo "2. Matches" + [[ zzzzzzzzz =~ ^z{2,6}$ ]] && echo "3. Matches" + + - name: "Regex with newline" + stdin: | + [[ $'\n' =~ . ]] && echo "1. Matches" + + - name: "Arithmetic comparisons with whitespace in operands" + stdin: | + # Leading/trailing whitespace should be trimmed before comparison + [[ 0 -eq ' 0' ]] && echo "1. leading space" + [[ 0 -eq '0 ' ]] && echo "2. trailing space" + [[ 0 -eq ' 0 ' ]] && echo "3. both spaces" + + # Tabs + [[ 0 -eq ' 0' ]] && echo "4. leading tab" + [[ 0 -eq '0 ' ]] && echo "5. trailing tab" + + # Newlines + [[ 0 -eq ' 0 + ' ]] && echo "6. newline" + + # Other operators + [[ 5 -lt ' 10' ]] && echo "7. -lt with space" + [[ 10 -gt ' 5' ]] && echo "8. -gt with space" + [[ 5 -ne ' 6' ]] && echo "9. -ne with space" + [[ 5 -le ' 5' ]] && echo "10. -le with space" + [[ 5 -ge ' 5' ]] && echo "11. -ge with space" + + # Negative numbers with whitespace + [[ -5 -eq ' -5' ]] && echo "12. negative with space" + + - name: "Terminal fd check with whitespace" + stdin: | + # -t operator should trim whitespace before parsing fd number + # These should all behave identically (false since not a terminal) + [[ -t 0 ]] + r1=$? + [[ -t ' 0' ]] + r2=$? + [[ -t '0 ' ]] + r3=$? + + # All should have the same result + [[ $r1 -eq $r2 && $r2 -eq $r3 ]] && echo "All -t results match" + + - name: "Nameref -R with attribute removal" + stdin: | + target="value" + declare -n ref=target + [[ -R ref ]] && echo "ref is a nameref" + [[ -R target ]] && echo "target is a nameref" || echo "target is not a nameref" + declare +n ref + [[ -R ref ]] && echo "still nameref" || echo "no longer a nameref" + + - name: "Nameref -v follows nameref" + known_failure: true + stdin: | + target="value" + declare -n ref=target + [[ -v ref ]] && echo "ref is set (via target)" + unset target + [[ -v ref ]] && echo "ref still set" || echo "ref not set (target gone)" + + - name: "Nameref -v to array element treats resolved name literally" + known_failure: true + stdin: | + arr=(a b c d) + declare -n ref='arr[2]' + [[ -v ref ]] && echo "set" || echo "unset" + + - name: "Nameref -v with explicit subscript on nameref to whole array" + # TODO(nameref): brush doesn't yet handle [[ -v "ref[N]" ]] where ref is + # a nameref to a whole array. Bash resolves ref→arr and then checks arr[2], + # but brush currently treats the subscript as part of the nameref target + # string rather than applying it after resolution. The fix belongs in + # extendedtests.rs (ShellVariableIsSetAndAssigned) — the operand "ref[2]" + # needs to be split into name="ref" + subscript="2", the nameref resolved + # on the name portion, then the subscript applied to the resolved target. + known_failure: true + stdin: | + arr=(a b c d) + declare -n ref2=arr + [[ -v "ref2[2]" ]] && echo "set" || echo "unset" + + - name: "Nameref -v to nonexistent array index" + known_failure: true + stdin: | + arr=(a b c) + declare -n ref='arr[5]' + [[ -v ref ]] && echo "set" || echo "unset" + + # + # Newlines within [[ ]] conditional expressions. + # + # bash allows newlines (1) at the start of a term (after `[[`, `(`, `&&`, + # `||`, `!`) and (2) trailing after a complete binary/unary/parenthesized + # term -- but NOT after a bare-word string test. These cases pin down both + # the accepted and rejected forms. See issue #1176. + # + + - name: "Newline after ( in [[ ]]" + stdin: | + [[ ( + a = a ) ]] && echo ok + + - name: "Newline after ( before bare word in [[ ]]" + stdin: | + [[ ( + a ) ]] && echo ok + + - name: "Newline before ) after binary test in [[ ]]" + stdin: | + [[ ( a = a + ) ]] && echo ok + + - name: "Newline before ) after unary test in [[ ]]" + stdin: | + [[ ( -n a + ) ]] && echo ok + + - name: "Newline before ) after nested parens in [[ ]]" + stdin: | + [[ ( ( a = a ) + ) ]] && echo ok + + - name: "Multiple newlines before ) in [[ ]]" + stdin: | + [[ ( a = a + + + + ) ]] && echo ok + + - name: "Newline after && in [[ ]]" + stdin: | + [[ ( a = a && + b = b ) ]] && echo ok + + - name: "Newline before && after binary test in [[ ]]" + stdin: | + [[ a = a + && b = b ]] && echo ok + + - name: "Newline before || after binary test in [[ ]]" + stdin: | + [[ a = b + || b = b ]] && echo ok + + - name: "Leading newline after [[ " + stdin: | + [[ + a = a ]] && echo ok + + - name: "Newline after ! in [[ ]]" + stdin: | + [[ ! + a = b ]] && echo ok + + - name: "Newline before ]] after binary test" + stdin: | + [[ a = a + ]] && echo ok + + - name: "Newline before ]] after unary test" + stdin: | + [[ -n a + ]] && echo ok + + - name: "Newline before ]] after parenthesized expression" + stdin: | + [[ ( a = a ) + ]] && echo ok + + - name: "Newline before ]] after not of parenthesized expression" + stdin: | + [[ ! ( a = b ) + ]] && echo ok + + # Cases bash rejects as syntax errors: a newline may not follow a bare-word + # string test (whether before `]]`, `)`, or a binary/logical operator). + + - name: "Error: newline before ]] after bare word" + ignore_stderr: true + stdin: | + [[ a + ]] && echo should-not-print + + - name: "Error: newline before ) after bare word" + ignore_stderr: true + stdin: | + [[ ( a + ) ]] && echo should-not-print + + - name: "Error: newline before && after bare word" + ignore_stderr: true + stdin: | + [[ a + && b ]] && echo should-not-print + + - name: "Error: newline before ) after not of bare word" + ignore_stderr: true + stdin: | + [[ ( ! a + ) ]] && echo should-not-print diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/fd_management.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/fd_management.yaml new file mode 100644 index 0000000000..93adb3a65d --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/fd_management.yaml @@ -0,0 +1,72 @@ +name: "File descriptor management" +# Regression tests for issue #1173: nested/concurrent shell constructs must not +# exhaust the process-wide file-descriptor table. +# +# brush runs subshells, command substitutions, background jobs, and function +# call contexts as in-process tasks rather than via fork(2), so they all share a +# single process-wide descriptor table. Each context inherits the caller's open +# files; sharing those descriptors -- rather than giving each context its own +# copy -- is what keeps deeply nested or highly concurrent scripts within the +# limit. A real shell gives each forked subshell its own descriptor table and so +# stays well within the limit too. +# +# Each case lowers the descriptor limit with `ulimit -n` and runs a workload that +# stays within the limit only when inherited descriptors are shared across +# contexts; if each context copied them, the workload would exceed the limit and +# fail with "Too many open files" and truncated output. The limits leave the +# correct (sharing) behavior several-fold headroom. These cases assume Unix +# `ulimit -n` semantics. +cases: + # Concurrent fan-out: every backgrounded job is alive simultaneously (they are + # all launched before `wait`), so copying the inherited descriptors per job + # would multiply the live descriptor count by the number of jobs. + - name: "Many concurrent background jobs each using command substitution" + incompatible_platforms: ["wasi"] + args: + - "-c" + - | + ulimit -n 256 || exit 1 + emit() { + local v + v=$(echo "$1") + echo "$v" + } + ( for i in $(seq 1 60); do emit "$i" & done; wait ) | wc -l | tr -d ' ' + + # nvm-style: backgrounded jobs in a subshell, piped to a filter, each touching + # /dev/null via redirects -- the precise shape that exhausted descriptors in + # `nvm ls`. + - name: "Subshell of backgrounded jobs with redirects piped to a filter" + incompatible_platforms: ["wasi"] + args: + - "-c" + - | + ulimit -n 256 || exit 1 + work() { + local v + v=$(echo "alias-$1" 2>/dev/null) + echo "$v" 2>/dev/null + } + ( for i in $(seq 1 50); do work "$i" 2>/dev/null & done; wait ) | sort | wc -l | tr -d ' ' + + # Nested depth: each command-substitution level keeps its ancestors' contexts + # alive while it runs, so copying inherited descriptors would grow the live + # count with nesting depth. + - name: "Deeply nested command substitution with redirects stays within the fd limit" + incompatible_platforms: ["wasi"] + args: + - "-c" + - | + ulimit -n 256 || exit 1 + rec() { + local n=$1 + if [ "$n" -le 0 ]; then + printf 'x' 2>/dev/null + return + fi + local inner + inner=$(rec $((n - 1)) 2>/dev/null) + printf '%s' "$inner" 2>/dev/null + } + out=$(rec 40) + echo "${#out}" diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/functions.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/functions.yaml new file mode 100644 index 0000000000..2c9245436d --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/functions.yaml @@ -0,0 +1,177 @@ +name: "Functions" +cases: + - name: "Basic function invocation" + test_files: + - path: "script.sh" + contents: | + myfunc() { + echo "In myfunc()." + } + echo "Calling myfunc()..." + myfunc + echo "Returned." + args: ["./script.sh"] + + - name: "Function invocation with args" + test_files: + - path: "script.sh" + contents: | + myfunc() { + echo "In myfunc()" + echo "1: $1" + echo "*: $*" + } + echo "Calling myfunc()..." + myfunc a b c + echo "Returned." + args: ["./script.sh"] + + - name: "Function invocation with empty arg" + stdin: | + myfunc() { + echo "count: ${#*}" + echo "\$1: $1" + echo "\$2: $2" + echo "\$3: $3" + } + + myfunc a b c + myfunc a "" c + + - name: "Function definition with output redirection" + stdin: | + myfunc() { + echo "In myfunc()" + } >>./test.txt + + myfunc + myfunc + + - name: "Function call with env variables" + stdin: | + myfunc() { + echo ${myvar} + } + + myvar="default" + myfunc + myvar="overridden" myfunc + myfunc + + - name: "Function definition without braces" + stdin: | + myfunc() + if true; then + echo true + else + echo false + fi + + myfunc + + - name: "Nested function definition" + stdin: | + outer() { + echo "Entered outer" + + inner() { + echo "In inner" + } + + echo "Invoking inner" + + inner + + echo "Returning from outer" + } + + echo "Calling outer from toplevel" + outer + + echo "Calling inner from toplevel" + inner + + - name: "Exporting functions to child instance" + stdin: | + mytestfunc() { + echo "In mytestfunc" + } + + export -f mytestfunc + + $0 -c 'mytestfunc' + + - name: "Function names with interesting characters" + stdin: | + my/func() { + echo "In my/func" + } + + my/func + + - name: "Functions shadowing builtins" + stdin: | + .() { + echo "In custom dot function" + } + + echo "Func result: $?" + + . + echo "Call result: $?" + + - name: "Reserved words cannot be used as function names without function keyword" + ignore_stderr: true + stdin: | + for (){ :; } + echo "exit: $?" + + - name: "Reserved words can be used as function names with function keyword" + stdin: | + function for { echo "custom for"; } + for x in 1; do echo "normal for: $x"; done + + - name: "Reserved words can be used as function names with function keyword and parens" + stdin: | + function while() { echo "custom while"; } + while false; do echo "nope"; done + echo "done" + + - name: "Functions shadowing builtins (POSIX mode)" + ignore_stderr: true + stdin: | + set -o posix + + .() { + echo "In custom dot function" + } + + echo "Func result: $?" + + . + echo "Call result: $?" + + - name: "Empty string arguments preserved with nullglob" + stdin: | + shopt -s nullglob + + myfunc() { + echo "arg count: $#" + for arg in "$@"; do + echo "arg: '$arg'" + done + } + + myfunc 3 - 2 "" 1.2.3b_alpha4 + + - name: "Multiple empty string arguments" + stdin: | + myfunc() { + echo "count: $#" + echo "1: '$1'" + echo "2: '$2'" + echo "3: '$3'" + echo "4: '$4'" + } + + myfunc "" "non-empty" "" "last" diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/here.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/here.yaml new file mode 100644 index 0000000000..596c954776 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/here.yaml @@ -0,0 +1,283 @@ +name: "Here docs/strings" +cases: + - name: "Basic here doc" + stdin: | + cat <" on stderr); the stdout + # is what verifies the IFS join. + ignore_stderr: true + stdin: | + arr=(a b c) + IFS=: + unset x + : ${x:=${arr[*]}} + echo "[$x]" + + - name: "Assign-default array at join uses space" + ignore_stderr: true + stdin: | + arr=(a b c) + IFS=: + unset y + : ${y:=${arr[@]}} + echo "[$y]" + + - name: "Use-default array star join honors IFS" + stdin: | + arr=(a b c) + IFS=: + unset z + echo "[${z:-${arr[*]}}]" + + - name: "Assign-default positional star join honors IFS" + ignore_stderr: true + stdin: | + set -- a b c + IFS=: + unset p + : ${p:=$*} + echo "[$p]" diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/interactive.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/interactive.yaml new file mode 100644 index 0000000000..f903566607 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/interactive.yaml @@ -0,0 +1,48 @@ +name: "Interactive" +incompatible_configs: ["sh"] +cases: + - name: "Basic interactive test" + pty: true + ignore_stdout: true # Needs investigation + ignore_whitespace: true # There's an extra newline for now in test output + stdin: | + #expect-prompt + echo hi + #send:Enter + #expect:hi + #expect-prompt + #send:Ctrl+D + + - name: "Simple tab completion" + skip: true # TODO(input): Need to find the right way to enable this with a rich input backend. + pty: true + ignore_stdout: true + test_files: + - path: "test-file.txt" + contents: | + Hello, world. + stdin: | + #expect-prompt + cat test-file. + #send:Tab + #send:Enter + #expect:Hello, world. + #expect-prompt + #send:Ctrl+D + + - name: "PROMPT_COMMAND" + ignore_stderr: true + args: ["-i"] + stdin: | + declare -a calls=() + command=0 + + PROMPT_COMMAND='calls+=("$command")' + + command=first + command=second + command=third + + unset PROMPT_COMMAND + + declare -p calls diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/list.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/list.yaml new file mode 100644 index 0000000000..734f7e362b --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/list.yaml @@ -0,0 +1,29 @@ +name: "List" +cases: + - name: "Ignore single quote in comment in list" + stdin: | + ( + # I'm + echo "Batman" + ) + + - name: "Ignore double quote in comment in list" + stdin: | + ( + # This " is not being + echo "parsed" + ) + + - name: "Ignore parentheses in comment in list" + stdin: | + ( + # :( + echo "Sad" + ) + + - name: "Ignore dollar in comment in list" + stdin: | + ( + # $ + echo "Mr. Crabs ^" + ) diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/nameref.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/nameref.yaml new file mode 100644 index 0000000000..9ce99e9c7d --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/nameref.yaml @@ -0,0 +1,2288 @@ +name: "Nameref variables" +cases: + - name: "Nameref basic read" + known_failure: true + stdin: | + target="hello world" + declare -n ref=target + echo "ref: $ref" + echo "target: $target" + + - name: "Nameref write through reference" + known_failure: true + stdin: | + target="original" + declare -n ref=target + echo "before: $target" + ref="modified" + echo "after: $target" + echo "ref: $ref" + + - name: "Nameref append with +=" + known_failure: true + stdin: | + target="hello" + declare -n ref=target + ref+=" world" + echo "target: $target" + echo "ref: $ref" + + - name: "Nameref to unset variable" + known_failure: true + stdin: | + declare -n ref=nonexistent + echo "ref: '${ref}'" + echo "ref set: '${ref+SET}'" + ref="now exists" + echo "ref: $ref" + echo "nonexistent: $nonexistent" + + - name: "Nameref reassignment - change target" + known_failure: true + stdin: | + var1="first" + var2="second" + declare -n ref=var1 + echo "ref -> var1: $ref" + ref=var2 + echo "var1 after: $var1" + # To change what a nameref points to, use declare -n again + declare -n ref=var2 + echo "ref -> var2: $ref" + + - name: "Nameref chained (A -> B -> C)" + known_failure: true + stdin: | + ultimate="final_value" + declare -n middle=ultimate + declare -n top=middle + echo "top: $top" + echo "middle: $middle" + top="changed" + echo "ultimate: $ultimate" + + - name: "Nameref circular reference" + known_failure: true + ignore_stderr: true + stdin: | + declare -n a=b + declare -n b=a + echo "a: '${a}'" + echo "done" + + - name: "Nameref self-reference" + known_failure: true + ignore_stderr: true + stdin: | + declare -n self=self + echo "self: '${self}'" + echo "done" + + - name: "Nameref in function - pass by reference" + known_failure: true + stdin: | + modify_var() { + local -n ref=$1 + ref="modified by function" + } + + myvar="original" + echo "before: $myvar" + modify_var myvar + echo "after: $myvar" + + - name: "Nameref in function scope - local nameref to outer var" + known_failure: true + stdin: | + outer="outer_value" + inner_func() { + local -n ref=outer + echo "inside func: $ref" + ref="modified_by_func" + } + echo "before: $outer" + inner_func + echo "after: $outer" + + - name: "Nameref in function - multiple namerefs (swap)" + known_failure: true + stdin: | + swap() { + local -n x=$1 + local -n y=$2 + local tmp="$x" + x="$y" + y="$tmp" + } + a="alpha" + b="beta" + echo "before: a=$a b=$b" + swap a b + echo "after: a=$a b=$b" + + - name: "Nameref with export" + stdin: | + target="exported_val" + declare -n ref=target + export ref + declare -p target | grep -o 'x' | head -1 || echo "no export flag" + + - name: "Nameref unset through ref removes target" + known_failure: true + stdin: | + target="value" + declare -n ref=target + echo "before: target=$target ref=$ref" + unset ref + echo "after: target='${target}' ref='${ref}'" + # ref still exists as a nameref, but target is gone + declare -p ref 2>/dev/null && echo "ref still declared" + declare -p target 2>/dev/null || echo "target is gone" + + - name: "Nameref unset target directly" + known_failure: true + stdin: | + target="exists" + declare -n ref=target + echo "ref: $ref" + unset target + echo "ref after unset: '${ref}'" + + - name: "Nameref array append with +=" + known_failure: true + stdin: | + target=(a b) + declare -n ref=target + ref+=(c d) + echo "target: ${target[@]}" + echo "count: ${#target[@]}" + declare -p target + + - name: "Nameref array element unset" + known_failure: true + stdin: | + target=(a b c d) + declare -n ref=target + unset 'ref[2]' + declare -p target + + - name: "Nameref array slicing" + known_failure: true + stdin: | + target=(a b c d e) + declare -n ref=target + echo "slice: ${ref[@]:1:2}" + echo "all: ${ref[@]}" + echo "count: ${#ref[@]}" + + - name: "Nameref to array element" + known_failure: true + stdin: | + arr=(zero one two three) + declare -n ref='arr[2]' + echo "ref: $ref" + ref="changed" + echo "arr[2]: ${arr[2]}" + + - name: "Nameref in subshell" + known_failure: true + stdin: | + target="original" + declare -n ref=target + (ref="subshell_value"; echo "inside: $target") + echo "outside: $target" + + - name: "Nameref in command substitution" + known_failure: true + stdin: | + target="original" + declare -n ref=target + result=$(ref="cmd_sub_value"; echo "$target") + echo "result: $result" + echo "target: $target" + + - name: "Nameref with integer attribute" + known_failure: true + stdin: | + target=0 + declare -ni ref=target + ref="hello" + echo "target: $target" + ref=42 + echo "target: $target" + declare -p ref + declare -p target + + - name: "Nameref with set -u and unset target" + known_failure: true + ignore_stderr: true + stdin: | + set -u + declare -n ref=unset_target + echo "$ref" + echo "should not print" + + - name: "Nameref to empty string target name" + ignore_stderr: true + stdin: | + declare -n ref="" + echo "ref: '${ref}'" + ref="value" 2>/dev/null + echo "done" + + - name: "Multiple namerefs to same target" + known_failure: true + stdin: | + target="initial" + declare -n ref1=target + declare -n ref2=target + ref1="from_ref1" + echo "ref2: $ref2" + echo "target: $target" + ref2="from_ref2" + echo "ref1: $ref1" + echo "target: $target" + + - name: "Circular reference in assignment context" + known_failure: true + ignore_stderr: true + stdin: | + declare -n a=b + declare -n b=a + a="value" 2>/dev/null + echo "exit: $?" + echo "done" + + - name: "Nameref with prefix enumeration" + stdin: | + declare -n ref_one=target1 + declare -n ref_two=target2 + regular_ref_var="not a nameref" + echo "prefix: ${!ref_@}" + + - name: "Nameref with local in nested functions" + known_failure: true + stdin: | + outer() { + local myval="outer_value" + inner + echo "after inner: $myval" + } + inner() { + local -n ref=myval + echo "ref sees: $ref" + ref="modified_by_inner" + } + outer + + - name: "Nameref for-in loop changes target" + known_failure: true + stdin: | + var1="a" + var2="b" + var3="c" + declare -n ref + for ref in var1 var2 var3; do + echo "ref=$ref" + done + echo "var1=$var1 var2=$var2 var3=$var3" + + - name: "Nameref to array element - arithmetic increment" + known_failure: true + stdin: | + arr=(10 20 30 40) + declare -n ref='arr[2]' + echo "before: $ref" + (( ref++ )) + echo "after: $ref" + echo "arr[2]: ${arr[2]}" + + - name: "Nameref to array element - arithmetic compound assign" + known_failure: true + stdin: | + arr=(100 200 300) + declare -n ref='arr[1]' + echo "before: $ref" + (( ref += 50 )) + echo "after: $ref" + echo "arr[1]: ${arr[1]}" + + - name: "Nameref to array element - arithmetic expression" + known_failure: true + stdin: | + arr=(100 200 300) + declare -n ref='arr[2]' + (( ref = ref * 2 )) + echo "arr[2]: ${arr[2]}" + + - name: "Nameref to array element - string append" + known_failure: true + stdin: | + arr=(hello world) + declare -n ref='arr[0]' + ref+=" there" + echo "arr[0]: ${arr[0]}" + + - name: "Nameref to array element - length" + known_failure: true + stdin: | + arr=("short" "a longer string here" "x") + declare -n ref='arr[1]' + echo "len: ${#ref}" + + - name: "Nameref to array element - parameter transformations" + known_failure: true + stdin: | + arr=("hello world" "foo") + declare -n ref='arr[0]' + echo "upper: ${ref^^}" + echo "Q: ${ref@Q}" + + - name: "Nameref to array element - substring" + known_failure: true + stdin: | + arr=("abcdefgh" "xyz") + declare -n ref='arr[0]' + echo "sub: ${ref:1:3}" + + - name: "Nameref to array element - pattern substitution" + known_failure: true + stdin: | + arr=("hello world hello" "x") + declare -n ref='arr[0]' + echo "first: ${ref/hello/bye}" + echo "all: ${ref//hello/bye}" + + - name: "Nameref to array element - default value" + known_failure: true + stdin: | + arr=("" "val") + declare -n ref='arr[0]' + echo "default: ${ref:-fallback}" + + - name: "Nameref to array element - negative index" + known_failure: true + stdin: | + arr=(a b c d e) + declare -n ref='arr[-1]' + echo "ref: $ref" + + - name: "Nameref to associative array element" + known_failure: true + stdin: | + declare -A map=([foo]=bar [baz]=qux) + declare -n ref='map[foo]' + echo "ref: $ref" + ref="changed" + echo "map_foo: ${map[foo]}" + + - name: "Nameref to array element - unset through ref" + known_failure: true + stdin: | + arr=(a b c d) + declare -n ref='arr[2]' + unset ref + declare -p arr + + - name: "Nameref chain 4 levels" + known_failure: true + stdin: | + final="deep" + declare -n c=final + declare -n b=c + declare -n a=b + echo "a: $a" + a="changed" + echo "final: $final" + + - name: "Nameref 3-level chain to array element" + known_failure: true + stdin: | + arr=(A B C) + declare -n mid='arr[1]' + declare -n top=mid + echo "top: $top" + top="CHANGED" + echo "arr[1]: ${arr[1]}" + + - name: "Nameref to associative array - keys and values" + known_failure: true + stdin: | + declare -A map=([x]=1 [y]=2) + declare -n ref=map + echo "x: ${ref[x]}" + ref[z]=3 + echo "z: ${map[z]}" + for k in "${!ref[@]}"; do echo "key=$k"; done | sort + + - name: "Nameref - declare -p shows nameref attribute" + stdin: | + target="hello" + declare -n ref=target + declare -p ref + declare -p target + + - name: "Nameref - test -v follows nameref" + known_failure: true + stdin: | + declare -n ref=nonesuch + [[ -v ref ]] && echo "set" || echo "unset" + nonesuch="now" + [[ -v ref ]] && echo "set" || echo "unset" + + - name: "Nameref to array - length and keys" + known_failure: true + stdin: | + arr=(a b c d e) + declare -n ref=arr + echo "len: ${#ref[@]}" + echo "keys: ${!ref[@]}" + echo "elem0len: ${#ref}" + + - name: "Nameref with printf -v" + known_failure: true + stdin: | + declare -n ref=formatted + printf -v ref "num=%d" 42 + echo "formatted: $formatted" + + - name: "Nameref - declare +n removes attribute" + known_failure: true + stdin: | + target="value" + declare -n ref=target + echo "before: $ref" + declare +n ref + echo "after: $ref" + declare -p ref + + - name: "Nameref to IFS" + known_failure: true + stdin: | + declare -n ref=IFS + saved="$IFS" + ref=":" + echo "ifs: '$IFS'" + IFS="$saved" + + - name: "Nameref to array - whole array assignment" + known_failure: true + stdin: | + declare -n ref=myarr + ref=(x y z) + declare -p myarr + + - name: "Nameref local -n recursive function" + known_failure: true + stdin: | + count_down() { + local v=$1 + if (( v <= 0 )); then return; fi + local -n o=result + o+="$v " + count_down $((v - 1)) + } + result="" + count_down 3 + echo "result: $result" + + - name: "Nameref to array - negative index access" + known_failure: true + stdin: | + arr=(a b c d e) + declare -n ref=arr + echo "last: ${ref[-1]}" + + - name: "Nameref in eval" + known_failure: true + stdin: | + target="eval_val" + declare -n ref=target + eval 'echo "eval: $ref"' + + - name: "Nameref in here-string" + known_failure: true + stdin: | + target="here_data" + declare -n ref=target + cat <<< "$ref" + + - name: "Nameref in extended test operators" + known_failure: true + stdin: | + target="abc" + declare -n ref=target + [[ $ref == abc ]] && echo "eq ok" + [[ $ref =~ ^a.c$ ]] && echo "regex ok" + [[ $ref < bcd ]] && echo "lt ok" + + - name: "Nameref to sparse array" + known_failure: true + stdin: | + declare -a arr + arr[5]=five + arr[10]=ten + declare -n ref=arr + echo "keys: ${!ref[@]}" + echo "len: ${#ref[@]}" + echo "elem5: ${ref[5]}" + + - name: "Nameref concurrent writes through two refs" + known_failure: true + stdin: | + target="init" + declare -n r1=target + declare -n r2=target + r1+="_one" + r2+="_two" + echo "target: $target" + + - name: "Nameref to array element - ternary in arithmetic" + stdin: | + arr=(0 1 0) + declare -n ref='arr[1]' + echo "ternary: $(( ref ? 100 : 200 ))" + + - name: "Nameref chain to assoc array element" + known_failure: true + stdin: | + declare -A map=([k]=v) + declare -n mid='map[k]' + declare -n top=mid + echo "top: $top" + + - name: "Nameref to array element - set test" + known_failure: true + stdin: | + arr=(a b c) + declare -n ref='arr[1]' + echo "set: ${ref+SET}" + unset 'arr[1]' + echo "unset: ${ref+SET}" + + - name: "Nameref local -n overrides outer nameref" + known_failure: true + stdin: | + a="A" + b="B" + declare -n ref=a + f() { + local -n ref=b + echo "inner: $ref" + } + f + echo "outer: $ref" + + - name: "Nameref target lowercase transform" + known_failure: true + stdin: | + declare -l target="" + declare -n ref=target + ref="HELLO" + echo "target: $target" + + - name: "Nameref export -n unexports target" + known_failure: true + stdin: | + export target="val" + declare -n ref=target + export -n ref + env | grep "^target=" | head -1 || echo "not exported" + + - name: "Nameref to array - push via index" + known_failure: true + stdin: | + arr=(a b) + declare -n ref=arr + ref[${#ref[@]}]="c" + echo "arr: ${ref[@]}" + echo "len: ${#ref[@]}" + + - name: "Nameref with readonly target - write fails" + ignore_stderr: true + stdin: | + target="immutable" + declare -n ref=target + readonly ref + ref="attempt" 2>/dev/null + echo "exit: $?" + echo "target: $target" + + - name: "Nameref - declare -a through nameref applies to target" + known_failure: true + stdin: | + declare -n ref=myarr + declare -a ref + ref=(1 2 3) + declare -p myarr 2>/dev/null || echo "not found" + + - name: "Nameref - declare -A through nameref applies to target" + known_failure: true + stdin: | + declare -n ref=mymap + declare -A ref + ref=([a]=1 [b]=2) + echo "a: ${mymap[a]}" + echo "b: ${mymap[b]}" + + - name: "Nameref to array element - test -v treats resolved name literally" + known_failure: true + stdin: | + # In bash, [[ -v ref ]] where ref→arr[2] looks for a variable literally + # named "arr[2]", not array element arr at index 2. So it's always unset. + arr=(a b c d) + declare -n ref='arr[2]' + echo "ref expands to: $ref" + [[ -v ref ]] && echo "set" || echo "unset" + unset 'arr[2]' + [[ -v ref ]] && echo "set" || echo "unset" + + - name: "Nameref to array element - test -v with nonexistent index" + known_failure: true + stdin: | + arr=(a b c) + declare -n ref='arr[5]' + [[ -v ref ]] && echo "set" || echo "unset" + + - name: "Nameref to arr[@] expands all elements" + known_failure: true + stdin: | + arr=(x y z) + declare -n ref='arr[@]' + echo "ref: $ref" + + - name: "Nameref circular with 3 nodes" + known_failure: true + ignore_stderr: true + stdin: | + declare -n c1=c2 + declare -n c2=c3 + declare -n c3=c1 + echo "c1: '${c1}'" + echo "exit: $?" + + - name: "Nameref in case pattern" + known_failure: true + stdin: | + target="hello" + declare -n ref=target + case $ref in + hello) echo "matched hello" ;; + *) echo "no match" ;; + esac + + - name: "Nameref local shadowing in nested functions" + known_failure: true + stdin: | + a="A" + b="B" + f() { + local -n ref=a + g + echo "f ref: $ref" + } + g() { + local -n ref=b + echo "g ref: $ref" + ref="modified_B" + } + f + echo "a=$a b=$b" + + - name: "Nameref to special variable RANDOM" + known_failure: true + stdin: | + declare -n ref=RANDOM + r1=$ref + r2=$ref + [[ "$r1" != "$r2" ]] && echo "different (dynamic)" || echo "same" + + - name: "Nameref for-in loop retargets through iteration" + known_failure: true + stdin: | + var1="A" + var2="B" + var3="C" + declare -n ref + for ref in var1 var2 var3; do + echo "ref=$ref" + done + echo "var1=$var1 var2=$var2 var3=$var3" + + # + # Additional edge cases: bare declare -n, invalid targets + # + + - name: "Nameref declared without value - bare declare -n" + known_failure: true + stdin: | + declare -n ref + echo "ref: '${ref}'" + declare -p ref 2>/dev/null + ref="value" + echo "ref after assign: '${ref}'" + declare -p ref 2>/dev/null + + - name: "Nameref declared without value - expansion behavior" + stdin: | + declare -n ref + echo "set: ${ref+SET}" + echo "default: ${ref:-fallback}" + echo "length: ${#ref}" + + - name: "Nameref to numeric-only target name" + known_failure: true + + ignore_stderr: true + stdin: | + declare -n ref=123 + echo "exit: $?" + echo "ref: '${ref}'" + echo "done" + + - name: "Nameref to name with special characters" + known_failure: true + + ignore_stderr: true + stdin: | + declare -n ref="a-b" + echo "exit1: $?" + declare -n ref2="a.b" + echo "exit2: $?" + declare -n ref3="a+b" + echo "exit3: $?" + echo "done" + + - name: "Nameref to name with spaces" + known_failure: true + + ignore_stderr: true + stdin: | + declare -n ref="a b" + echo "exit: $?" + echo "done" + + - name: "Nameref to positional parameter name" + known_failure: true + + ignore_stderr: true + stdin: | + declare -n ref=1 + echo "exit: $?" + echo "done" + + - name: "Nameref to @ or *" + known_failure: true + + ignore_stderr: true + stdin: | + declare -n ref1="@" + echo "exit1: $?" + declare -n ref2="*" + echo "exit2: $?" + echo "done" + + # + # Assignment semantics clarity + # + + - name: "Nameref assignment writes to target not retargets" + known_failure: true + stdin: | + var1="first" + var2="second" + declare -n ref=var1 + ref=var2 + echo "var1: $var1" + echo "ref: $ref" + declare -p ref + declare -p var1 + + # + # unset -n edge cases + # + + - name: "Unset -n on non-nameref variable" + known_failure: true + stdin: | + var="value" + unset -n var + echo "var: '${var}'" + declare -p var 2>/dev/null || echo "var is gone" + + - name: "Unset vs unset -n on same nameref" + known_failure: true + stdin: | + target="value" + declare -n ref=target + echo "before: target=$target ref=$ref" + unset ref + echo "after unset ref: target='${target}' ref='${ref}'" + target="restored" + echo "target restored: ref=$ref" + + - name: "Unset -n then recreate nameref" + known_failure: true + stdin: | + target="hello" + declare -n ref=target + echo "before: $ref" + unset -n ref + echo "after unset -n:" + declare -p ref 2>/dev/null || echo "ref gone" + declare -n ref=target + echo "recreated: $ref" + + - name: "Unset nameref where target is already unset" + known_failure: true + stdin: | + declare -n ref=nonexistent + unset ref + echo "exit: $?" + declare -p ref 2>/dev/null && echo "ref still exists" || echo "ref gone" + + - name: "Unset -n nameref where target is already unset" + known_failure: true + stdin: | + declare -n ref=nonexistent + unset -n ref + echo "exit: $?" + declare -p ref 2>/dev/null || echo "ref gone" + + - name: "Unset -v on nameref removes target" + known_failure: true + stdin: | + target="value" + declare -n ref=target + unset -v ref + echo "target: '${target}'" + declare -p ref 2>/dev/null && echo "ref exists" + declare -p target 2>/dev/null || echo "target gone" + + - name: "Unset nameref in function scope" + known_failure: true + stdin: | + target="global" + f() { + local -n ref=target + unset ref + echo "inside f: target='${target}'" + } + f + echo "outside: target='${target}'" + + # + # declare flag combinations with -n + # + + - name: "Nameref with uppercase attribute -u" + known_failure: true + stdin: | + declare -u target + declare -n ref=target + ref="hello world" + echo "target: $target" + echo "ref: $ref" + + - name: "Nameref combined -nx export" + known_failure: true + ignore_stderr: true + stdin: | + declare -nx ref=MYVAR + ref="exported_value" + echo "MYVAR: $MYVAR" + declare -p ref + declare -p MYVAR 2>/dev/null + + - name: "Nameref combined -na conflict" + known_failure: true + ignore_stderr: true + stdin: | + declare -na ref=target + echo "exit: $?" + declare -p ref 2>/dev/null + + - name: "Nameref combined -nA conflict" + known_failure: true + ignore_stderr: true + stdin: | + declare -nA ref=target + echo "exit: $?" + declare -p ref 2>/dev/null + + - name: "Declare -rn readonly nameref" + known_failure: true + ignore_stderr: true + stdin: | + target="val" + declare -rn ref=target + echo "ref: $ref" + declare -n ref=other 2>/dev/null + echo "retarget exit: $?" + unset -n ref 2>/dev/null + echo "unset -n exit: $?" + declare -p ref + + # + # Readonly deeper edge cases + # + + - name: "Readonly nameref - cannot unset -n" + known_failure: true + ignore_stderr: true + stdin: | + declare -n ref=target + readonly ref + unset -n ref 2>/dev/null + echo "exit: $?" + declare -p ref + + - name: "Make target readonly through nameref" + known_failure: true + ignore_stderr: true + stdin: | + target="value" + declare -n ref=target + readonly ref + declare -p target + target="new" 2>/dev/null + echo "target write exit: $?" + + # + # Export deeper edge cases + # + + - name: "Export through nameref - child sees it" + known_failure: true + stdin: | + declare -n ref=MY_EXPORT + ref="exported_value" + export ref + bash -c 'echo "child: $MY_EXPORT"' + + - name: "Unexport through nameref with declare +x" + known_failure: true + stdin: | + export target="was_exported" + declare -n ref=target + declare +x ref + env | grep "^target=" || echo "not exported" + echo "target: $target" + + # + # Parameter expansion - missing operators + # + + - name: "Nameref with := assign default creates target" + known_failure: true + stdin: | + declare -n ref=brand_new + echo "before: '${brand_new}'" + echo "assign: ${ref:=created}" + echo "after: $brand_new" + declare -p brand_new + + - name: "Nameref with :? error on unset" + known_failure: true # error prefix differs: brush prints "error:" vs bash prints "bash: line N:" + ignore_stderr: true + stdin: | + declare -n ref=missing + ( echo "${ref:?variable is required}" ) 2>&1 | tail -1 + echo "outer continues" + + - name: "Nameref with @E escape" + known_failure: true + stdin: | + target='hello\tworld\n' + declare -n ref=target + echo "E: ${ref@E}" + + - name: "Nameref substring with negatives" + known_failure: true + stdin: | + target="abcdefghij" + declare -n ref=target + echo "offset 3: ${ref:3}" + echo "offset 3 len 4: ${ref:3:4}" + echo "negative offset: ${ref: -3}" + echo "negative offset len: ${ref: -3:2}" + + - name: "Nameref with case modification operators" + known_failure: true + stdin: | + target="hello world" + declare -n ref=target + echo "^: ${ref^}" + echo "^^: ${ref^^}" + echo ",: ${ref,}" + echo ",,: ${ref,,}" + + - name: "Nameref with pattern removal prefix and suffix anchors" + known_failure: true + stdin: | + target="hello hello hello" + declare -n ref=target + echo "prefix: ${ref/#hello/bye}" + echo "suffix: ${ref/%hello/bye}" + + # + # Indirection ${!ref} interaction + # + + - name: "Nameref indirection on chain" + known_failure: true + stdin: | + ultimate="deep_value" + declare -n middle=ultimate + declare -n top=middle + echo "!top: ${!top}" + echo "!middle: ${!middle}" + echo "top: $top" + + - name: "Nameref indirection - ${!ref} on non-nameref" + known_failure: true + stdin: | + target="value" + ref=target + echo "!ref (not nameref): ${!ref}" + declare -n nref=target + echo "!nref (nameref): ${!nref}" + + - name: "Nameref indirection with array nameref keys" + known_failure: true + stdin: | + arr=(a b c) + declare -n ref=arr + echo "!ref[@]: ${!ref[@]}" + + - name: "Nameref indirection on nameref to array element" + known_failure: true + stdin: | + arr=(x y z) + declare -n ref='arr[1]' + echo "!ref: ${!ref}" + + # + # Arithmetic deeper + # + + - name: "Nameref in (( )) comparison" + stdin: | + target=42 + declare -n ref=target + (( ref == 42 )) && echo "equal" + (( ref > 10 )) && echo "greater" + (( ref < 100 )) && echo "less" + + - name: "Nameref in $((ref)) expression" + stdin: | + target=7 + declare -n ref=target + echo "expr: $(( ref + 3 ))" + echo "expr2: $(( ref * ref ))" + + - name: "Nameref array element in arithmetic for loop" + known_failure: true + stdin: | + arr=(0) + declare -n ref='arr[0]' + for (( ref=1; ref<=5; ref++ )); do + echo "ref=$ref arr[0]=${arr[0]}" + done + + - name: "Nameref in arithmetic assignment chain" + known_failure: true + stdin: | + a=0 + b=0 + declare -n refa=a + declare -n refb=b + (( refa = refb = 42 )) + echo "a=$a b=$b" + + # + # Execution contexts + # + + - name: "Nameref in pipe components" + known_failure: true + stdin: | + target="pipe_test" + declare -n ref=target + echo "$ref" | cat + echo "$ref" | { read line; echo "pipe read: $line"; } + + - name: "Nameref in trap handler" + known_failure: true + stdin: | + target="before_trap" + declare -n ref=target + trap 'echo "trap: ref=$ref target=$target"' EXIT + ref="after_trap" + + - name: "Nameref in brace group" + known_failure: true + stdin: | + target="brace" + declare -n ref=target + { ref="modified"; echo "inside: $target"; } + echo "outside: $target" + + - name: "Nameref in while loop" + known_failure: true + stdin: | + target=0 + declare -n ref=target + while (( ref < 3 )); do + echo "loop: ref=$ref" + (( ref++ )) + done + echo "final target: $target" + + - name: "Nameref in until loop" + known_failure: true + stdin: | + target=3 + declare -n ref=target + until (( ref <= 0 )); do + echo "until: ref=$ref" + (( ref-- )) + done + echo "final target: $target" + + - name: "Nameref in here-doc" + known_failure: true + stdin: | + target="heredoc_value" + declare -n ref=target + cat < 3 )); then + echo "greater" + fi + if [[ "$ref" == "5" ]]; then + echo "string match" + fi + + - name: "Nameref in background job" + known_failure: true + stdin: | + target="bg_test" + declare -n ref=target + { echo "bg ref: $ref"; } & + wait + echo "main target: $target" + + # + # Function scope deeper + # + + - name: "Nameref in function - target name same as local in caller" + known_failure: true + stdin: | + f() { + local val="inner" + g val + echo "after g: val=$val" + } + g() { + local -n ref=$1 + ref="modified" + } + f + + - name: "Nameref scope - same name different targets in nested calls" + known_failure: true + stdin: | + a="A" + b="B" + outer() { + local -n ref=a + echo "outer before: $ref" + inner + echo "outer after: $ref" + } + inner() { + local -n ref=b + echo "inner: $ref" + ref="B_modified" + } + outer + echo "a=$a b=$b" + + - name: "Nameref in function - ref to caller local" + known_failure: true + stdin: | + wrapper() { + local secret="hidden" + revealer secret + echo "after: secret=$secret" + } + revealer() { + local -n ref=$1 + echo "revealed: $ref" + ref="exposed" + } + wrapper + + - name: "Nameref in function - local var shadows nameref target" + known_failure: true + stdin: | + target="global_target" + f() { + local target="local_target" + local -n ref=target + echo "ref: $ref" + ref="modified" + echo "local target: $target" + } + f + echo "global target: $target" + + - name: "Nameref - return value pattern via nameref" + known_failure: true + stdin: | + compute() { + local -n _result=$1 + _result=$(( 6 * 7 )) + } + answer=0 + compute answer + echo "answer: $answer" + + - name: "Nameref - multiple out params via namerefs" + known_failure: true + stdin: | + divide() { + local -n _quotient=$1 + local -n _remainder=$2 + _quotient=$(( $3 / $4 )) + _remainder=$(( $3 % $4 )) + } + q=0 + r=0 + divide q r 17 5 + echo "17/5 = $q remainder $r" + + - name: "Nameref with declare -g in function" + known_failure: true + stdin: | + f() { + declare -gn ref=global_target + ref="from_function" + } + f + echo "global_target: $global_target" + declare -p ref 2>/dev/null && echo "ref exists globally" + + - name: "Nameref global pointing to local" + known_failure: true + stdin: | + declare -n gref=local_var + f() { + local local_var="local_value" + echo "gref in f: $gref" + } + f + echo "gref outside: '${gref}'" + + # + # Circular/chain deeper + # + + - name: "Circular nameref - 4 node cycle" + known_failure: true + ignore_stderr: true + stdin: | + declare -n a=b + declare -n b=c + declare -n c=d + declare -n d=a + echo "a: '${a}'" + echo "done" + + - name: "Chain where intermediate target is unset" + known_failure: true + stdin: | + declare -n top=middle + declare -n middle=bottom + bottom="value" + echo "top: $top" + unset -n middle + echo "top after: '${top}'" + + - name: "Nameref chain - declare -p at each level" + stdin: | + val="hello" + declare -n b=val + declare -n a=b + declare -p a + declare -p b + declare -p val + + - name: "Nameref self-reference - write attempt" + ignore_stderr: true + stdin: | + declare -n self=self + self="value" 2>/dev/null + echo "exit: $?" + echo "done" + + - name: "Nameref self-reference - declare -p" + known_failure: true + ignore_stderr: true + stdin: | + declare -n self=self + declare -p self + + - name: "Circular nameref - write attempt" + known_failure: true + ignore_stderr: true + stdin: | + declare -n x=y + declare -n y=x + x="value" 2>/dev/null + echo "write exit: $?" + echo "done" + + # + # Special variables + # + + - name: "Nameref to SECONDS" + stdin: | + SECONDS=0 + declare -n ref=SECONDS + echo "type check: $(( ref >= 0 ? 1 : 0 ))" + + - name: "Nameref to FUNCNAME in function" + known_failure: true + stdin: | + f() { + local -n ref=FUNCNAME + echo "funcname[0]: ${ref[0]}" + } + f + + - name: "Nameref to BASH_REMATCH" + known_failure: true + stdin: | + [[ "hello123world" =~ ([0-9]+) ]] + declare -n ref=BASH_REMATCH + echo "match: ${ref[0]}" + echo "group1: ${ref[1]}" + + - name: "Nameref to REPLY" + known_failure: true + stdin: | + declare -n ref=REPLY + read ref <<< "input_data" + echo "REPLY: $REPLY" + + - name: "Nameref to OPTIND" + known_failure: true + stdin: | + OPTIND=1 + declare -n ref=OPTIND + echo "optind: $ref" + ref=5 + echo "OPTIND: $OPTIND" + + # + # Extended test edge cases + # + + - name: "Nameref -v with nameref chain" + known_failure: true + stdin: | + val="exists" + declare -n mid=val + declare -n top=mid + [[ -v top ]] && echo "set" || echo "unset" + unset val + [[ -v top ]] && echo "set" || echo "unset" + + - name: "Nameref -R on regular variable" + stdin: | + regular="value" + [[ -R regular ]] && echo "is nameref" || echo "not nameref" + + - name: "Nameref -R on unset variable" + stdin: | + [[ -R nonexistent ]] && echo "is nameref" || echo "not nameref" + + - name: "Nameref in test -z / -n" + known_failure: true + stdin: | + target="" + declare -n ref=target + [[ -z "$ref" ]] && echo "empty: yes" + [[ -n "$ref" ]] && echo "nonempty: yes" || echo "nonempty: no" + target="data" + [[ -z "$ref" ]] && echo "empty: yes" || echo "empty: no" + [[ -n "$ref" ]] && echo "nonempty: yes" + + # + # Compound assignment through nameref + # + + - name: "Nameref compound array assignment to nonexistent" + known_failure: true + stdin: | + declare -n ref=newarray + ref=(alpha beta gamma) + declare -p newarray + + - name: "Nameref compound assignment replaces" + known_failure: true + stdin: | + target=(old1 old2 old3) + declare -n ref=target + ref=(new1 new2) + declare -p target + + - name: "Nameref += integer append on integer var" + known_failure: true + stdin: | + declare -i target=10 + declare -n ref=target + ref+=5 + echo "target: $target" + ref+=3 + echo "target: $target" + + # + # Array operations deeper + # + + - name: "Nameref to array - copy array" + known_failure: true + stdin: | + src=(1 2 3) + declare -n ref=src + dst=("${ref[@]}") + declare -p dst + + - name: "Nameref to array - declare -p shows array type" + stdin: | + declare -a arr=(x y z) + declare -n ref=arr + declare -p ref + declare -p arr + + - name: "Nameref to assoc array - declare -p shows assoc type" + stdin: | + declare -A map=([a]=1) + declare -n ref=map + declare -p ref + declare -p map + + - name: "Nameref to array - string operations on element 0" + known_failure: true + stdin: | + arr=("hello world" "foo") + declare -n ref=arr + echo "upper: ${ref^^}" + echo "length: ${#ref}" + echo "sub: ${ref:0:5}" + + - name: "Nameref to array - append to specific index" + known_failure: true + stdin: | + arr=(a b c) + declare -n ref=arr + ref[1]+="X" + declare -p arr + + - name: "Nameref to associative array - delete key" + known_failure: true + stdin: | + declare -A map=([x]=1 [y]=2 [z]=3) + declare -n ref=map + unset 'ref[y]' + for k in "${!ref[@]}"; do echo "$k=${ref[$k]}"; done | sort + + - name: "Nameref to associative array - key existence" + known_failure: true + stdin: | + declare -A map=([x]=1 [y]="") + declare -n ref=map + echo "x set: ${ref[x]+yes}" + echo "y set: ${ref[y]+yes}" + echo "z set: ${ref[z]+yes}" + + - name: "Nameref to array element - computed index" + known_failure: true + stdin: | + arr=(10 20 30 40 50) + i=3 + declare -n ref="arr[$i]" + echo "ref: $ref" + ref=99 + echo "arr[3]: ${arr[3]}" + + - name: "Nameref to array element - out of bounds" + known_failure: true + stdin: | + arr=(a b c) + declare -n ref='arr[10]' + echo "ref: '${ref}'" + ref="new" + declare -p arr + + - name: "Nameref to assoc array - missing key then assign" + known_failure: true + stdin: | + declare -A map=([x]=1) + declare -n ref='map[missing]' + echo "ref: '${ref}'" + ref="new" + echo "map[missing]: ${map[missing]}" + + - name: "Nameref to arr[@] - write attempt" + known_failure: true + ignore_stderr: true + stdin: | + arr=(a b c) + declare -n ref='arr[@]' + ref="new" 2>/dev/null + echo "exit: $?" + declare -p arr + + - name: "Nameref to arr[*] expansion" + known_failure: true + stdin: | + arr=(a b c) + declare -n ref='arr[*]' + echo "ref: $ref" + + # + # Variable attributes through ref + # + + - name: "Declare -i through nameref adds integer to target" + known_failure: true + stdin: | + declare -n ref=myvar + declare -i ref + ref="hello" + echo "myvar: $myvar" + ref=42 + echo "myvar: $myvar" + declare -p myvar + + - name: "Declare -u through nameref adds uppercase to target" + known_failure: true + stdin: | + declare -n ref=myvar + declare -u ref + ref="hello" + echo "myvar: $myvar" + declare -p myvar + + - name: "Declare -x through nameref exports target" + known_failure: true + stdin: | + target="value" + declare -n ref=target + declare -x ref + declare -p target + + # + # Typeset compatibility + # + + - name: "Typeset -n creates nameref" + known_failure: true + stdin: | + target="typeset_val" + typeset -n ref=target + echo "ref: $ref" + declare -p ref + + - name: "Typeset +n removes nameref attribute" + known_failure: true + stdin: | + target="value" + typeset -n ref=target + echo "before: $ref" + typeset +n ref + echo "after: $ref" + declare -p ref + + # + # For-in loop retarget + mutation + # + + - name: "For loop nameref - modify each target" + known_failure: true + stdin: | + a=1 + b=2 + c=3 + declare -n ref + for ref in a b c; do + ref=$(( ref * 10 )) + done + echo "a=$a b=$b c=$c" + + # + # Empty/whitespace values + # + + - name: "Nameref to variable with empty string value" + known_failure: true + stdin: | + target="" + declare -n ref=target + echo "ref: '$ref'" + echo "set: ${ref+SET}" + echo "default: ${ref:-DEFAULT}" + echo "nocolon default: ${ref-NOCOLON}" + + - name: "Nameref to variable with newline value" + known_failure: true + stdin: | + target=$'line1\nline2' + declare -n ref=target + echo "ref: $ref" + echo "---" + echo "$ref" | wc -l + + # + # set -u edge cases + # + + - name: "Nameref to set empty var with set -u" + known_failure: true + stdin: | + set -u + target="" + declare -n ref=target + echo "ref: '$ref'" + echo "survived" + + - name: "Nameref with set -u and default expansion" + known_failure: true + stdin: | + set -u + declare -n ref=missing + echo "default: ${ref:-safe}" + echo "survived" + + - name: "Nameref with set -u - ${ref+x} does not error" + known_failure: true + stdin: | + set -u + declare -n ref=missing + echo "set test: ${ref+SET}" + echo "survived" + + # + # Miscellaneous edge cases + # + + - name: "Nameref multiple declare -n retargets" + known_failure: true + stdin: | + a="A" + b="B" + declare -n ref=a + echo "first: $ref" + declare -n ref=b + echo "second: $ref" + + - name: "Nameref in array subscript context" + stdin: | + idx=2 + declare -n ref=idx + arr=(a b c d e) + echo "arr[ref]: ${arr[$ref]}" + + - name: "Two namerefs to same target - unset via one access via other" + known_failure: true + stdin: | + target="value" + declare -n ref1=target + declare -n ref2=target + unset ref1 + echo "ref2: '${ref2}'" + echo "target: '${target}'" + + - name: "Nameref with word splitting" + known_failure: true + stdin: | + target="one two three" + declare -n ref=target + for word in $ref; do echo "word: $word"; done + echo "---" + for word in "$ref"; do echo "word: $word"; done + + - name: "Nameref to _ (underscore variable)" + known_failure: true + stdin: | + _="test_value" + declare -n ref=_ + echo "ref: $ref" + + - name: "Nameref preserves target type across reassignment" + known_failure: true + stdin: | + declare -a arr=(x y) + declare -n ref=arr + ref=(a b c) + declare -p arr + + - name: "Nameref with command in value is literal" + known_failure: true + stdin: | + target='$(echo injected)' + declare -n ref=target + echo "ref: $ref" + + - name: "Nameref target created by assignment" + known_failure: true + stdin: | + declare -n ref=brand_new_var + ref="created" + echo "brand_new_var: $brand_new_var" + declare -p brand_new_var + + - name: "Nameref array index assignment" + known_failure: true + stdin: | + declare -a arr=(a b c) + declare -n ref=arr + ref[0]="X" + ref[2]="Z" + declare -p arr + + - name: "Nameref assoc array assignment" + known_failure: true + stdin: | + declare -A map + declare -n ref=map + ref[key1]="val1" + ref[key2]="val2" + for k in "${!map[@]}"; do echo "$k=${map[$k]}"; done | sort + + - name: "Nameref in source command" + known_failure: true + stdin: | + tmp=$(mktemp) + echo 'declare -n ref=target; ref="from_source"' > "$tmp" + target="original" + source "$tmp" + echo "target: $target" + rm "$tmp" + + - name: "Nameref to integer variable - arithmetic coercion" + known_failure: true + stdin: | + declare -i num=10 + declare -n ref=num + ref=20+5 + echo "num: $num" + ref="3*4" + echo "num after expr: $num" + + - name: "Nameref with declare giving value" + known_failure: true + stdin: | + declare -n ref=target + declare ref="value" + echo "target: ${target}" + declare -p target 2>/dev/null + declare -p ref + + - name: "Read -r into nameref" + known_failure: true + stdin: | + declare -n ref=result + read -r ref <<< 'hello\tworld' + echo "result: $result" + + - name: "Nameref in process substitution" + known_failure: true + stdin: | + target="proc_sub" + declare -n ref=target + cat <(echo "$ref") + + - name: "Nameref in environment prefix assignment" + known_failure: true + stdin: | + target="old" + declare -n ref=target + ref=new printenv target 2>/dev/null || echo "ref=new as prefix" + echo "target after: $target" + + - name: "Local non-nameref shadows outer nameref" + known_failure: true + stdin: | + f() { + local -n ref=target + g + echo "f ref: $ref" + } + g() { + local ref="plain" + echo "g ref: $ref" + } + target="T" + f + echo "target: $target" + + - name: "Local -n referencing same-scope local" + known_failure: true + stdin: | + f() { + local target="local_val" + local -n ref=target + echo "ref: $ref" + ref="changed" + echo "target: $target" + } + f + + - name: "Nameref with array slice reassignment" + known_failure: true + stdin: | + arr=(1 2 3) + declare -n ref=arr + echo "before: ${ref[@]}" + ref=("${ref[@]:1}") + echo "after: ${ref[@]}" + + - name: "Declare -p -n lists only namerefs" + stdin: | + declare -n r1=a + declare -n r2=b + regular="not_ref" + declare -p -n 2>/dev/null | sort + + # + # Integer attribute arithmetic evaluation on assignment + # (Not nameref-specific but exercises same code path) + # + + - name: "Integer variable evaluates arithmetic on assignment" + known_failure: true + stdin: | + declare -i x + x=20+5 + echo "x: $x" + x="3*4" + echo "x: $x" + x="10/3" + echo "x: $x" + x="y=7, y+3" + echo "x: $x" + + - name: "Integer variable with variable references in expression" + known_failure: true + stdin: | + declare -i x + y=10 + x=y+5 + echo "x: $x" + x="y*2" + echo "x: $x" + + - name: "Integer variable += with arithmetic" + known_failure: true + stdin: | + declare -i x=10 + x+=5 + echo "x: $x" + x+=3*2 + echo "x: $x" + + - name: "Integer variable through nameref evaluates arithmetic" + known_failure: true + stdin: | + declare -i num=10 + declare -n ref=num + ref=20+5 + echo "num: $num" + ref="3*4" + echo "num: $num" + ref+=10 + echo "num: $num" + + # + # :? error format (non-nameref cases too) + # + + - name: "Expansion :? error with regular variable" + ignore_stderr: true + stdin: | + unset missing + ( echo "${missing:?custom error}" ) 2>/dev/null + echo "exit: $?" + + - name: "Expansion :? with no message" + ignore_stderr: true + stdin: | + unset missing + ( echo "${missing:?}" ) 2>/dev/null + echo "exit: $?" + + # + # Self-reference edge cases beyond basic declaration + # + + - name: "Nameref retarget to self after creation is rejected" + known_failure: true + ignore_stderr: true + stdin: | + target="val" + declare -n ref=target + echo "ref: $ref" + declare -n ref=ref + echo "exit: $?" + echo "ref: $ref" + declare -p ref + + - name: "Circular via retarget" + known_failure: true + ignore_stderr: true + stdin: | + declare -n a=b + declare -n b=c + c="value" + echo "a: $a" + # Now make it circular + declare -n c=a + echo "a after: '${a}'" + echo "done" + + # + # Additional edge cases from review + # + + - name: "Self-reference by adding -n to existing var" + known_failure: true + ignore_stderr: true + stdin: | + x=x + declare -n x + echo "exit: $?" + declare -p x + echo "val: '${x}'" + + - name: "Array assignment through nameref-to-subscript errors" + known_failure: true + ignore_stderr: true + stdin: | + arr=(1 2 3) + declare -n ref='arr[2]' + ref=(a b c) 2>/dev/null + echo "exit: $?" + declare -p arr + + - name: "Integer array element via nameref with +=" + known_failure: true + stdin: | + declare -ia arr=(10 20 30) + declare -n ref='arr[1]' + ref+=5 + echo "arr[1]: ${arr[1]}" + + - name: "Nameref in select loop" + known_failure: true + stdin: | + target="" + declare -n ref=target + echo "2" | { + select ref in apple banana cherry; do + echo "target=$target ref=$ref" + break + done + } + + - name: "Nameref to LINENO" + known_failure: true + stdin: | + declare -n ref=LINENO + echo "ref: $ref" + + - name: "Nameref to BASH_SOURCE" + known_failure: true + stdin: | + declare -n ref=BASH_SOURCE + echo "ref0: '${ref[0]}'" + + - name: "Compound array append through subscripted nameref errors" + known_failure: true + ignore_stderr: true + stdin: | + arr=(a b c) + declare -n ref='arr[0]' + ref+=(x y z) 2>/dev/null + echo "exit: $?" + echo "arr: ${arr[@]}" + + - name: "Readonly propagates through 2-level nameref chain" + known_failure: true + ignore_stderr: true + stdin: | + target="original" + declare -n middle=target + declare -n top=middle + readonly top + declare -p target + top="new" 2>/dev/null + echo "exit: $?" + middle="new" 2>/dev/null + echo "exit: $?" + target="new" 2>/dev/null + echo "exit: $?" + echo "target: $target" + + - name: "Export -n (remove export) through nameref" + known_failure: true + stdin: | + target="exported_value" + export target + declare -n ref=target + env | grep '^target=' | head -1 + export -n ref + env | grep '^target=' || echo "target no longer exported" + + - name: "Declare -n with invalid target name containing spaces" + known_failure: true + ignore_stderr: true + stdin: | + declare -n ref='a b' 2>/dev/null + echo "exit: $?" + declare -n ref2='hello world' 2>/dev/null + echo "exit: $?" + echo "done" + + - name: "Declare -n with invalid target name special chars" + known_failure: true + ignore_stderr: true + stdin: | + declare -n ref='a+b' 2>/dev/null + echo "exit: $?" + declare -n ref2='a.b' 2>/dev/null + echo "exit: $?" + echo "done" + + - name: "Env var lookup through subscripted nameref" + known_failure: true + stdin: | + arr=(zero one two three) + declare -n ref='arr[2]' + echo "ref: $ref" + echo "ref length: ${#ref}" + + - name: "Subscripted nameref with [@] returns empty" + known_failure: true + stdin: | + arr=(zero one two three) + declare -n ref='arr[2]' + echo "ref[@]: ${ref[@]}" + echo "count: ${#ref[@]}" + + - name: "Subscripted nameref with explicit subscript returns empty" + known_failure: true + stdin: | + arr=(zero one two three) + declare -n ref='arr[2]' + echo "ref[0]: '${ref[0]}'" + echo "ref[1]: '${ref[1]}'" + + - name: "Subscripted nameref - member keys returns empty" + known_failure: true + stdin: | + arr=(zero one two three) + declare -n ref='arr[2]' + echo "keys: '${!ref[@]}'" + + - name: "Let assignment through subscripted nameref" + known_failure: true + stdin: | + arr=(10 20 30) + declare -n ref='arr[1]' + let ref=42 + echo "arr[1]: ${arr[1]}" + let ref+=8 + echo "arr[1]: ${arr[1]}" + + - name: "Arithmetic (( )) with subscripted nameref" + known_failure: true + stdin: | + arr=(100 200 300) + declare -n ref='arr[0]' + (( ref += 50 )) + echo "arr[0]: ${arr[0]}" + echo "expr: $(( ref * 2 ))" + + - name: "String length through subscripted nameref" + known_failure: true + stdin: | + arr=("short" "a longer string here" "x") + declare -n ref='arr[1]' + echo "len: ${#ref}" + + - name: "Long nameref chain (8 levels - at bash max)" + known_failure: true + stdin: | + final_target="deep_value" + declare -n c8=final_target + declare -n c7=c8 + declare -n c6=c7 + declare -n c5=c6 + declare -n c4=c5 + declare -n c3=c4 + declare -n c2=c3 + declare -n c1=c2 + echo "c1: $c1" + c1="changed" + echo "final_target: $final_target" + + - name: "Nameref chain exceeds max depth" + known_failure: true + ignore_stderr: true + stdin: | + final_target="deep_value" + declare -n c9=final_target + declare -n c8=c9 + declare -n c7=c8 + declare -n c6=c7 + declare -n c5=c6 + declare -n c4=c5 + declare -n c3=c4 + declare -n c2=c3 + declare -n c1=c2 + echo "c1: '$c1'" + echo "done" + + - name: "Declare -x through subscripted nameref applies to base" + known_failure: true + stdin: | + arr=(a b c) + declare -n ref='arr[2]' + declare -x ref 2>/dev/null + declare -p arr 2>/dev/null | grep -c 'x' || echo "no export" + + - name: "Exported nameref passes literal value to child" + stdin: | + target="value" + declare -nx myref=target + # The nameref's literal value (the target name) is exported to child + bash -c 'echo "myref: $myref"' + # The target itself needs separate export + export target + bash -c 'echo "target: $target"' + + - name: "Subscripted nameref with set -u when element is set" + known_failure: true + stdin: | + set -u + arr=(a b c) + declare -n ref='arr[1]' + echo "ref: $ref" + + - name: "Subscripted nameref with set -u when element is unset" + known_failure: true + ignore_stderr: true + stdin: | + set -u + arr=(a b c) + declare -n ref='arr[5]' + echo "$ref" + echo "should not print" + + - name: "Nameref with := default creates target through nameref" + known_failure: true + stdin: | + declare -n ref=new_var + echo "before: '${new_var:-}'" + result="${ref:=hello}" + echo "result: $result" + echo "new_var: $new_var" + + - name: "Nameref with := default when target exists but is empty" + known_failure: true + stdin: | + target="" + declare -n ref=target + result="${ref:=fallback}" + echo "result: $result" + echo "target: $target" + + - name: "Readonly nameref in for-in loop fails" + known_failure: true + ignore_stderr: true + stdin: | + declare -n ref=var1 + readonly ref + for ref in a b c; do + echo "should not print: $ref" + done + echo "exit: $?" + echo "done" + + - name: "Circular nameref with set -e does not abort script" + known_failure: true + ignore_stderr: true + stdin: | + set -e + declare -n a=b + declare -n b=a + echo "val: '${a}'" + echo "script continues" + + - name: "Nameref to unset variable with set -eu errors and exits" + known_failure: true + ignore_stderr: true + stdin: | + set -eu + declare -n ref=nonexistent + echo "$ref" + echo "should not print" + + - name: "Export through subscripted nameref is rejected by bash" + # In bash, `export ref` where ref→arr[1] passes the literal "arr[1]" to + # export, which rejects it as an invalid identifier. Brush instead resolves + # the nameref to the base variable and exports it. + ignore_stderr: true + stdin: | + arr=(a b c) + declare -n ref='arr[1]' + export ref 2>/dev/null + echo "exit: $?" + declare -p arr | grep -o 'x' | head -1 || echo "no export" + + - name: "Readonly through subscripted nameref makes base readonly" + # In bash, `readonly ref` where ref→arr[1] does NOT make the base array + # readonly. Brush currently resolves the nameref to the base variable and + # applies readonly there. The fix would be to have resolve_nameref_for_declaration + # in declare.rs handle subscripted nameref targets specially. + ignore_stderr: true + stdin: | + arr=(a b c) + declare -n ref='arr[1]' + readonly ref + declare -p arr + arr[0]="new" 2>/dev/null + echo "exit: $?" + + - name: "Declare -p on subscripted nameref shows nameref itself" + known_failure: true + stdin: | + arr=(a b c) + declare -n ref='arr[1]' + declare -p ref + echo "ref value: $ref" diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/options/bash_xtracefd.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/options/bash_xtracefd.yaml new file mode 100644 index 0000000000..71a2954b24 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/options/bash_xtracefd.yaml @@ -0,0 +1,84 @@ +name: "BASH_XTRACEFD tests" +cases: + - name: "basic redirection to fd 3" + stdin: | + exec 3>trace.txt + BASH_XTRACEFD=3 + + set -x + echo "hello" + echo "world" + + set +x + cat trace.txt + + - name: "redirection takes effect immediately" + stdin: | + exec 3>trace.txt + + set -x + echo "before setting BASH_XTRACEFD" + + BASH_XTRACEFD=3 + echo "after setting BASH_XTRACEFD" + + set +x + + echo "Content of trace file:" + cat trace.txt + + - name: "unsetting BASH_XTRACEFD returns to stderr" + stdin: | + exec 3>trace.txt + BASH_XTRACEFD=3 + + set -x + echo "redirected" + + unset BASH_XTRACEFD + echo "back to stderr" + + set +x + + echo "Content of trace file:" + cat trace.txt + + - name: "invalid fd value falls back to stderr" + known_failure: true # bash validates BASH_XTRACEFD at assignment time, brush doesn't have that mechanism + stdin: | + BASH_XTRACEFD=999 + + set -x + echo "should go to stderr" + + set +x + + - name: "non-numeric value falls back to stderr" + known_failure: true # bash validates BASH_XTRACEFD at assignment time, brush doesn't have that mechanism + stdin: | + BASH_XTRACEFD=invalid + + set -x + echo "should go to stderr" + + set +x + + - name: "empty value falls back to stderr" + stdin: | + BASH_XTRACEFD= + + set -x + echo "should go to stderr" + + - name: "redirect to file that fails writes" + stdin: | + # This test relies on /dev/full being present. We degrade gracefully + # if it doesn't. + [[ -e /dev/full ]] || exit + + exec 3>/dev/full + BASH_XTRACEFD=3 + + set -x + echo "first echo" + echo "first echo result: $?" diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/options/default.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/options/default.yaml new file mode 100644 index 0000000000..efc0be9349 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/options/default.yaml @@ -0,0 +1,8 @@ +name: "Default options" +cases: + - name: "Default options" + stdin: | + echo "Default options: $-" + + - name: "Default options (-c)" + args: ["-c", 'echo "Default options: $-"'] diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/options/errtrace.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/options/errtrace.yaml new file mode 100644 index 0000000000..482feb430f --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/options/errtrace.yaml @@ -0,0 +1,213 @@ +name: "Options: errtrace (-E)" +cases: + # + # Basic errtrace option behavior + # + - name: "errtrace disabled by default" + stdin: | + set +E 2>/dev/null || true + echo "errtrace test" + + - name: "errtrace can be enabled and disabled" + stdin: | + set -E + echo "enabled: $-" | grep -q E && echo "E is set" + set +E + echo "disabled: $-" | grep -qv E && echo "E is unset" + + # + # ERR trap inheritance in functions (core errtrace behavior) + # + - name: "errtrace disabled - functions don't inherit ERR trap" + stdin: | + set +E + trap 'echo "ERR trapped"' ERR + myfunc() { false; } + myfunc + echo "after" + + - name: "errtrace enabled - functions inherit ERR trap" + stdin: | + set -E + trap 'echo "ERR trapped"' ERR + myfunc() { false; } + myfunc + echo "after" + + - name: "errtrace with nested functions" + stdin: | + set -E + trap 'echo "ERR trapped"' ERR + inner() { false; } + outer() { inner; } + outer + echo "after" + + - name: "errtrace toggling mid-script" + stdin: | + trap 'echo "ERR trapped"' ERR + set -E + myfunc1() { false; } + myfunc1 + echo "after first call" + set +E + myfunc2() { false; } + myfunc2 + echo "after second call" + + # + # ERR trap inheritance in subshells + # + - name: "errtrace disabled - subshell doesn't inherit ERR trap" + stdin: | + set +E + trap 'echo "ERR"' ERR + ( + trap -p ERR + false + echo "no ERR trap fired" + ) + echo "after" + + - name: "errtrace enabled - subshell inherits ERR trap" + stdin: | + set -E + trap 'echo "ERR"' ERR + ( + trap -p ERR | grep -q ERR && echo "ERR trap inherited" + false + echo "after false in subshell" + ) + echo "after" + + # + # ERR trap inheritance in command substitution + # + - name: "errtrace disabled - command substitution doesn't inherit ERR trap" + stdin: | + set +E + trap 'echo "[ERR trapped]" >&2' ERR + result=$(trap -p ERR; false; echo "no trap inherited") + echo "result: $result" + + - name: "errtrace enabled - command substitution inherits ERR trap" + stdin: | + set -E + trap 'echo "[ERR in subshell]" >&2' ERR + result=$(false; echo "after false in subshell") + echo "result: $result" + + # + # ERR trap inheritance in pipelines + # + - name: "errtrace with pipeline - last command fails" + stdin: | + set -E + trap 'echo "[ERR: $?]" >&2' ERR + echo "data" | cat | false + echo "after pipeline" + + - name: "errtrace with pipeline - middle command fails (pipefail)" + stdin: | + set -E + set -o pipefail + trap 'echo "[ERR: $?]" >&2' ERR + echo "data" | false | cat + echo "after pipeline" + + - name: "errtrace - ERR trap in pipeline subshells" + stdin: | + set -E + trap 'echo "[ERR: $BASH_COMMAND]" >&2' ERR + echo "input" | { false; echo "after false in brace"; } + echo "done" + + # + # ERR trap with errexit interaction + # + - name: "errtrace with errexit - ERR trap fires before exit" + stdin: | + set -e + set -E + trap 'echo "ERR trap fired"' ERR + false + echo "should not print" + + - name: "errtrace with errexit in function" + known_failure: true # TODO(traps): ERR trap not firing in function with errexit + stdin: | + set -e + set -E + trap 'echo "ERR trap"' ERR + myfunc() { false; } + myfunc + echo "should not print" + + # + # ERR trap inheritance in deeply nested functions + # + - name: "errtrace - ERR trap in deeply nested function" + stdin: | + set -E + trap 'echo "ERR at level ${#FUNCNAME[@]}"' ERR + level3() { false; } + level2() { level3; } + level1() { level2; } + level1 + echo "after" + + # + # ERR trap modification within functions + # + - name: "errtrace - ERR trap can be changed inside function" + stdin: | + set -E + trap 'echo "outer ERR"' ERR + myfunc() { + trap 'echo "inner ERR"' ERR + false + } + myfunc + false + echo "after" + + - name: "errtrace - ERR trap can be cleared inside function" + stdin: | + set -E + trap 'echo "ERR"' ERR + myfunc() { + trap - ERR + false + echo "no ERR in func" + } + myfunc + false + echo "after" + + # + # ERR trap in background jobs with errtrace + # + - name: "errtrace - ERR trap in background job" + stdin: | + set -E + trap 'echo "ERR" >&2' ERR + { false; echo "bg done"; } & + wait + echo "main done" + + # + # Sourced scripts with errtrace + # + - name: "errtrace - ERR trap inherited in sourced script" + test_files: + - path: "failing.sh" + contents: | + echo "in sourced" + false + echo "after false in sourced" + stdin: | + set -E + trap 'echo "ERR trapped"' ERR + . ./failing.sh + echo "after source" + diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/options/extdebug.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/options/extdebug.yaml new file mode 100644 index 0000000000..416f92653f --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/options/extdebug.yaml @@ -0,0 +1,314 @@ +name: "Options: extdebug" +cases: + # + # extdebug implies functrace (-T) + # + - name: "extdebug implies functrace - DEBUG inherited in functions" + known_failure: true # TODO(BASH_COMMAND): quotes differ + stdin: | + shopt -s extdebug + trap 'echo "[debug: $BASH_COMMAND]"' DEBUG + myfunc() { + echo "in func" + } + echo "before" + myfunc + echo "after" + + - name: "extdebug implies functrace - DEBUG inherited in subshells" + known_failure: true # TODO(BASH_COMMAND): quotes differ + stdin: | + shopt -s extdebug + trap 'echo "[debug: $BASH_COMMAND]"' DEBUG + echo "before" + (echo "in subshell") + echo "after" + + # + # DEBUG trap return value effects (extdebug-specific) + # + - name: "DEBUG trap - returns 0 command executes normally" + known_failure: true # TODO(extdebug): return in trap handler not supported + stdin: | + shopt -s extdebug + trap 'return 0' DEBUG + echo "line 1" + echo "line 2" + echo "line 3" + + - name: "DEBUG trap - returns 1 command is skipped" + known_failure: true # TODO(extdebug): DEBUG trap return value not implemented + stdin: | + shopt -s extdebug + skip_next=false + trap ' + if $skip_next; then + skip_next=false + return 1 + fi + return 0 + ' DEBUG + echo "line 1" + skip_next=true + echo "this should be skipped" + echo "line 3" + + - name: "DEBUG trap - returns 2 simulates return from function" + known_failure: true # TODO(extdebug): DEBUG trap return value not implemented + stdin: | + shopt -s extdebug + trap ' + if [[ "${FUNCNAME[0]}" == "myfunc" && "$BASH_COMMAND" == *"should not"* ]]; then + return 2 + fi + return 0 + ' DEBUG + myfunc() { + echo "start of func" + echo "this should not print" + echo "also should not print" + } + myfunc + echo "after func: $?" + + - name: "DEBUG trap - return value ignored without extdebug" + known_failure: true # TODO(extdebug): return in trap handler error message differs + stdin: | + shopt -u extdebug + trap 'return 1' DEBUG + echo "line 1" + echo "line 2" + echo "line 3" + + # + # Command skipping scenarios (extdebug-specific) + # + - name: "Skip simple command" + known_failure: true # TODO(extdebug): DEBUG trap return value not implemented + stdin: | + shopt -s extdebug + trap '[[ "$BASH_COMMAND" == *"skip"* ]] && return 1; return 0' DEBUG + echo "keep this" + echo "skip this" + echo "keep this too" + + - name: "Skip in loop - iteration skipped but loop continues" + known_failure: true # TODO(extdebug): DEBUG trap return value not implemented + stdin: | + shopt -s extdebug + trap '[[ "$BASH_COMMAND" == *"skip"* ]] && return 1; return 0' DEBUG + for i in 1 2 3; do + echo "iter $i" + echo "skip $i" + echo "after skip $i" + done + echo "done" + + - name: "Skip in conditional" + known_failure: true # TODO(extdebug): DEBUG trap return value not implemented + stdin: | + shopt -s extdebug + trap '[[ "$BASH_COMMAND" == *"skip"* ]] && return 1; return 0' DEBUG + if true; then + echo "in then" + echo "skip in then" + echo "after skip in then" + fi + echo "after if" + + - name: "Return 2 in nested function - unwinds to caller" + known_failure: true # TODO(extdebug): DEBUG trap return value not implemented + stdin: | + shopt -s extdebug + trap ' + if [[ "${FUNCNAME[0]}" == "inner" && "$BASH_COMMAND" == *"inner cmd"* ]]; then + return 2 + fi + return 0 + ' DEBUG + inner() { + echo "inner start" + echo "inner cmd" + echo "inner end" + } + outer() { + echo "outer start" + inner + echo "outer after inner" + } + outer + echo "main after outer" + + # + # BASH_ARGC and BASH_ARGV (extdebug-specific) + # + - name: "BASH_ARGC - contains argument counts per call frame" + stdin: | + shopt -s extdebug + show_argc() { + echo "BASH_ARGC: ${BASH_ARGC[*]}" + } + func_with_args() { + show_argc + } + func_with_args a b c + + - name: "BASH_ARGV - contains arguments in reverse order" + stdin: | + shopt -s extdebug + show_argv() { + echo "BASH_ARGV: ${BASH_ARGV[*]}" + } + func_with_args() { + show_argv + } + func_with_args a b c + + - name: "BASH_ARGC/ARGV - in nested calls" + stdin: | + shopt -s extdebug + inner() { + echo "inner ARGC: ${BASH_ARGC[*]}" + echo "inner ARGV: ${BASH_ARGV[*]}" + } + outer() { + inner x y + } + outer a b c + + - name: "BASH_ARGC/ARGV - empty when no arguments" + stdin: | + shopt -s extdebug + no_args() { + echo "BASH_ARGC: '${BASH_ARGC[*]}'" + echo "BASH_ARGV: '${BASH_ARGV[*]}'" + } + no_args + + # + # declare -F shows source info (extdebug-specific) + # + - name: "declare -F - shows function name only without extdebug" + stdin: | + myfunc() { echo hi; } + declare -F myfunc + + - name: "declare -F - shows filename and line number with extdebug" + known_failure: true # TODO(extdebug): declare -F not showing source info + test_files: + - path: "funcs.sh" + contents: | + myfunc() { + echo hi + } + stdin: | + shopt -s extdebug + . ./funcs.sh + declare -F myfunc | sed 's/[0-9]*/N/' + + # + # Interaction with other options + # + - name: "extdebug with errexit - skipped command doesn't trigger errexit" + known_failure: true # TODO(extdebug): DEBUG trap return value not implemented + stdin: | + shopt -s extdebug + set -e + trap '[[ "$BASH_COMMAND" == "false" ]] && return 1; return 0' DEBUG + echo "before" + false + echo "after - should print because false was skipped" + + - name: "extdebug disabled after being enabled" + known_failure: true # TODO(extdebug): DEBUG trap return value not implemented + stdin: | + shopt -s extdebug + trap 'return 1' DEBUG + echo "line 1 - should be skipped" + shopt -u extdebug + echo "line 2 - should print" + echo "line 3 - should print" + + - name: "extdebug with set -T - both affect DEBUG inheritance" + known_failure: true # TODO(BASH_COMMAND): quotes differ + stdin: | + shopt -s extdebug + set -T + trap 'echo "[debug: $BASH_COMMAND]"' DEBUG + myfunc() { + echo "in func" + } + myfunc + echo "done" + + # + # DEBUG trap with extdebug in various contexts + # + - name: "DEBUG trap with extdebug in function" + known_failure: true # TODO(extdebug): return in trap handler not supported + stdin: | + shopt -s extdebug + count=0 + trap ' + ((count++)) + echo "debug $count: $BASH_COMMAND" + return 0 + ' DEBUG + myfunc() { + local x=1 + echo "x=$x" + } + myfunc + + - name: "DEBUG trap with extdebug controls command execution" + known_failure: true # TODO(extdebug): DEBUG trap return value not implemented + stdin: | + shopt -s extdebug + should_skip=false + trap ' + if $should_skip; then + should_skip=false + echo "[skipping: $BASH_COMMAND]" + return 1 + fi + return 0 + ' DEBUG + echo "first" + should_skip=true + echo "second - will be skipped" + echo "third" + + # + # Edge cases + # + - name: "extdebug with recursive function" + known_failure: true # TODO(extdebug): DEBUG trap firing count differs + stdin: | + shopt -s extdebug + trap 'echo "depth: ${#FUNCNAME[@]}"' DEBUG + recurse() { + local n=$1 + if ((n > 0)); then + recurse $((n - 1)) + fi + } + recurse 3 + + - name: "BASH_COMMAND shows compound command" + known_failure: true # TODO(BASH_COMMAND): quotes differ + stdin: | + shopt -s extdebug + trap 'echo "cmd: $BASH_COMMAND"' DEBUG + if true; then echo "in if"; fi + + - name: "DEBUG trap can modify variables" + known_failure: true # TODO(extdebug): return in trap handler not supported + stdin: | + shopt -s extdebug + counter=0 + trap '((counter++)); return 0' DEBUG + echo "a" + echo "b" + echo "c" + echo "counter: $counter" + diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/options/functrace.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/options/functrace.yaml new file mode 100644 index 0000000000..44f9a20a8e --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/options/functrace.yaml @@ -0,0 +1,314 @@ +name: "Options: functrace (-T)" +cases: + # + # Basic functrace option behavior + # + - name: "functrace can be enabled and disabled" + stdin: | + set -T + echo "enabled: $-" | grep -q T && echo "T is set" + set +T + echo "disabled: $-" | grep -qv T && echo "T is unset" + + # + # DEBUG trap inheritance in functions + # + - name: "DEBUG trap - does not fire in function without set -T" + known_failure: true # TODO(BASH_COMMAND): quotes differ + stdin: | + trap 'echo "[debug: $BASH_COMMAND]"' DEBUG + myfunc() { + echo "in func" + } + echo "before func" + myfunc + echo "after func" + + - name: "DEBUG trap - fires in function with set -T" + known_failure: true # TODO(BASH_COMMAND): quotes differ + stdin: | + set -T + trap 'echo "[debug: $BASH_COMMAND]"' DEBUG + myfunc() { + echo "in func" + } + echo "before func" + myfunc + echo "after func" + + # + # DEBUG trap inheritance in subshells + # + - name: "DEBUG trap - does not fire in subshell without set -T" + known_failure: true # TODO(BASH_COMMAND): quotes differ + stdin: | + trap 'echo "[debug: $BASH_COMMAND]"' DEBUG + echo "before subshell" + (echo "in subshell") + echo "after subshell" + + - name: "DEBUG trap - fires in subshell with set -T" + known_failure: true # TODO(BASH_COMMAND): quotes differ + stdin: | + set -T + trap 'echo "[debug: $BASH_COMMAND]"' DEBUG + echo "before subshell" + (echo "in subshell") + echo "after subshell" + + # + # DEBUG trap inheritance in command substitution + # + - name: "DEBUG trap - does not fire in command substitution without set -T" + known_failure: true # TODO(BASH_COMMAND): quotes differ + stdin: | + trap 'echo "[debug: $BASH_COMMAND]" >&2' DEBUG + echo "before" + result=$(echo "in substitution") + echo "result: $result" + + - name: "DEBUG trap - fires in command substitution with set -T" + known_failure: true # TODO(BASH_COMMAND): quotes differ + stdin: | + set -T + trap 'echo "[debug: $BASH_COMMAND]" >&2' DEBUG + echo "before" + result=$(echo "in substitution") + echo "result: $result" + + # + # DEBUG trap inheritance in pipelines + # + - name: "DEBUG trap - does not fire in pipeline without set -T" + known_failure: true # TODO(BASH_COMMAND): quotes differ + stdin: | + trap 'echo "[debug: $BASH_COMMAND]" >&2' DEBUG + echo "before" + echo "hello" | cat | cat + echo "after" + + - name: "DEBUG trap - fires in pipeline with set -T" + known_failure: true # TODO(BASH_COMMAND): quotes differ + stdin: | + set -T + trap 'echo "[debug: $BASH_COMMAND]" >&2' DEBUG + echo "before" + echo "hello" | cat | cat + echo "after" + + # + # RETURN trap inheritance in functions + # + - name: "RETURN trap - does not fire in called function without set -T" + stdin: | + trap 'echo "[return: $FUNCNAME]"' RETURN + inner() { + echo "in inner" + } + outer() { + echo "in outer" + inner + echo "outer continuing" + } + outer + echo "after" + + - name: "RETURN trap - fires in called function with set -T" + known_failure: true # TODO(functrace): RETURN trap inheritance not implemented + stdin: | + set -T + trap 'echo "[return: ${FUNCNAME:-main}]"' RETURN + inner() { + echo "in inner" + } + outer() { + echo "in outer" + inner + echo "outer continuing" + } + outer + echo "after" + + - name: "RETURN trap - fires for each nested function return with set -T" + known_failure: true # TODO(functrace): RETURN trap inheritance not implemented + stdin: | + set -T + trap 'echo "[return: ${FUNCNAME:-main} -> $?]"' RETURN + level3() { echo "level3"; return 3; } + level2() { echo "level2"; level3; return 2; } + level1() { echo "level1"; level2; return 1; } + level1 + echo "done" + + # + # Function-specific trace attribute (declare -t) + # + - name: "declare -t - enables trace for specific function" + known_failure: true # TODO(BASH_COMMAND): quotes differ + stdin: | + trap 'echo "[debug: $BASH_COMMAND]"' DEBUG + traced_func() { + echo "in traced" + } + untraced_func() { + echo "in untraced" + } + declare -t traced_func + echo "calling untraced" + untraced_func + echo "calling traced" + traced_func + echo "done" + + - name: "declare +t - disables trace for specific function" + known_failure: true # TODO(BASH_COMMAND): quotes differ + stdin: | + set -T + trap 'echo "[debug: $BASH_COMMAND]"' DEBUG + myfunc() { + echo "in func" + } + declare +t myfunc + echo "calling func" + myfunc + echo "done" + + - name: "declare -f - shows trace attribute" + stdin: | + myfunc() { echo hi; } + declare -t myfunc + declare -f myfunc | head -1 + + # + # Toggling set -T mid-script + # + - name: "set -T enabled mid-script affects subsequent calls" + known_failure: true # TODO(BASH_COMMAND): quotes differ + stdin: | + trap 'echo "[debug: $BASH_COMMAND]"' DEBUG + func1() { echo "in func1"; } + func2() { echo "in func2"; } + echo "calling func1 without -T" + func1 + set -T + echo "calling func2 with -T" + func2 + echo "done" + + - name: "set +T disables inheritance mid-script" + known_failure: true # TODO(BASH_COMMAND): quotes differ + stdin: | + set -T + trap 'echo "[debug: $BASH_COMMAND]"' DEBUG + func1() { echo "in func1"; } + func2() { echo "in func2"; } + echo "calling func1 with -T" + func1 + set +T + echo "calling func2 without -T" + func2 + echo "done" + + # + # DEBUG trap inheritance in background jobs + # + - name: "DEBUG trap - in background job with set -T" + stdin: | + set -T + trap 'echo "[debug]" >&2' DEBUG + { + echo "in background" + } & + wait + echo "done" + + # + # DEBUG trap inheritance in process substitution + # + - name: "DEBUG trap - in process substitution with set -T" + stdin: | + set -T + trap 'echo "[debug]" >&2' DEBUG + cat <(echo "from procsub") + echo "done" + + # + # BASH_COMMAND visibility in DEBUG trap with functrace + # + - name: "BASH_COMMAND in function with set -T" + known_failure: true # TODO(BASH_COMMAND): quotes differ + stdin: | + set -T + trap 'echo "cmd: $BASH_COMMAND"' DEBUG + myfunc() { + local v=10 + echo "v=$v" + } + myfunc + + # + # Nested functions with varying trace states + # + - name: "Outer function traced, inner function not traced" + stdin: | + trap 'echo "[debug in ${FUNCNAME:-main}]"' DEBUG + inner() { + echo "inner" + } + outer() { + echo "outer start" + inner + echo "outer end" + } + declare -t outer + outer + echo "done" + + - name: "Only inner function traced" + stdin: | + trap 'echo "[debug in ${FUNCNAME:-main}]"' DEBUG + inner() { + echo "inner" + } + outer() { + echo "outer start" + inner + echo "outer end" + } + declare -t inner + outer + echo "done" + + # + # RETURN trap with sourced scripts and set -T + # + - name: "RETURN trap - fires for function in sourced script with set -T" + known_failure: true # TODO(functrace): RETURN trap inheritance not implemented + test_files: + - path: "sourced.sh" + contents: | + sourced_func() { + echo "in sourced_func" + } + sourced_func + stdin: | + set -T + trap 'echo "[return: ${FUNCNAME:-main}]"' RETURN + . ./sourced.sh + echo "after source" + + # + # Edge cases + # + - name: "DEBUG trap - count in deeply nested function calls with set -T" + known_failure: true # TODO(functrace): DEBUG trap count differs + stdin: | + set -T + count=0 + trap 'count=$((count + 1))' DEBUG + a() { b; } + b() { c; } + c() { echo "deep"; } + a + echo "debug count: $count" + diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/options/inherit-errexit.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/options/inherit-errexit.yaml new file mode 100644 index 0000000000..e2470b513f --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/options/inherit-errexit.yaml @@ -0,0 +1,76 @@ +name: "Options: inherit_errexit" +cases: + - name: "inherit_errexit disabled by default" + stdin: | + shopt -q inherit_errexit && echo "enabled" || echo "disabled" + + - name: "inherit_errexit disabled - command substitution ignores errexit" + stdin: | + set -e + shopt -u inherit_errexit + result=$(false; echo "still running") + echo "result: $result" + echo "after" + + - name: "inherit_errexit enabled - command substitution inherits errexit" + stdin: | + set -e + shopt -s inherit_errexit + result=$(false; echo "should not print") + echo "should not print" + + - name: "inherit_errexit with nested command substitution" + stdin: | + set -e + shopt -s inherit_errexit + result=$(echo "outer: $(false; echo inner)") + echo "should not print" + + - name: "inherit_errexit disabled - nested substitution continues" + stdin: | + set -e + shopt -u inherit_errexit + result=$(echo "outer: $(false; echo inner)") + echo "result: $result" + echo "after" + + - name: "inherit_errexit with backtick substitution" + stdin: | + set -e + shopt -s inherit_errexit + result=`false; echo "should not print"` + echo "should not print" + + - name: "inherit_errexit with assignment" + stdin: | + set -e + shopt -s inherit_errexit + var=$(false) + echo "should not print" + + - name: "inherit_errexit in function" + stdin: | + set -e + shopt -s inherit_errexit + myfunc() { + local result=$(false; echo "should not print") + echo "after local" + } + myfunc + echo "should not print" + + - name: "inherit_errexit toggling" + stdin: | + set -e + shopt -s inherit_errexit + result1=$(false; echo "should not print in first") + echo "should not print 1" + ignore_stderr: true + + - name: "inherit_errexit with multiple commands in substitution" + stdin: | + set -e + shopt -u inherit_errexit + result=$(echo "start"; false; echo "after false") + echo "result: $result" + echo "after" diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/options/set-B.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/options/set-B.yaml new file mode 100644 index 0000000000..c2171093c7 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/options/set-B.yaml @@ -0,0 +1,9 @@ +name: "set -B" +cases: + - name: "set -B" + stdin: | + set +B + echo "+B: " ${a,b} + + set -B + echo "-B: " ${a,b} diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/options/set-C.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/options/set-C.yaml new file mode 100644 index 0000000000..b66b1e5eca --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/options/set-C.yaml @@ -0,0 +1,27 @@ +name: "set -C" +cases: + - name: "set -C" + ignore_stderr: true + stdin: | + touch existing-file + + set -C + + echo hi > non-existing-file + echo "Result (non existing): $?" + echo "File contents: $(cat non-existing-file)" + echo + + echo hi > /dev/null + echo "Result (device file): $?" + echo + + echo hi > existing-file + echo "Result (existing file): $?" + echo "File contents: $(cat existing-file)" + echo + + echo hi >| existing-file + echo "Result (clobber): $?" + echo "File contents: $(cat existing-file)" + echo diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/options/set-a.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/options/set-a.yaml new file mode 100644 index 0000000000..bdc077cfac --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/options/set-a.yaml @@ -0,0 +1,20 @@ +name: "set -a" +cases: + - name: "set -a" + stdin: | + v1=1 v2=2 v3=3 v4=4 v5=(a b c) + declare -p v1 v2 v3 v4 + + set -a + v1=reassigned + v2+=appended + declare -i v3 + v4+=(appended) + v5[2]=updated + v6=new + v7[0]=new + v8=(new) + declare v9=new + declare -a v10=(new) + + declare -p v1 v2 v3 v4 v5 v6 v7 v8 v9 v10 diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/options/set-e.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/options/set-e.yaml new file mode 100644 index 0000000000..1ba38e73b7 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/options/set-e.yaml @@ -0,0 +1,1324 @@ +name: "Options: set -e" +cases: + - name: "errexit with set -o errexit" + stdin: | + set -o errexit + false + echo "should not print" + + - name: "errexit status check" + stdin: | + set -e + set -o | grep errexit + + # Basic behavior + - name: "Basic non-error case" + stdin: | + set -e + true + echo "Text after call" + + - name: "Basic error case" + stdin: | + set -e + false + echo "Text after call" + + - name: "Command line -e" + args: ["-e"] + stdin: | + false + echo "should not print" + + - name: "Multiple commands before error" + stdin: | + set -e + echo "first" + echo "second" + false + echo "should not print" + + - name: "Error in middle of script" + stdin: | + set -e + echo "before" + false + echo "after" + + - name: "Error in if condition" + stdin: | + set -e + if false; then + echo "then branch" + else + echo "else branch" + fi + echo "after if" + + - name: "Error in elif condition" + stdin: | + set -e + if false; then + echo "then branch" + elif false; then + echo "elif branch" + else + echo "else branch" + fi + echo "after if" + + - name: "Error in if body" + stdin: | + set -e + if true; then + false + echo "should not print" + fi + echo "after if" + + - name: "Error in elif body" + stdin: | + set -e + if false; then + echo "then branch" + elif true; then + false + echo "should not print" + fi + echo "after if" + + - name: "Error in else body" + stdin: | + set -e + if false; then + echo "then" + else + false + echo "should not print" + fi + echo "after if" + + - name: "Error in if condition with compound test" + stdin: | + set -e + if false && false; then + echo "then" + else + echo "else" + fi + echo "after" + + - name: "Error in while condition" + stdin: | + set -e + count=0 + while false; do + echo "loop body" + done + echo "after while" + + - name: "Error in while body" + stdin: | + set -e + count=0 + while [ $count -lt 2 ]; do + count=$((count + 1)) + false + echo "should not print" + done + echo "after while" + + - name: "Error in until condition" + stdin: | + set -e + count=0 + until true; do + echo "loop body" + done + echo "after until" + + - name: "Error in until body" + stdin: | + set -e + until false; do + false + echo "loop body" + break + done + echo "after until" + + # Boolean operators - set -e should NOT exit with && and || + - name: "Error on right side of &&" + stdin: | + set -e + true && false + echo "after &&" + + - name: "Error on left side of &&" + stdin: | + set -e + false && true + echo "after &&" + + - name: "Error on both sides of ||" + stdin: | + set -e + false || false + echo "after ||" + + - name: "Error on left side of ||" + stdin: | + set -e + false || true + echo "after ||" + + - name: "Error on right side of ||" + stdin: | + set -e + true || false + echo "after ||" + + - name: "Error after && chain" + stdin: | + set -e + true && true + false + echo "should not print" + + - name: "Complex boolean expression" + stdin: | + set -e + false || true && false || true + echo "after complex expression" + + - name: "Negated command with failure" + stdin: | + set -e + ! false + echo "after negation" + + - name: "Negated command with success" + stdin: | + set -e + ! true + echo "after negation" + + - name: "Error after negated command" + stdin: | + set -e + ! false + false + echo "should not print" + + # Pipelines + - name: "Error in first command of pipeline (without pipefail)" + stdin: | + set -e + false | true + echo "after pipeline" + + - name: "Error in first command of pipeline (without pipefail, negated)" + stdin: | + set -e + ! false | true + echo "after pipeline" + + - name: "Error in first command of pipeline (with pipefail)" + stdin: | + set -e -o pipefail + false | true + echo "after pipeline" + + - name: "Error in first command of pipeline (with pipefail, negated)" + stdin: | + set -e -o pipefail + ! false | true + echo "after pipeline" + + - name: "Error in middle of pipeline (without pipefail)" + stdin: | + set -e + true | false | true + echo "after pipeline" + + - name: "Error in middle of pipeline (without pipefail, negated)" + stdin: | + set -e + ! true | false | true + echo "after pipeline" + + - name: "Error in middle of pipeline (with pipefail)" + stdin: | + set -e -o pipefail + true | false | true + echo "after pipeline" + + - name: "Error in middle of pipeline (with pipefail, negated)" + stdin: | + set -e -o pipefail + ! true | false | true + echo "after pipeline" + + - name: "Error in last command of pipeline (without pipefail)" + stdin: | + set -e + true | false + echo "after pipeline" + + - name: "Error in last command of pipeline (without pipefail, negated)" + stdin: | + set -e + ! true | false + echo "after pipeline" + + - name: "Error in last command of pipeline (with pipefail)" + stdin: | + set -e -o pipefail + true | false + echo "after pipeline" + + - name: "Error in last command of pipeline (with pipefail, negated)" + stdin: | + set -e -o pipefail + ! true | false + echo "after pipeline" + + - name: "Errors in multiple commands of pipeline (without pipefail)" + stdin: | + set -e + + function ret() { + return $1 + } + + ret 1 | ret 2 | ret 3 + + - name: "Errors in multiple commands of pipeline (without pipefail, negated)" + stdin: | + set -e + + function ret() { + return $1 + } + + ! ret 1 | ret 2 | ret 3 + + - name: "Errors in multiple commands of pipeline (with pipefail)" + stdin: | + set -e -o pipefail + + function ret() { + return $1 + } + + ret 1 | ret 2 | ret 3 + + - name: "Errors in multiple commands of pipeline (with pipefail, negated)" + stdin: | + set -e -o pipefail + + function ret() { + return $1 + } + + ! ret 1 | ret 2 | ret 3 + + - name: "Pipeline in conditional" + stdin: | + set -e + if true | false; then + echo "then" + else + echo "else" + fi + echo "after" + + - name: "Negated pipeline" + stdin: | + set -e + ! false | false + echo "after" + + # Functions + - name: "Error in function body exits function and script" + stdin: | + set -e + myfunc() { + echo "in function" + false + echo "should not print in function" + } + myfunc + echo "should not print after function" + + - name: "Function returning error in conditional" + stdin: | + set -e + myfunc() { + false + } + if myfunc; then + echo "then" + else + echo "else" + fi + echo "after" + + - name: "Function called with &&" + stdin: | + set -e + myfunc() { + false + } + true && myfunc + echo "after" + + - name: "Function called with ||" + stdin: | + set -e + myfunc() { + false + } + false || myfunc + echo "after" + + - name: "Function called with negation" + stdin: | + set -e + myfunc() { + false + } + ! myfunc + echo "after" + + - name: "Error in function body with set -e inside function" + stdin: | + myfunc() { + set -e + echo "before error" + false + echo "should not print" + } + myfunc + echo "after function call" + + - name: "Function with explicit return still respects set -e" + stdin: | + set -e + myfunc() { + false + return 0 + } + myfunc + echo "after function" + + # Subshells + - name: "Error in subshell exits subshell but not parent" + stdin: | + set -e + echo "before subshell" + ( + echo "in subshell" + false + echo "should not print in subshell" + ) + echo "after subshell" + + - name: "Subshell with set -e exits on error" + stdin: | + false + ( + set -e + echo "before" + false + echo "should not print" + ) + echo "after subshell" + + - name: "Subshell failure in conditional" + stdin: | + set -e + if (false); then + echo "then" + else + echo "else" + fi + echo "after" + + - name: "Subshell with &&" + stdin: | + set -e + true && (false) + echo "after" + + # Command substitution + - name: "Error in command substitution" + stdin: | + set -e + result=$(false) + echo "after command substitution" + + - name: "Error in command substitution with assignment" + stdin: | + set -e + var=$(false; echo "value") + echo "var is: $var" + echo "after" + + - name: "Command substitution in conditional" + stdin: | + set -e + if [ "$(false; echo no)" = "no" ]; then + echo "matched" + fi + echo "after" + + - name: "set +e disables error exit" + stdin: | + set -e + echo "with -e" + set +e + false + echo "after false with +e" + + - name: "set -e can be re-enabled" + stdin: | + set -e + set +e + false + echo "after false with +e" + set -e + false + echo "should not print" + + - name: "set +e in function" + stdin: | + set -e + myfunc() { + set +e + false + echo "in function after false" + } + myfunc + false + echo "should not print" + + # Exit status handling + - name: "Non-zero exit status triggers exit" + stdin: | + set -e + (exit 1) + echo "should not print" + + - name: "Specific non-zero exit status" + stdin: | + set -e + (exit 42) + echo "should not print" + + - name: "Zero exit status" + stdin: | + set -e + (exit 0) + echo "after exit 0" + + # Assignment with command substitution + - name: "Assignment from failing command" + stdin: | + set -e + var=$(false) || true + echo "after assignment" + + - name: "Local variable assignment with failing command" + stdin: | + set -e + myfunc() { + local var=$(false) + echo "after local" + } + myfunc + echo "after function" + + # Complex scenarios + - name: "Error after successful conditional" + stdin: | + set -e + if true; then + echo "in if" + fi + false + echo "should not print" + + - name: "Nested conditionals with error" + stdin: | + set -e + if true; then + if false; then + echo "inner then" + else + echo "inner else" + fi + echo "after inner if" + fi + echo "after outer if" + + - name: "Error in case branch" + stdin: | + set -e + case "x" in + $(false)) + echo "matched" + ;; + esac + echo "after case" + + - name: "Error in case statement body" + stdin: | + set -e + case "x" in + x) + echo "matched" + false + echo "should not print" + ;; + esac + echo "after case" + + - name: "case pattern matching on non-match" + stdin: | + set -e + case "x" in + y) echo "y" ;; + z) echo "z" ;; + esac + echo "after case" + + # List constructs + - name: "Error in ; list" + stdin: | + set -e + true ; false ; echo "should not print" + + - name: "Semicolon separated commands respect set -e" + stdin: | + set -e + echo "first" ; echo "second" ; false ; echo "should not print" + + # Background jobs + - name: "Error in background job" + stdin: | + set -e + false & + wait + echo "after wait" + + # Interaction with other options + - name: "set -e with set -o pipefail" + stdin: | + set -e + set -o pipefail + true | false + echo "should not print" + + - name: "set -e without pipefail allows pipeline errors" + stdin: | + set -e + true | false | true + echo "after pipeline" + + # Edge cases with test builtin + - name: "test builtin false result in conditional" + stdin: | + set -e + if test 1 -eq 2; then + echo "then" + else + echo "else" + fi + echo "after" + + - name: "test builtin false result standalone" + stdin: | + set -e + test 1 -eq 2 + echo "should not print" + + - name: "[ builtin false result in conditional" + stdin: | + set -e + if [ 1 -eq 2 ]; then + echo "then" + else + echo "else" + fi + echo "after" + + - name: "[ builtin false result standalone" + stdin: | + set -e + [ 1 -eq 2 ] + echo "should not print" + + - name: "trap ERR" + stdin: | + set -e + trap 'echo "error trapped"' ERR + false + echo "should not print" + + - name: "trap EXIT" + stdin: | + set -e + trap 'echo "exit trapped"' EXIT + false + echo "should not print" + + # Combination of contexts + - name: "Error in function in conditional" + stdin: | + set -e + myfunc() { + false + } + if myfunc; then + echo "then" + else + echo "else" + fi + echo "after" + + - name: "Negated function call" + stdin: | + set -e + myfunc() { + echo "in function" + false + } + ! myfunc + echo "after" + + - name: "Function in pipeline on internal error" + stdin: | + set -e + myfunc() { + false + } + myfunc | true + echo "after" + + - name: "Error after function in boolean context" + stdin: | + set -e + myfunc() { + false + } + myfunc || true + false + echo "should not print" + + # Arithmetic constructs with errexit + - name: "Arithmetic command (( )) standalone with false result" + stdin: | + set -e + (( 0 )) + echo "should not print" + + - name: "Arithmetic command (( )) standalone with true result" + stdin: | + set -e + (( 1 )) + echo "after arithmetic" + + - name: "Arithmetic command (( )) in if condition" + stdin: | + set -e + if (( 0 )); then + echo "then" + else + echo "else" + fi + echo "after" + + - name: "Arithmetic command (( )) with &&" + stdin: | + set -e + (( 0 )) && echo "matched" + echo "after" + + - name: "Arithmetic for loop condition false" + stdin: | + set -e + count=0 + for ((; count > 0 ;)); do + echo "body" + done + echo "after for loop" + + - name: "Arithmetic for loop with errexit in body" + stdin: | + set -e + for ((i=0; i<2; i++)); do + echo "iteration $i" + false + echo "should not print" + done + echo "should not print after loop" + + # Extended test [[ ]] with errexit + - name: "Extended test [[ ]] standalone with false result" + stdin: | + set -e + [[ 1 -eq 2 ]] + echo "should not print" + + - name: "Extended test [[ ]] standalone with true result" + stdin: | + set -e + [[ 1 -eq 1 ]] + echo "after test" + + - name: "Extended test [[ ]] in if condition" + stdin: | + set -e + if [[ 1 -eq 2 ]]; then + echo "then" + else + echo "else" + fi + echo "after" + + - name: "Extended test [[ ]] with &&" + stdin: | + set -e + [[ 1 -eq 2 ]] && echo "matched" + echo "after" + + - name: "Extended test [[ ]] with ||" + stdin: | + set -e + [[ 1 -eq 2 ]] || echo "not matched" + echo "after" + + # Brace groups with errexit + - name: "Brace group in pipeline with errexit" + stdin: | + set -e + { false; } | true + echo "after pipeline" + + - name: "Brace group in pipeline with pipefail" + stdin: | + set -e -o pipefail + true | { false; } + echo "should not print" + + - name: "Brace group with internal error and errexit" + stdin: | + set -e + { + echo "in brace" + false + echo "should not print in brace" + } + echo "should not print after brace" + + # Nested subshells with errexit + - name: "Nested subshells with errexit" + stdin: | + set -e + ( + ( + false + echo "inner should not print" + ) + echo "outer should print - inner exited but does not affect parent" + ) + echo "after subshells" + + - name: "Deeply nested subshells with errexit" + stdin: | + set -e + ( + ( + ( + false + echo "level 3 should not print" + ) + echo "level 2 should print" + ) + echo "level 1 should print" + ) + echo "after" + + # Functions calling functions with errexit + - name: "Functions calling functions with errexit" + stdin: | + set -e + inner() { false; echo "inner should not print"; } + outer() { inner; echo "outer should not print"; } + outer + echo "should not print" + + - name: "Nested function call in conditional" + stdin: | + set -e + inner() { false; } + outer() { if inner; then echo "then"; else echo "else"; fi; echo "after inner call"; } + outer + echo "after" + + # Complex combinations + - name: "if with && and pipeline" + stdin: | + set -e + if true && false | true; then + echo "then" + else + echo "else" + fi + echo "after" + + - name: "if with || and pipeline failure" + stdin: | + set -e + if false || true | false; then + echo "then" + else + echo "else" + fi + echo "after" + + - name: "Negated pipeline in conditional" + stdin: | + set -e + if ! false | false; then + echo "then" + else + echo "else" + fi + echo "after" + + - name: "Command substitution in argument with errexit" + stdin: | + set -e + echo "value: $(false; echo result)" + echo "after echo" + + # PIPESTATUS with pipefail + - name: "PIPESTATUS with pipefail and multiple failures" + stdin: | + set -o pipefail + (exit 2) | (exit 3) | (exit 0) + echo "Exit: $?; PIPESTATUS: ${PIPESTATUS[@]}" + + - name: "PIPESTATUS preserved correctly with pipefail" + stdin: | + set -o pipefail + (exit 0) | (exit 5) | (exit 0) + echo "Exit: $?; PIPESTATUS: ${PIPESTATUS[@]}" + + - name: "pipefail in conditional" + stdin: | + set -o pipefail + if false | true; then + echo "then" + else + echo "else" + fi + echo "Exit: $?" + + # eval with errexit + - name: "eval with failing command" + stdin: | + set -e + eval "false" + echo "should not print" + + - name: "eval with successful command" + stdin: | + set -e + eval "true" + echo "after eval" + + - name: "eval with && chain" + stdin: | + set -e + eval "false && true" + echo "after eval" + + - name: "eval with || chain" + stdin: | + set -e + eval "false || true" + echo "after eval" + + - name: "eval in conditional" + stdin: | + set -e + if eval "false"; then + echo "then" + else + echo "else" + fi + echo "after" + + - name: "eval with multiple commands" + stdin: | + set -e + eval "echo first; false; echo should not print" + echo "should not print after eval" + + # source/dot with errexit + - name: "source script with failing command" + stdin: | + set -e + echo 'echo "in sourced"; false; echo "should not print"' > /tmp/test_source_$$.sh + source /tmp/test_source_$$.sh + echo "should not print after source" + rm -f /tmp/test_source_$$.sh + + - name: "source script with successful commands" + stdin: | + set -e + echo 'echo "in sourced"; true' > /tmp/test_source_$$.sh + source /tmp/test_source_$$.sh + echo "after source" + rm -f /tmp/test_source_$$.sh + + - name: "dot operator with failing command" + stdin: | + set -e + echo 'false' > /tmp/test_dot_$$.sh + . /tmp/test_dot_$$.sh + echo "should not print" + rm -f /tmp/test_dot_$$.sh + + - name: "source in conditional" + stdin: | + set -e + echo 'false' > /tmp/test_source_$$.sh + if source /tmp/test_source_$$.sh; then + echo "then" + else + echo "else" + fi + echo "after" + rm -f /tmp/test_source_$$.sh + + # trap DEBUG interaction + - name: "trap DEBUG with errexit" + stdin: | + set -e + trap 'echo "DEBUG"' DEBUG + echo "first" + false + echo "should not print" + + - name: "trap DEBUG in conditional with errexit" + stdin: | + set -e + trap 'echo "DEBUG"' DEBUG + if false; then + echo "then" + else + echo "else" + fi + echo "after" + + # BASH_COMMAND with errexit + - name: "BASH_COMMAND set during errexit" + stdin: | + set -e + trap 'echo "failed: $BASH_COMMAND"' EXIT + false + echo "should not print" + + - name: "BASH_COMMAND in function with errexit" + stdin: | + set -e + trap 'echo "failed: $BASH_COMMAND"' EXIT + myfunc() { false; } + myfunc + echo "should not print" + + # Arithmetic expansion failures + - name: "Division by zero in arithmetic expansion" + ignore_stderr: true + stdin: | + set -e + echo "before" + result=$((1/0)) + echo "should not print" + + - name: "Division by zero in (( ))" + ignore_stderr: true + known_failure: true # TODO(arithmetic): Division by zero should trigger errexit + stdin: | + set -e + echo "before" + (( 1/0 )) + echo "should not print" + + - name: "Division by variable zero" + ignore_stderr: true + stdin: | + set -e + x=0 + result=$((1/x)) + echo "should not print" + + # Multiple assignments with one failure + - name: "Multiple assignments with middle failure" + stdin: | + set -e + a=$(echo "a") b=$(false) c=$(echo "c") + echo "a=$a b=$b c=$c" + echo "after" + + - name: "Multiple assignments all succeed" + stdin: | + set -e + a=$(echo "a") b=$(echo "b") c=$(echo "c") + echo "a=$a b=$b c=$c" + echo "after" + + - name: "Multiple assignments first fails" + stdin: | + set -e + a=$(false) b=$(echo "b") c=$(echo "c") + echo "a=$a b=$b c=$c" + echo "after" + + # time keyword with errexit + - name: "time with failing command" + ignore_stderr: true + stdin: | + set -e + time false + echo "should not print" + + - name: "time with successful command" + ignore_stderr: true + stdin: | + set -e + time true + echo "after time" + + - name: "time in conditional" + ignore_stderr: true + stdin: | + set -e + if time false; then + echo "then" + else + echo "else" + fi + echo "after" + + - name: "time with pipeline" + ignore_stderr: true + stdin: | + set -e + time true | false + echo "after" + + # Pattern matching failures + - name: "Extended test with valid regex" + stdin: | + set -e + var="hello" + if [[ $var =~ ^h ]]; then + echo "matched" + else + echo "no match" + fi + echo "after" + + - name: "Extended test regex no match" + stdin: | + set -e + var="hello" + [[ $var =~ ^x ]] || echo "no match" + echo "after" + + # Extglob in case patterns + - name: "case with extglob pattern" + stdin: | + set -e + shopt -s extglob + case "abc" in + a*(b)c) echo "matched" ;; + *) echo "no match" ;; + esac + echo "after" + + - name: "case with extglob no match" + stdin: | + set -e + shopt -s extglob + case "xyz" in + a*(b)c) echo "matched" ;; + esac + echo "after" + + # Signal handler during errexit (limited - can't easily test INT) + - name: "EXIT trap runs before errexit terminates" + stdin: | + set -e + trap 'echo "EXIT trap: $?"' EXIT + false + echo "should not print" + + - name: "Multiple traps with errexit" + stdin: | + set -e + trap 'echo "EXIT: $?"' EXIT + trap 'echo "ERR: $?"' ERR + false + echo "should not print" + + # Coproc with errexit + - name: "coproc with failing command" + known_failure: true # TODO(wait): wait with job specs not implemented + stdin: | + set -e + coproc { exit 5; } + wait $COPROC_PID + echo "after coproc wait, status: $?" + + - name: "coproc with successful command" + known_failure: true # TODO(wait): wait with job specs not implemented + stdin: | + set -e + coproc { echo "from coproc"; } + cat <&${COPROC[0]} + wait $COPROC_PID + echo "after coproc" + + - name: "coproc in conditional" + known_failure: true # TODO(wait): wait with job specs not implemented + stdin: | + set -e + coproc { exit 1; } + if wait $COPROC_PID; then + echo "then" + else + echo "else" + fi + echo "after" + + - name: "coproc with name and brace group" + stdin: | + f() { coproc MYCOPROC { echo hello; }; } + f + + # Additional edge cases + - name: "Subshell in assignment with errexit" + known_failure: true # TODO(arithmetic): $(( exit 5 )) is parsed differently from bash + stdin: | + set -e + var=$(( exit 5 )) + echo "should not print" + + - name: "Arithmetic in condition of && chain" + stdin: | + set -e + (( 0 )) && echo "matched" || echo "not matched" + echo "after" + + - name: "Nested eval with errexit" + stdin: | + set -e + eval 'eval "false"' + echo "should not print" + + - name: "Command group with errexit" + stdin: | + set -e + { echo "first"; false; echo "should not print"; } + echo "should not print after group" + + - name: "Empty command with errexit" + stdin: | + set -e + : + echo "after colon" + + - name: "true command with errexit" + stdin: | + set -e + true + echo "after true" + + - name: "break in errexit context" + stdin: | + set -e + for i in 1 2 3; do + if [ $i -eq 2 ]; then + break + fi + echo "i=$i" + done + echo "after loop" + + - name: "continue in errexit context" + stdin: | + set -e + for i in 1 2 3; do + if [ $i -eq 2 ]; then + continue + fi + echo "i=$i" + done + echo "after loop" + + - name: "Complex nested structure" + stdin: | + set -e + outer() { + inner() { + if false; then + echo "inner then" + else + echo "inner else" + fi + false + echo "should not print in inner" + } + inner + echo "should not print in outer" + } + outer + echo "should not print" + + - name: "with -u" + ignore_stderr: true + stdin: | + set -eu + echo "before" + x=$unset + echo "after" + + - name: "with -x and if/else ending in assignment" + stdin: | + set -ex + if false; then + E=then + else + E=else + fi + echo "after ${E}" + + - name: "with -x and assignment after exempted failing command" + stdin: | + set -ex + false || E=value + echo "after ${E}" + + - name: "syntax error" + ignore_stderr: true + stdin: | + set -e + echo "before" + if false; then echo hi; done + echo "after" diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/options/set-n.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/options/set-n.yaml new file mode 100644 index 0000000000..f9c973ed00 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/options/set-n.yaml @@ -0,0 +1,7 @@ +name: "set -n" +cases: + - name: "set -n" + stdin: | + set -n + touch somefile.txt + ls diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/options/set-t.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/options/set-t.yaml new file mode 100644 index 0000000000..16411bd532 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/options/set-t.yaml @@ -0,0 +1,7 @@ +name: "set -t" +cases: + - name: "set -t" + args: ["-t"] + stdin: | + echo first + echo second diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/options/set-u.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/options/set-u.yaml new file mode 100644 index 0000000000..42e64e5fb5 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/options/set-u.yaml @@ -0,0 +1,534 @@ +name: "Options: set -u" +cases: + # Basic behavior + - name: "Basic set variable access" + stdin: | + set -u + var="value" + echo "$var" + + - name: "Unset variable triggers error" + ignore_stderr: true + stdin: | + set -u + echo "$unset_var" + echo "should not print" + + - name: "Command line -u" + args: ["-u"] + ignore_stderr: true + stdin: | + echo "$unset_var" + echo "should not print" + + # Special parameters - should NOT error even when unset + - name: "Special parameter $@ does not error" + stdin: | + set -u + echo "args: $@" + + - name: "Special parameter $* does not error" + stdin: | + set -u + echo "args: $*" + + - name: "Special parameter $# does not error" + stdin: | + set -u + echo "count: $#" + + - name: "Special parameter $? does not error" + stdin: | + set -u + true + echo "status: $?" + + - name: "Special parameter $$ does not error" + stdin: | + set -u + echo "pid: $$" > /dev/null + echo "ok" + + - name: "Special parameter $! does not error when no background jobs" + known_failure: true + ignore_stderr: true + stdin: | + set -u + echo "last bg: ${!}" || echo "ok" + + - name: "Special parameter $0 does not error" + stdin: | + set -u + echo "shell: $0" > /dev/null + echo "ok" + + - name: "Special parameter $- does not error" + stdin: | + set -u + echo "flags: $-" > /dev/null + echo "ok" + + # Positional parameters + - name: "Unset positional parameter triggers error" + ignore_stderr: true + stdin: | + set -u + myfunc() { + echo "$1" + } + myfunc + echo "should not print" + + - name: "Set positional parameter does not error" + stdin: | + set -u + myfunc() { + echo "$1" + } + myfunc "arg" + + - name: "Positional parameters beyond provided args error" + ignore_stderr: true + stdin: | + set -u + myfunc() { + echo "$2" + } + myfunc "only_one_arg" + echo "should not print" + + # Parameter expansion forms + - name: "Unset variable with default does not error" + stdin: | + set -u + echo "${unset_var:-default}" + + - name: "Empty vs unset with :- operator" + stdin: | + set -u + empty="" + echo "${empty:-default}" + echo "${unset:-default}" + + - name: "Empty vs unset with - operator" + stdin: | + set -u + empty="" + echo "${empty-default}" + echo "${unset-default}" + + # Arrays (if supported) + - name: "Unset array element with default" + stdin: | + set -u + arr=(a b c) + echo "${arr[10]:-default}" + + - name: "Unset array treated as unset variable" + stdin: | + set -u + echo "${unset_array[@]}" + echo "should not print" + + - name: "Unset array with default does not error" + stdin: | + set -u + echo "${unset_array[@]:-default}" + + # Conditionals + - name: "Unset variable in conditional test errors" + ignore_stderr: true + stdin: | + set -u + if [ "$unset_var" = "value" ]; then + echo "then" + fi + echo "should not print" + + - name: "Unset variable in [[ test errors" + ignore_stderr: true + stdin: | + set -u + if [[ $unset_var = "value" ]]; then + echo "then" + fi + echo "should not print" + + - name: "Unset variable with default in conditional works" + stdin: | + set -u + if [ "${unset_var:-}" = "" ]; then + echo "empty" + fi + + - name: "test -v for unset variable" + stdin: | + set -u + if test -v unset_var; then + echo "set" + else + echo "unset" + fi + + - name: "test -z with unset variable errors" + ignore_stderr: true + stdin: | + set -u + if test -z "$unset_var"; then + echo "empty" + fi + echo "should not print" + + # Command substitution + - name: "Unset variable in command substitution errors" + ignore_stderr: true + stdin: | + set -u + result=$(echo "$unset_var") + echo "should not print" + + - name: "Unset variable with default in command substitution" + stdin: | + set -u + result=$(echo "${unset_var:-default}") + echo "$result" + + # Arithmetic expansion + - name: "Unset variable in arithmetic defaults to zero" + ignore_stderr: true + stdin: | + set -u + result=$((unset_var + 5)) + echo "$result" + + # set +u to disable + - name: "set +u disables unset variable check" + stdin: | + set -u + set +u + echo "$unset_var" + echo "ok" + + - name: "set -u can be re-enabled" + ignore_stderr: true + stdin: | + set -u + set +u + echo "$unset_var" + set -u + echo "$another_unset" + echo "should not print" + + - name: "set +u in function does not affect parent" + stdin: | + set -u + myfunc() { + set +u + echo "$unset_in_func" + } + myfunc + echo "$unset_in_parent" + echo "should not print" + + # Functions + - name: "Unset variable in function body errors" + ignore_stderr: true + stdin: | + set -u + myfunc() { + echo "$unset_var" + } + myfunc + echo "should not print" + + - name: "Local unset variable errors" + ignore_stderr: true + stdin: | + set -u + myfunc() { + local unset_local + echo "$unset_local" + } + myfunc + echo "should not print" + + - name: "Local variable declaration with value works" + stdin: | + set -u + myfunc() { + local my_var="value" + echo "$my_var" + } + myfunc + + - name: "Function parameter shadows unset global" + stdin: | + set -u + myfunc() { + local var="$1" + echo "$var" + } + myfunc "arg" + + # Subshells + - name: "Unset variable in subshell errors" + ignore_stderr: true + stdin: | + set -u + (echo "$unset_var") + echo "should not print" + + - name: "set +u in subshell does not affect parent" + ignore_stderr: true + stdin: | + set -u + ( + set +u + echo "$unset_in_subshell" + ) + echo "$unset_in_parent" + echo "should not print" + + # Variable assignment and unset + - name: "Variable set then unset errors on access" + ignore_stderr: true + stdin: | + set -u + var="value" + echo "$var" + unset var + echo "$var" + echo "should not print" + + - name: "Variable set then unset with default works" + stdin: | + set -u + var="value" + unset var + echo "${var:-default}" + + - name: "Unsetting non-existent variable does not error" + stdin: | + set -u + unset non_existent + echo "ok" + + # Export and environment + - name: "Exported unset variable errors" + ignore_stderr: true + stdin: | + set -u + export unset_exported + echo "$unset_exported" + echo "should not print" + + - name: "Exported variable with value works" + stdin: | + set -u + export exported_var="value" + echo "$exported_var" + + # Pattern expansion + - name: "Unset variable in pattern errors" + ignore_stderr: true + stdin: | + set -u + echo ${unset_var#prefix} + echo "should not print" + + # Length operator + - name: "Length of unset variable errors" + ignore_stderr: true + stdin: | + set -u + echo ${#unset_var} + echo "should not print" + + - name: "Length of empty variable works" + stdin: | + set -u + empty="" + echo ${#empty} + + # Indirect expansion + - name: "Indirect expansion of unset variable errors" + ignore_stderr: true + stdin: | + set -u + ref="unset_target" + echo ${!ref} + echo "should not print" + + - name: "Indirect expansion to set variable works" + stdin: | + set -u + target="value" + ref="target" + echo ${!ref} + + # Case statements + - name: "Unset variable in case pattern errors" + ignore_stderr: true + stdin: | + set -u + case "$unset_var" in + *) echo "matched" ;; + esac + echo "should not print" + + - name: "Unset variable with default in case works" + stdin: | + set -u + case "${unset_var:-default}" in + default) echo "matched" ;; + esac + + # For loops + - name: "Unset variable in for loop errors" + ignore_stderr: true + stdin: | + set -u + for item in $unset_var; do + echo "$item" + done + echo "should not print" + + - name: "Unset variable with default in for loop works" + stdin: | + set -u + for item in ${unset_var:-a b c}; do + echo "$item" + done + + # While loops with unset variables + - name: "Unset variable in while condition errors" + ignore_stderr: true + stdin: | + set -u + while [ "$unset_var" != "stop" ]; do + echo "loop" + break + done + echo "should not print" + + # Read command + - name: "read into variable then access works" + stdin: | + set -u + echo "value" | read my_var || true + echo "${my_var:-default}" + + - name: "Variable unset before read then set works" + stdin: | + set -u + echo "test" | { + read input + echo "$input" + } + + # Compound assignments + - name: "Associative array unset key with default" + stdin: | + set -u + declare -A assoc + echo "${assoc[key]:-default}" + + # Pipelines + - name: "Unset variable with default in pipeline works" + stdin: | + set -u + echo "${unset_var:-default}" | cat + + # Here documents and here strings + - name: "Unset variable in here doc errors" + ignore_stderr: true + stdin: | + set -u + cat </dev/null + echo "exit=$?" + + - name: "failglob does not affect matching glob" + stdin: | + touch realfile.txt + shopt -s failglob + echo real*.txt + + - name: "failglob does not affect quoted glob chars" + stdin: | + shopt -s failglob + echo 'non-existent-*.txt' + + - name: "failglob with negation" + ignore_stderr: true + stdin: | + shopt -s failglob + ! echo non-existent-*.txt 2>/dev/null + echo "exit=$?" + + - name: "failglob in subshell" + ignore_stderr: true + stdin: | + shopt -s failglob + (echo non-existent-*.txt; echo "inner=$?") + echo "outer=$?" + + - name: "failglob lone ] is not a glob" + stdin: | + shopt -s failglob + echo foo] + + - name: "failglob lone [ is not a glob" + stdin: | + shopt -s failglob + echo [abc + + - name: "failglob aborts compound list" + ignore_stderr: true + stdin: | + shopt -s failglob + echo *.nomatch; echo "after" + + - name: "failglob in multi-member pipeline" + ignore_stderr: true + stdin: | + shopt -s failglob + echo *.nomatch | cat + echo "after" + + - name: "failglob extglob pattern" + ignore_stderr: true + stdin: | + shopt -s failglob + shopt -s extglob + echo @(nomatch_pattern_xyz) + echo "after" + + - name: "failglob takes precedence over nullglob" + ignore_stderr: true + stdin: | + shopt -s failglob nullglob + echo non-existent-*.txt 2>/dev/null + echo "exit=$?" + + - name: "failglob on unquoted variable with glob chars" + ignore_stderr: true + stdin: | + shopt -s failglob + x='*.nomatch_xyz' + echo $x 2>/dev/null + echo "exit=$?" diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/options/shopt-lastpipe.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/options/shopt-lastpipe.yaml new file mode 100644 index 0000000000..9018c3350d --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/options/shopt-lastpipe.yaml @@ -0,0 +1,11 @@ +name: "shopt lastpipe" +cases: + - name: "shopt -s lastpipe" + stdin: | + echo ignored | read var + echo "1. var='${var}'" + + shopt -s lastpipe + set +o monitor + echo ignored | read var + echo "2. var='${var}'" diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/options/shopt-login_shell.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/options/shopt-login_shell.yaml new file mode 100644 index 0000000000..41ec830779 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/options/shopt-login_shell.yaml @@ -0,0 +1,12 @@ +name: "shopt login_shell" +cases: + - name: "login_shell is on for login shells" + args: + - "--login" + - "-c" + - "shopt -q login_shell" + + - name: "login_shell is off for non-login shells" + args: + - "-c" + - "shopt -q login_shell" diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/options/shopt-nullglob.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/options/shopt-nullglob.yaml new file mode 100644 index 0000000000..6b0e70f852 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/options/shopt-nullglob.yaml @@ -0,0 +1,79 @@ +name: "Options: nullglob" +cases: + - name: "nullglob removes unmatched glob" + stdin: | + shopt -s nullglob + echo "result:" non-existent-*.txt + + - name: "nullglob does not affect matching glob" + stdin: | + touch realfile.txt + shopt -s nullglob + echo real*.txt + + - name: "nullglob does not affect quoted glob chars" + stdin: | + shopt -s nullglob + echo 'non-existent-*.txt' + + - name: "nullglob does not affect literal text without glob chars" + stdin: | + shopt -s nullglob + echo hello world + + - name: "nullglob with multiple unmatched patterns" + stdin: | + shopt -s nullglob + echo "result:" *.zzz *.yyy *.xxx + + - name: "nullglob with mix of matched and unmatched" + stdin: | + touch realfile.txt + shopt -s nullglob + echo real*.txt *.zzz + + - name: "nullglob with question mark glob" + stdin: | + shopt -s nullglob + echo "result:" ?.nonexistent + + - name: "nullglob with bracket glob" + stdin: | + shopt -s nullglob + echo "result:" [abc].nonexistent + + - name: "nullglob disabled by default" + stdin: | + echo *.nonexistent + + - name: "nullglob can be toggled" + stdin: | + shopt -s nullglob + echo "on:" *.nonexistent + shopt -u nullglob + echo "off:" *.nonexistent + + - name: "nullglob in subshell" + stdin: | + shopt -s nullglob + (echo "inner:" *.nonexistent) + echo "outer:" *.nonexistent + + - name: "nullglob with for loop" + stdin: | + shopt -s nullglob + for f in *.nonexistent; do + echo "unexpected: $f" + done + echo "done" + + - name: "nullglob with array assignment" + stdin: | + shopt -s nullglob + arr=(*.nonexistent) + echo "count: ${#arr[@]}" + + - name: "nullglob with directory glob" + stdin: | + shopt -s nullglob + echo "result:" nonexistent_dir/*/ diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/patterns/filename_expansion.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/patterns/filename_expansion.yaml new file mode 100644 index 0000000000..577aef88f5 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/patterns/filename_expansion.yaml @@ -0,0 +1,377 @@ +name: "Filename expansion" +cases: + - name: "Single file expansion" + stdin: "echo file1.txt" + + - name: "Expansion with noglob" + test_files: + - path: "file1.txt" + - path: "file2.txt" + stdin: | + set -f + echo "*.txt:" *.txt + + - name: "Multiple file expansion" + test_files: + - path: "file1.txt" + - path: "file2.txt" + stdin: | + echo *.txt + echo *."txt" + + - name: "File expansion with nocaseglob" + test_files: + - path: "FILE1.TXT" + - path: "file2.txt" + stdin: | + shopt -s nocaseglob + echo "*.txt:" *.txt + echo "*.TXT:" *.txt + + - name: "Nested directory expansion" + test_files: + - path: "dir/file1.txt" + stdin: "echo dir/*" + + - name: "Multiple level directory expansion" + test_files: + - path: "dir/subdir/file1.txt" + stdin: "echo dir/subdir/*.txt" + + - name: "Expansion with no matches" + test_files: + - path: "file1.txt" + stdin: "echo *.jpg" + + - name: "Expansion with no matches + nullglob" + test_files: + - path: "file1.txt" + stdin: | + shopt -s nullglob + echo "*.jpg:" *.jpg + + - name: "Expansion with matches and non-matches in directory" + test_files: + - path: "dir/file.txt" + - path: "dir/file.jpg" + stdin: "echo dir/*.jpg" + + - name: "Expansion with matches and non-matches in directory + nullglob" + test_files: + - path: "dir/file.txt" + - path: "dir/file.jpg" + stdin: | + shopt -s nullglob + echo "dir/*.jpg:" dir/*.jpg + + - name: "Expansion with special characters" + test_files: + - path: "file1.txt" + - path: "file2.txt" + stdin: "echo file?.txt" + + - name: "Expansion with brackets" + test_files: + - path: "file1.txt" + - path: "file2.txt" + stdin: "echo file[12].txt" + + - name: "Expansion with range" + test_files: + - path: "file1.txt" + - path: "file2.txt" + stdin: "echo file[1-2].txt" + + - name: "Expansion with negation" + test_files: + - path: "file1.txt" + - path: "file2.txt" + stdin: "echo file[!2].txt" + + - name: "Expansion with tilde" + env: + HOME: /some/dir + stdin: | + echo ~/file1.txt + echo ~/file1.txt:~/file1.txt + + - name: "Expansion with dots" + stdin: | + echo ./file1.txt + echo ../file1.txt + + - name: "Expansion with mixed patterns" + test_files: + - path: "dir/subdir/file1.txt" + stdin: "echo dir/*/file?.txt" + + - name: "Expansion with escaped characters" + test_files: + - path: "file*.txt" + stdin: "echo file\\*.txt" + + - name: "Pathname expansion: extglob disabled" + ignore_stderr: true + test_files: + - path: "ab.txt" + - path: "abc.txt" + - path: "abd.txt" + - path: "def.txt" + - path: "abadac.txt" + - path: "fabadac.txt" + - path: "f.txt" + - path: "script.sh" + contents: | + echo !(a*) + echo "result: $?" + + echo @(abc|abd).txt + echo "result: $?" + + echo ab?(c).txt + echo "result: $?" + + echo *(ab|ad|ac).txt + echo "result: $?" + + echo f+(ab|ad|ac).txt + echo "result: $?" + stdin: | + shopt -u extglob + chmod +x ./script.sh + ./script.sh + + - name: "Pathname expansion: Inverted patterns" + ignore_stderr: true + test_files: + - path: "abc.txt" + - path: "abd.txt" + - path: "def.txt" + stdin: | + shopt -s extglob + echo "1: " !(a*) + echo "2: " !(abc.txt) + echo "3: " !(abc) + echo "4: " !(*) + + - name: "Pathname expansion: Degenerate inverted pattern" + test_files: + - path: "abc.txt" + - path: "abd.txt" + - path: "def.txt" + stdin: | + shopt -s extglob + echo !() + + - name: "Pathname expansion: Extended patterns" + ignore_stderr: true + test_files: + - path: "abc.txt" + - path: "abd.txt" + stdin: | + shopt -s extglob + echo "1: " @(abc|abd).txt + echo "2: " @(abc.txt) + echo "3: " @(abc) + echo "4: " @(|abc.txt) + + - name: "Pathname expansion: Optional patterns" + ignore_stderr: true + test_files: + - path: "ab.txt" + - path: "abc.txt" + stdin: | + shopt -s extglob + echo ab?(c).txt + + - name: "Pathname expansion: Star patterns" + ignore_stderr: true + test_files: + - path: "abadac.txt" + - path: "ab.txt" + stdin: | + shopt -s extglob + echo *(ab|ad|ac).txt + + - name: "Pathname expansion: Plus patterns" + ignore_stderr: true + test_files: + - path: "fabadac.txt" + - path: "f.txt" + stdin: | + shopt -s extglob + echo f+(ab|ad|ac).txt + + - name: "Pathname expansion: quoting" + test_files: + - path: test.txt + - path: subdir/test.txt + stdin: | + echo "./test"*.txt + echo "subdir"/*.txt + echo "test.*" + + - name: "Pathname expansion: dot files (no dotglob)" + min_oracle_version: 5.2 + stdin: | + touch .file + touch .dir + + shopt -u dotglob + echo "* : " * + echo "*i* : " *i* + echo "./* : " ./* + echo ".* : " .* + echo "./.*: " ./.* + + - name: "Glob results are sorted" + test_files: + - path: "cherry.txt" + - path: "apple.txt" + - path: "banana.txt" + stdin: | + echo *.txt + + - name: "Multiple globs on one command line" + test_files: + - path: "a.txt" + - path: "b.txt" + - path: "x.log" + - path: "y.log" + stdin: | + echo *.txt *.log + + - name: "Glob does not expand in variable assignment" + test_files: + - path: "file1.txt" + - path: "file2.txt" + stdin: | + x=*.txt + echo "$x" + + - name: "Glob expands in unquoted variable use" + test_files: + - path: "file1.txt" + - path: "file2.txt" + stdin: | + x=*.txt + echo $x + + - name: "Glob with trailing slash matches directories" + test_files: + - path: "mydir/file.txt" + - path: "myfile.txt" + stdin: | + echo */ + + - name: "Trailing path filters glob results" + test_files: + - path: "README.md" + - path: "photos/photo.png" + - path: "a/apple.txt" + - path: "b/banana.txt" + stdin: | + echo */banana.txt + echo */*.txt + + - name: "Trailing literal keeps dangling symlinks but drops missing" + test_files: + - path: "a/real.txt" + - path: "b/real.txt" + - path: "c/other.txt" + stdin: | + # a/link.txt is a *dangling* symlink: bash's glob matches it (directory-entry + # existence, via lstat), so brush must too. b and c have no link.txt and must + # be dropped. + ln -s nonexistent-target a/link.txt + echo "link.txt:" */link.txt + echo "real.txt:" */real.txt + + - name: "Trailing literal with directory symlinks" + test_files: + - path: "realdir/file.txt" + stdin: | + # A symlink to a directory should be traversed like a real directory. + ln -s realdir linkdir + ln -s nonexistent danglingdir + echo "*/file.txt:" */file.txt + + - name: "Globstar disabled by default" + test_files: + - path: "a/b/c.txt" + stdin: | + shopt -u globstar + echo **/c.txt + + - name: "Globstar recursive matching" + known_failure: true + test_files: + - path: "a/b/c.txt" + - path: "a/d.txt" + - path: "e.txt" + stdin: | + shopt -s globstar + echo **/*.txt + + - name: "GLOBIGNORE filters glob results" + known_failure: true + test_files: + - path: "a.txt" + - path: "b.txt" + - path: "c.txt" + stdin: | + GLOBIGNORE="b.txt" + echo *.txt + + - name: "GLOBIGNORE with multiple patterns" + known_failure: true + test_files: + - path: "a.txt" + - path: "b.txt" + - path: "c.txt" + - path: "d.log" + stdin: | + GLOBIGNORE="b.txt:c.txt" + echo *.txt + echo *.log + + - name: "GLOBIGNORE implicitly enables dotglob" + known_failure: true + min_oracle_version: 5.2 + test_files: + - path: "a.txt" + - path: ".hidden.txt" + stdin: | + GLOBIGNORE="a.txt" + echo *.txt + + - name: "Unsetting GLOBIGNORE restores behavior" + known_failure: true + test_files: + - path: "a.txt" + - path: "b.txt" + stdin: | + GLOBIGNORE="a.txt" + echo *.txt + unset GLOBIGNORE + echo *.txt + + - name: "Star does not match slash in path" + test_files: + - path: "dir/file.txt" + - path: "top.txt" + stdin: | + echo *.txt + + - name: "Pathname expansion: dot files (with dotglob)" + min_oracle_version: 5.2 + stdin: | + touch .file + touch .dir + + shopt -s dotglob + echo "* : " * + echo "*i* : " *i* + echo "./* : " ./* + echo ".* : " .* + echo "./.*: " ./.* diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/patterns/patterns.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/patterns/patterns.yaml new file mode 100644 index 0000000000..01a21284e9 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/patterns/patterns.yaml @@ -0,0 +1,174 @@ +name: "Patterns" +cases: + - name: "Expansion with escaped characters" + test_files: + - path: "file*.txt" + stdin: "echo file\\*.txt" + + - name: "Basic pattern matching" + stdin: | + test_pattern() { + local str="$1" + local pattern="$2" + + if [[ "${str}" == ${pattern} ]]; then + echo "Matched: ${str} using ${pattern}" + else + echo "No match: ${str} using ${pattern}" + fi + } + + test_pattern "abc" "a*" + test_pattern "abc" "b*" + + test_pattern "?bc" "abc" + test_pattern "?bc" "aabc" + + test_pattern "ac" "[ab]c" + test_pattern "bc" "[ab]c" + test_pattern "cc" "[ab]c" + + test_pattern "ad" "[a-c]d" + test_pattern "bd" "[a-c]d" + test_pattern "dd" "[a-c]d" + + test_pattern "1" "[[:alpha:]]" + test_pattern "a" "[[:alpha:]]" + + test_pattern "a/b" "*b" + test_pattern "a/b" "a/b" + test_pattern "a/b" "*/*" + + - name: "Extglob pattern matching" + stdin: | + shopt -s extglob + + test_pattern() { + local str="$1" + local pattern="$2" + + if [[ "${str}" == ${pattern} ]]; then + echo "Matched: ${str} using ${pattern}" + else + echo "No match: ${str} using ${pattern}" + fi + } + + test_pattern "aabc" "!(a*)" + test_pattern "abc" "!(a*)" + test_pattern "def" "!(a*)" + + test_pattern "a.foo.tar.gz" "a.!(foo|bar).tar.gz" + test_pattern "a.bar.tar.gz" "a.!(foo|bar).tar.gz" + test_pattern "a.baz.tar.gz" "a.!(foo|bar).tar.gz" + test_pattern "a.tar.tar.gz" "a.!(foo|bar).tar.gz" + test_pattern "a..tar.gz" "a.!(foo|bar).tar.gz" + test_pattern "a.tar.gz" "a.!(foo|bar).tar.gz" + + test_pattern "abc" "@(abc|def)" + test_pattern "def" "@(abc|def)" + test_pattern "ghi" "@(abc|def)" + + test_pattern "abc" "ab?(c)" + test_pattern "ab" "ab?(c)" + test_pattern "abd" "ab?(c)" + + test_pattern "" "*(ab|ac)" + test_pattern "ab" "*(ab|ac)" + test_pattern "abab" "*(ab|ac)" + test_pattern "ad" "*(ab|ac)" + + test_pattern "" "+(ab|ac)" + test_pattern "ab" "+(ab|ac)" + test_pattern "abab" "+(ab|ac)" + test_pattern "ad" "+(ab|ac)" + + - name: "Extglob with escaping" + stdin: | + shopt -s extglob + + [[ \( == +(\() ]] && echo "1. Matched" + [[ x == +(\() ]] && echo "2. Matched" + [[ \) == +(\)) ]] && echo "3. Matched" + [[ x == +(\)) ]] && echo "4. Matched" + + - name: "Patterns: quoting" + stdin: | + [[ "abc" == "a"* ]] && echo "1. Matched" + [[ "abc" == a"*" ]] && echo "2. Matched" + [[ "abc" == "a*" ]] && echo "3. Matched" + + - name: "Patterns: escaped special characters" + stdin: | + myfunc() { + if [[ $1 == \\* ]]; then + echo "Matched: '$1'" + else + echo "Did *not* match: '$1'" + fi + } + + myfunc abc + myfunc "*" + + - name: "Pattern matching: character ranges" + stdin: | + [[ "x" == [a-z] ]] && echo "1. Matched" + [[ "x" == [0-9] ]] && echo "2. Matched" + [[ "-" == [---] ]] && echo "3. Matched" + [[ "*" == [\*-\*] ]] && echo "4. Matched" + [[ "-" == [\*-\*] ]] && echo "5. Matched" + [[ "." == [\*-/] ]] && echo "6. Matched" + + - name: "Pattern matching: invalid character ranges" + stdin: | + [[ "a" == [f-a] ]] && echo "1. Matched" + [[ "b" == [f-a] ]] && echo "2. Matched" + [[ "f" == [f-a] ]] && echo "3. Matched" + [[ "g" == [f-a] ]] && echo "4. Matched" + + - name: "Pattern matching: escaped hyphen in bracket" + stdin: | + [[ "-" == [a\-\*] ]] && echo "1. Matched" + [[ "*" == [a\-\*] ]] && echo "2. Matched" + [[ "a" == [a\-\*] ]] && echo "3. Matched" + [[ "b" == [a\-\*] ]] && echo "4. Matched" + [[ "a" == [a\-f] ]] && echo "5. Matched" + [[ "b" == [a\-f] ]] && echo "6. Matched" + [[ "f" == [a\-f] ]] && echo "7. Matched" + + - name: "Pattern matching: character sets" + stdin: | + [[ "x" == [abc] ]] && echo "1. Matched" + [[ "x" == [xyz] ]] && echo "2. Matched" + [[ "x" == [^xyz] ]] && echo "3. Matched" + [[ "x" == [!xyz] ]] && echo "4. Matched" + [[ "(" == [\(] ]] && echo "5. Matched" + [[ "+" == [+-] ]] && echo "6. Matched" + [[ "[" == [\[] ]] && echo "7. Matched" + [[ "]" == [\]] ]] && echo "8. Matched" + [[ "^" == [\^] ]] && echo "9. Matched" + + - name: "Pattern matching: character classes" + stdin: | + [[ "1" == [[:digit:]] ]] && echo "1. Matched" + [[ "1" == [[:alpha:]] ]] && echo "2. Matched" + + - name: "Pattern matching: case sensitivity" + stdin: | + shopt -u nocasematch + [[ "abc" == "ABC" ]] && echo "1. Matched" + [[ "abc" == "[A-Z]BC" ]] && echo "2. Matched" + + shopt -s nocasematch + [[ "abc" == "ABC" ]] && echo "3. Matched" + [[ "abc" == "[A-Z]BC" ]] && echo "4. Matched" + + - name: "Pattern matching: stars in negative extglobs" + stdin: | + shopt -s extglob + [[ 'd' == !(*d)d ]] && echo "1. Matched" + + - name: "Pattern matching: quotes in character class" + stdin: | + [[ f == ['f'] ]] && echo "1. Matched" diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/pipeline.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/pipeline.yaml new file mode 100644 index 0000000000..b7a4f555c2 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/pipeline.yaml @@ -0,0 +1,60 @@ +name: "Pipeline" +cases: + - name: "Basic pipe" + stdin: | + echo hi | grep -l h + + - name: "Longer pipe" + stdin: | + echo hi | grep h | wc -l + + - name: "Invert result" + stdin: | + ! false + echo "! false: $?" + ! true + echo "! true: $?" + + - name: "Standalone negation (no command)" + stdin: | + ! + echo "After !: $?" + ! ! + echo "After ! !: $?" + + - name: "Exit codes for piped commands" + test_files: + - path: "script.sh" + executable: true + contents: | + #!/bin/sh + (cat; echo -n "-> $1") + exit $1 + stdin: | + ./script.sh 10 | ./script.sh 0 | ./script.sh 33 + + - name: "Side effects in pipe commands" + stdin: | + var=0 + echo "var: ${var}" + { var=1; } + echo "var: ${var}" + { var=2; echo hi; } | cat + echo "var: ${var}" + echo hi | { var=3; cat; } + echo "var: ${var}" + + - name: "pipeline extension" + stdin: | + echo -e "hello" |& wc -l + cat dfdfgdfgdf |& wc -l + foo() { cat dfgdfg; } |& wc -l + + - name: "Possible broken pipe case" + stdin: | + echo hello | var=value + + - name: "printf broken pipe returns 141 in PIPESTATUS" + stdin: | + printf '%s\n' {0..10000} | x=1 + echo "Last: $?, PIPESTATUS: ${PIPESTATUS[*]}" diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/process.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/process.yaml new file mode 100644 index 0000000000..c09868c819 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/process.yaml @@ -0,0 +1,17 @@ +name: "Process" +common_test_files: + - path: "process-helpers.sh" + source_path: "../../utils/process-helpers.sh" + +cases: + - name: "Basic process" + stdin: | + # TODO(jobs): Figure out how to make this work elsewhere + if [[ "$(uname)" != "Linux" ]]; then + echo "Skipping test on non-Linux platform" + exit 0 + fi + + source process-helpers.sh + echo "pid != ppid: $(( $(get-pid) != $(get-ppid) ))" + echo "pid == pgrp: $(( $(get-pid) != $(get-pgrp) ))" diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/prompt.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/prompt.yaml new file mode 100644 index 0000000000..6afe0605d1 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/prompt.yaml @@ -0,0 +1,335 @@ +name: "Prompt" +cases: + - name: "Simple prompts" + stdin: | + prompt='$ ' + echo "Prompt: '${prompt@P}'" + + prompt='a\r\n> ' + echo "Prompt: '${prompt@P}'" + + prompt='\\' + echo "Prompt: '${prompt@P}'" + + prompt='\?' + echo "Prompt: '${prompt@P}'" + + prompt='\81' + echo "Prompt: '${prompt@P}'" + + - name: "Working dir based prompts" + stdin: | + cd /usr + + prompt='\w ' + echo "Prompt: '${prompt@P}'" + + prompt='\W ' + echo "Prompt: '${prompt@P}'" + + arr=("\w" "\w") + echo "Prompt: '${arr@P}'" + echo "Prompt: '${arr[@]@P}'" + echo "Prompt: '${arr[*]@P}'" + + - name: "Non-printing chars" + stdin: | + prompt='\[\]Prompt>\[\]' + echo "Prompt: '${prompt@P}'" + + - name: "Hostname in prompts" + stdin: | + prompt='\h ' + echo "Prompt: '${prompt@P}'" + + prompt='\H ' + echo "Prompt: '${prompt@P}'" + + - name: "Shell name" + stdin: | + prompt='\s' + [[ "${prompt@P}" == "$(basename $0)" ]] + + - name: "Shell version info" + stdin: | + prompt='\v' + [[ "${prompt@P}" =~ ^\d+\.\d+$ ]] && echo "Version is well-formatted" + + prompt='\V' + [[ "${prompt@P}" == ^\d+\.\d+\.\d+$ ]] && echo "Release is correct" + + - name: "Simple date" + stdin: | + prompt='\d' + [[ "${prompt@P}" == $(date +'%a %b %d') ]] && echo '\d date matches' + + - name: "Date format with plain string" + stdin: | + prompt='\D{something}' + echo "Date format with plain string: '${prompt@P}'" + + - name: "Date format with year" + stdin: | + prompt='\d{%Y}' + echo "Date format with year: '${prompt@P}'" + + - name: "Time format: A" + stdin: | + prompt='\A' + expanded=${prompt@P} + roundtripped=$(date --date="${expanded}" +'%H:%M') + [[ ${expanded} == ${roundtripped} ]] && echo "Time matches" + + - name: "Time format: @" + stdin: | + prompt='\@' + expanded=${prompt@P} + roundtripped=$(date --date="${expanded}" +'%I:%M %p') + [[ ${expanded} == ${roundtripped} ]] && echo "Time matches" + + - name: "Time format: T" + stdin: | + prompt='\T' + expanded=${prompt@P} + roundtripped=$(date --date="${expanded}" +'%I:%M:%S') + [[ ${expanded} == ${roundtripped} ]] && echo "Time matches" + + - name: "Time format: t" + stdin: | + prompt='\t' + expanded=${prompt@P} + roundtripped=$(date --date="${expanded}" +'%H:%M:%S') + [[ ${expanded} == ${roundtripped} ]] && echo "Time matches" + + - name: "Current history number" + known_failure: true + stdin: | + prompt='\!' + expanded=${prompt@P} + roundtripped=$(history | tail -n 1 | cut -d ' ' -f 1) + [[ ${expanded} == ${roundtripped} ]] && echo "History number matches" + + - name: "Current command number" + known_failure: true # Issue #471 + stdin: | + prompt='\#' + expanded=${prompt@P} + echo "${expanded}" + + - name: "Escaped dollar-sign" + stdin: | + prompt='\$' + echo "Prompt: '${prompt@P}'" + + - name: "Var in prompt" + stdin: | + var=value + + prompt="\$var" + + shopt -s promptvars + echo "Prompt (dollar-sign not escaped, with promptvars): ${prompt@P}" + + shopt -u promptvars + echo "Prompt (dollar-sign not escaped, without promptvars): ${prompt@P}" + + prompt="\\\$var" + + shopt -s promptvars + echo "Prompt (dollar-sign escaped, with promptvars): ${prompt@P}" + + shopt -u promptvars + echo "Prompt (dollar-sign escaped, without promptvars): ${prompt@P}" + + - name: "Tilde in prompt" + stdin: | + HOME=$(pwd) + + prompt='~' + + shopt -s promptvars + echo "Prompt (with promptvars): ${prompt@P}" + + shopt -u promptvars + echo "Prompt (without promptvars): ${prompt@P}" + + - name: "Command substitution in prompt" + stdin: | + prompt='$(echo Hello)' + echo "Without spaces: ${prompt@P}" + + prompt=' $(echo Hello) ' + echo "With spaces: ${prompt@P}" + + - name: "Backquoted command substitution in prompt" + stdin: | + prompt='`echo Hello`' + echo "Without spaces: ${prompt@P}" + + prompt=' `echo Hello` ' + echo "With spaces: ${prompt@P}" + + - name: "Parameter expansion with non-printing chars" + stdin: | + var=value + prompt="\${var:+\[text\]}" + echo "Conditional (set): '${prompt@P}'" + + var= + prompt="\${var:+\[text\]}" + echo "Conditional (unset): '${prompt@P}'" + + - name: "Command substitution in prompt and exit code" + args: ["-i"] + ignore_stderr: true + stdin: | + PS1='$(echo Hello)> ' + false + echo $? + + - name: "Brackets and var" + stdin: | + prompt='\[$var\]\u > ' + echo "Prompt: ${prompt@P}" + + - name: "Brackets and var (interactive mode)" + args: ["-i"] + ignore_stderr: true + stdin: | + prompt='\[$var\]\u > ' + echo "Prompt: ${prompt@P}" + + # The following cases exercise double-quote escape semantics in the second + # pass of prompt expansion: a backslash only escapes (and is consumed by) + # one of the same characters it would escape inside a double-quoted string; + # for any other character, the backslash is preserved literally. + # + # We pipe the expansion result through hexdump -C to get a deterministic + # byte-level view, avoiding any divergence in `printf '%q'` formatting. + - name: "Backslash-CR survives prompt expansion (issue #1119)" + stdin: | + # OSC 133;C followed by ST (\e\\) and CR. The ST must end with a literal + # backslash byte (0x5c); without it, the OSC is malformed and terminals + # may eat the first character of subsequent output. + prompt='\e]133;C\e\\\r' + printf '%s' "${prompt@P}" | hexdump -C + + - name: "Backslash before non-special char preserves backslash" + stdin: | + # Under double-quote escape rules, a backslash before a non-special + # character is preserved literally. \\X (raw) = '\' + 'X' should expand + # to literal '\X'. + prompt='\\X' + printf '%s' "${prompt@P}" | hexdump -C + + prompt='\\Z' + printf '%s' "${prompt@P}" | hexdump -C + + prompt='\\n' + printf '%s' "${prompt@P}" | hexdump -C + + prompt=$'\\\\\t' + printf '%s' "${prompt@P}" | hexdump -C + + - name: "Backslash before dollar escapes parameter expansion" + stdin: | + # `\\$VAR` (PS0 raw chars: \\, $, V, A, R) → step 1 produces \$VAR, then + # step 2's `\$` consumes the backslash and leaves a literal $, so $VAR + # remains literal (not expanded). + var=hello + prompt='\\$var' + printf '%s' "${prompt@P}" | hexdump -C + + - name: "Double backslash before dollar yields backslash + expansion" + stdin: | + # `\\\\$VAR` → step 1 produces \\$VAR, then step 2's `\\` reduces to a + # literal `\` and `$VAR` is expanded normally. + var=hello + prompt='\\\\$var' + printf '%s' "${prompt@P}" | hexdump -C + + - name: "Backslash before escaped dollar" + stdin: | + # `\\\$` → step 1 produces \$ (Backslash + DollarOrPound). Step 2 keeps + # the literal backslash and the literal $, so the result is `\$`. + prompt='\\\$' + printf '%s' "${prompt@P}" | hexdump -C + + - name: "Many backslashes before non-special" + stdin: | + # Each `\\` in step 1 produces a literal `\`. Step 2 reduces consecutive + # backslashes pairwise. + prompt='\\\\\\\\X' + printf '%s' "${prompt@P}" | hexdump -C + + prompt=$'\\\\\\\\\r' + printf '%s' "${prompt@P}" | hexdump -C + + - name: "Backslash before newline is line continuation" + stdin: | + # `\\\n` produces (Backslash, Newline) → \ in formatted prompt. + # In double-quote escape rules, \ is line continuation and + # consumes both characters. + prompt='\\\n' + printf '%s' "${prompt@P}" | hexdump -C + + # Same expected behavior when the LF byte is supplied directly. + prompt=$'\\\\\n' + printf '%s' "${prompt@P}" | hexdump -C + + - name: "Trailing literal backslash survives" + stdin: | + # `\\` at end of prompt should produce a single literal backslash. + prompt='\\' + printf '%s' "${prompt@P}" | hexdump -C + + # `\\X\\` should produce `\X\`. + prompt='\\X\\' + printf '%s' "${prompt@P}" | hexdump -C + + - name: "Backslash followed by working-dir prompt piece" + stdin: | + # Make sure prompt pieces whose formatted output happens to contain `\` + # (e.g., AsciiCharacter for octal `\134`) survive intact. + prompt='\134' + printf '%s' "${prompt@P}" | hexdump -C + + # `\\\w` should yield `\`. Use a fixed cwd so brush vs. oracle + # output is deterministic. + cd / + prompt='\\\w' + printf '%s' "${prompt@P}" | hexdump -C + + - name: "Bare \\$ leaves no protective backslash (issue #1167)" + stdin: | + # `\$` alone must format to a bare `$` or `#` — pass 1's protective `\` + # must not leak when the resulting char (e.g. `#` for root) isn't + # double-quote-escapable. + prompt='\$' + printf '%s' "${prompt@P}" | hexdump -C + + # Same in a context where promptvars would otherwise re-expand a bare $: + var=SHOULD_NOT_APPEAR + prompt='\$var' + shopt -s promptvars + printf '%s' "${prompt@P}" | hexdump -C + shopt -u promptvars + + - name: "Backslash adjacent to unrecognized escape sequence" + stdin: | + # `\\\X` (Backslash + EscapedSequence(\X)) must reduce to a single `\X`. + # An over-eager protective `\` on the EscapedSequence would leak (X + # isn't dq-escapable) and emit `\\X`. + prompt='\\\X' + printf '%s' "${prompt@P}" | hexdump -C + + prompt='\\\?' + printf '%s' "${prompt@P}" | hexdump -C + + # X here is dq-escapable, so pass 2 consumes the EscapedSequence's own + # `\` and emits just the literal char. + prompt='\"' + printf '%s' "${prompt@P}" | hexdump -C + + prompt='\`' + printf '%s' "${prompt@P}" | hexdump -C diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/redirection.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/redirection.yaml new file mode 100644 index 0000000000..344b1f43bf --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/redirection.yaml @@ -0,0 +1,277 @@ +name: "Redirection" +cases: + - name: "Redirection to null" + stdin: | + echo hi >/dev/null + + - name: "Output redirection to file" + stdin: | + echo hi >out.txt + ls out.txt + cat out.txt + + - name: "Output redirection to file (append)" + stdin: | + echo hi >>out.txt + echo there >>out.txt + ls out.txt + cat out.txt + + - name: "Input redirection from file" + test_files: + - path: "in.txt" + contents: | + Something here. + stdin: | + cat &2 + + - name: "Redirecting stdout + stderr to file" + stdin: | + function write_both() { + echo "$1" + echo "$2" >&2 + } + + echo "[Writing]" + write_both stdout stderr >& file.txt + echo "[Contents of file.txt]" + cat file.txt + + - name: "Invalid stdout + stderr redir" + ignore_stderr: true + stdin: | + function write_both() { + echo "$1" + echo "$2" >&2 + } + + echo "[Writing]" + write_both stdout stderr 2>& file.txt + echo "[Contents of file.txt]" + cat file.txt + + - name: "Redirecting an fd" + stdin: | + echo hi-out 1>stdout.txt + echo hi-error 2>stderr.txt >&2 + + - name: "Almost redirecting but not actually redirecting" + stdin: | + # Checks to make sure the '1' isn't treated as a file descriptor + echo 1 >&2 + + - name: "Redirection with arbitrary fd number" + stdin: | + echo hi 4>file.txt >&4 + + - name: "Process substitution: basic" + ignore_stdout: true # Specific fd-based paths may vary across runs + stdin: | + shopt -u -o posix + echo <(:) >(:) + echo <(:) <(:) + echo >(:) >(:) + + - name: "Process substitution: not in simple commands" + known_failure: true # Known to fail because we are only handling them in simple commands now + stdin: | + shopt -u -o posix + for f in <(echo hi); do echo $f; done + + - name: "Process substitution: builtins" + stdin: | + source <(echo VAR=100) + echo "var: ${VAR}" + + - name: "Process substitution: input redirection" + stdin: | + shopt -u -o posix + cat < <(echo hi) + + - name: "Process substitution: output redirection" + skip: true # TODO(redirection): Test is flaky; needs work. + stdin: | + shopt -u -o posix + echo hi > >(wc -l) + echo hi >> >(wc -l) + + - name: "Process substitution: input" + stdin: | + shopt -u -o posix + var="value" + cat <(var="updated"; echo ${var}) + echo "Done." + echo "${var}" + + - name: "Input redirection from /dev/null" + stdin: | + cat < /dev/null + echo "exit: $?" + + - name: "Redirect stderr to /dev/null" + stdin: | + ls /non-existent-path 2>/dev/null + echo "exit: $?" + + - name: "Append redirection to /dev/null" + stdin: | + echo hi >>/dev/null + echo "exit: $?" + + - name: "Redirect to /dev/null via variable" + stdin: | + target=/dev/null + echo hi >"$target" + echo "exit: $?" + + - name: "Read and write /dev/null" + stdin: | + cat <>/dev/null + echo "exit: $?" + + - name: "Redirect stdout and stderr" + stdin: | + ls -d . non-existent-dir &>/dev/null + ls -d . non-existent-dir &>>/dev/null + + - name: "Process substitution: input + output" + stdin: | + shopt -u -o posix + cp <(echo hi) >(cat) + + - name: "Redirection in command substitution" + stdin: | + echo $(echo hi >&2) 2>stderr.txt + + - name: "Redirection in command substitution to non-standard fd" + ignore_stderr: true + stdin: | + echo $(echo hi >&3) 3>output.txt + + - name: "Redirection in command substitution in braces to non-standard fd" + ignore_stderr: true + stdin: | + { : $(echo hi >&3); } 3>output.txt + + - name: "Redirection in command substitution in subshell to non-standard fd" + ignore_stderr: true + stdin: | + ( : $(echo hi >&3); ) 3>output.txt + + - name: "Redirection failure" + ignore_stderr: true + stdin: | + echo hi > /non-existent-dir/file.txt; echo following-command + + - name: "Redirection for opening read and write" + stdin: | + echo hi >file.txt + cat <>file.txt + + - name: "Close stdout" + ignore_stderr: true + stdin: | + echo "This should not appear" 1>&- + echo "This should appear" + + - name: "Close stderr" + stdin: | + non-existent-command 2>&- + echo "This should appear" + + - name: "Close output file descriptor" + ignore_stderr: true + stdin: | + # Open a new output fd (3). + exec 3>test.txt + # Write to the file. + echo "test" >&3 + # Close the file. + exec 3>&- + # Make sure we can't write to it. + echo "after close" >&3 || echo "Cannot write to closed fd" + + - name: "Close input file descriptor" + ignore_stderr: true + test_files: + - path: "input.txt" + contents: | + line1 + line2 + stdin: | + # Open an input file descriptor (3). + exec 3/dev/null || echo "Cannot read from closed fd" + + - name: "Duplicate input file descriptor" + test_files: + - path: "input.txt" + contents: | + test input line + stdin: | + # Open an input file descriptor (3). + exec 3fd3.txt 4>fd4.txt 5>fd5.txt + # Write to the new fds + echo "fd3" >&3 + echo "fd4" >&4 + echo "fd5" >&5 + # Close the fds + exec 3>&- 4>&- 5>&- + # Read from the files to verify + echo "[3]" + cat fd3.txt + echo "[4]" + fd4.txt + echo "[5]" + fd5.txt + + - name: "High file descriptor numbers" + stdin: | + # Open high numbered file descriptor + echo "test" 10>fd10.txt >&10 + # Dump the file + echo "[10]" + cat fd10.txt + + - name: "Inheritance of fds from parent" + test_files: + - path: "test.sh" + contents: | + echo hi >&3 + stdin: | + args="" + + # NOTE: For now, brush doesn't inherit arbitrary all open fds by default like bash does. + # We need to explicitly specify which fds to inherit. + if [[ $BRUSH_VERSION ]]; then + args+=" --inherit-fd=3" + fi + + $0 $args test.sh 3>output.txt diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/simple_commands.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/simple_commands.yaml new file mode 100644 index 0000000000..fe1d54ffc1 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/simple_commands.yaml @@ -0,0 +1,39 @@ +name: "Simple commands" +cases: + - name: "Simple command" + stdin: | + echo 1 + + - name: "Simple command with reserved word args" + stdin: | + echo then + + - name: "Command that's a directory" + ignore_stderr: true + stdin: | + mkdir test-dir + ./test-dir + echo "Result: $?" + + - name: "Non-existent command" + ignore_stderr: true + stdin: | + ./non-existent-command + echo "Result: $?" + + - name: "Redirection of errors" + stdin: | + ./non-existent-command 2>/dev/null + echo "Result: $?" + + - name: "Simple command with non-existent cwd" + # N.B. We intentionally fail here because there's no safe way to pick an + # alternate working directory that we are okay with. To our knowledge, + # there's no easy way to hold onto the current working directory past + # its deletion. + known_failure: true + stdin: | + mkdir test-dir + cd test-dir + rmdir $(pwd) + ls diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/special_parameters.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/special_parameters.yaml new file mode 100644 index 0000000000..29b97e28c8 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/special_parameters.yaml @@ -0,0 +1,261 @@ +name: "Special parameters" +cases: + - name: "$_ - initial value matches $0" + stdin: | + [[ "$0" == "$_" ]] && echo "matches on start" + + - name: "$_ - last argument tracked across simple commands" + stdin: | + : one + echo "[$_]" + : two three four + echo "[$_]" + echo last_echo_arg + echo "[$_]" + + - name: "$_ - zero-arg command uses command name" + stdin: | + echo seed1 seed2 + : + echo "after colon: [$_]" + true + echo "after true: [$_]" + echo + echo "after bare echo: [$_]" + + - name: "$_ - args with spaces and special chars" + stdin: | + echo "seed last" + echo "[$_]" + echo "arg with spaces" 'arg-with-dashes' "arg:with:colons" + echo "[$_]" + echo "" trailing + echo "[$_]" + + - name: "$_ - builtin dispatch path" + stdin: | + echo seed last + : bu1 bu2 bu3 + echo "[$_]" + printf '%s\n' pf1 pf2 + echo "[$_]" + + - name: "$_ - external command dispatch path" + stdin: | + echo seed last + $(which true) ext1 ext2 + echo "[$_]" + env ENV=1 $(which true) external_final + echo "[$_]" + + - name: "$_ - external command exports resolved path via env" + stdin: | + # When invoking an external command, it should see _ set + # to the resolved pathname of the command (not "last arg"). + $(which env) | grep '^_=' + + - name: "$_ - function dispatch sets to call-site last arg" + stdin: | + f() { :; } + echo seed_last + f call_a call_b + echo "[$_]" + + - name: "$_ - function body sees pre-invocation value on entry" + stdin: | + f() { + echo "entry: [$_]" + echo new_inner + echo "after inner echo: [$_]" + } + echo pre_seed + f call_arg + echo "after return: [$_]" + + - name: "$_ - nested function dispatch" + stdin: | + inner() { echo innerdone; } + outer() { inner ia ib; } + echo seed + outer oa ob + echo "[$_]" + + - name: "$_ - assignment-only function body" + stdin: | + f() { a=1; b=2; } + echo seed_last + f foo bar + echo "[$_]" + + - name: "$_ - cleared by assignment-only statements" + stdin: | + echo seed_last + x=assigned + echo "after assign: [$_]" + y=another + echo "after second assign: [$_]" + : colonarg + echo "after colon: [$_]" + + - name: "$_ - cleared by assignment with command substitution" + stdin: | + echo seed_last + z=$(echo subst_inner) + echo "after assignment: [$_]" + + - name: "$_ - single-stage pipeline does not update parent" + stdin: | + echo prev_a prev_b + echo pipe_a pipe_b | cat + echo "[$_]" + + - name: "$_ - multi-stage pipeline does not update parent" + stdin: | + echo prev_a prev_b + echo pipe_a pipe_b | cat | cat + echo "[$_]" + + - name: "$_ - and-chain tracks last arg of last executed command" + stdin: | + echo seed_last + true && echo a1 a2 + echo "[$_]" + true && true t1 t2 + echo "[$_]" + + - name: "$_ - or-chain tracks last arg of last executed command" + stdin: | + echo seed_last + false || echo or1 or2 + echo "[$_]" + + - name: "$_ - command-not-found update" + ignore_stderr: true + stdin: | + echo seed_last + nosuchcmd_xyzzy fail_a fail_b + echo "[$_]" + + - name: "$_ - for loop body" + stdin: | + echo seed_last + for i in 1 2 3; do :; done + echo "[$_]" + for i in a b c; do echo "loop $i"; done + echo "[$_]" + + - name: "$_ - while loop body" + stdin: | + echo seed_last + i=0 + while [ $i -lt 2 ]; do echo "iter $i"; i=$((i+1)); done + echo "[$_]" + + - name: "$_ - until loop body" + stdin: | + echo seed_last + i=0 + until [ $i -ge 2 ]; do i=$((i+1)); done + echo "[$_]" + + - name: "$_ - if/then body" + stdin: | + echo seed_last + if true; then echo then_a then_b; fi + echo "[$_]" + + - name: "$_ - case body" + stdin: | + echo seed_last + case x in + x) echo match_a match_b ;; + esac + echo "[$_]" + + - name: "$_ - eval tracks eval's last arg" + stdin: | + echo seed_last + eval "echo evaled one two" + echo "[$_]" + + - name: "$_ - source tracks sourced script's commands" + test_files: + - path: sourced.sh + contents: | + echo sourced_a sourced_b + stdin: | + echo seed_last + . ./sourced.sh + echo "[$_]" + + - name: "$_ - subshell does not leak into parent" + stdin: | + echo outer_a outer_b + (echo inner1 inner2; echo "sub: [$_]") + echo "parent: [$_]" + + - name: "$_ - background job + wait" + stdin: | + echo seed_last + (:) & + wait + echo "[$_]" + + - name: "$_ - exported attribute after update" + stdin: | + export _=initial_value + echo first last + # After commands have run, does _ still have the -x attribute? + declare -p _ | grep -c '^declare -x' + + - name: "$_ - declare -p reflects updated value" + stdin: | + export _=initial_value + echo first last + declare -p _ + + - name: "$_ - readonly is honored" + ignore_stderr: true + stdin: | + readonly _=frozen + echo hi there + echo "[$_]" + + - name: "$_ - exec does not override _ in child env" + known_failure: true # exec builtin goes through compose_std_command which unconditionally sets _ + stdin: | + # bash does not set _ in the child's env for exec; the child inherits + # the parent's _ value. brush currently sets _ to the resolved command + # path (same as a normal external command), which is wrong for exec. + (exec $(which env)) | grep '^_=' + + - name: "$@ and $*" + stdin: | + function myfunc() { + echo "--myfunc called--" + + echo "COUNT: $#" + echo "AT-SIGN VALUE: '$@'" + echo "STAR VALUE: '$*'" + + for arg in $@; do + echo "AT-SIGN ELEMENT: '${arg}'" + done + + for arg in $*; do + echo "STAR ELEMENT: '${arg}'" + done + + for arg in "$@"; do + echo "DOUBLE-QUOTED AT-SIGN ELEMENT: '${arg}'" + done + + for arg in "$*"; do + echo "DOUBLE-QUOTED STAR ELEMENT: '${arg}'" + done + } + + myfunc + myfunc 1 + myfunc 1 2 + myfunc "a b c" 2 diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/status.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/status.yaml new file mode 100644 index 0000000000..7c5dce80f2 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/status.yaml @@ -0,0 +1,131 @@ +name: "Status tests" +cases: + - name: "Basic status" + stdin: | + cat /non/existent >/dev/null + echo "[1] Status: $?; pipe status: ${PIPESTATUS[@]}" + + - name: "Parse error status" + ignore_stderr: true + stdin: | + # Generate parse error + for f done + echo "[2] Status: $?; pipe status: ${PIPESTATUS[@]}" + + - name: "Pipeline status" + stdin: | + /non/existent/program 2>/dev/null | cat + echo "Status: $?; pipe status: ${PIPESTATUS[@]}" + + - name: "Command substitution status" + stdin: | + x=$(echo hi | wc -l) + echo "[1] Status: $?; pipe status: ${PIPESTATUS[@]}" + + x=$(cat /non/existent 2>/dev/null) + echo "[2] Status: $?; pipe status: ${PIPESTATUS[@]}" + + - name: "Subshell status" + stdin: | + (echo hi | wc -l) + echo "[1] Status: $?; pipe status: ${PIPESTATUS[@]}" + + (cat /non/existent 2>/dev/null) + echo "[2] Status: $?; pipe status: ${PIPESTATUS[@]}" + + - name: "Stored status" + stdin: | + false + status=$? + echo "Stored status from false: $status" + + true + status=$? + echo "Stored status from true: $status" + + - name: "Status from assignment" + stdin: | + false + false_status=$? + assign_status=$? + + echo "Status after assignment: $assign_status" + + - name: "Status from assignment with xtrace" + stdin: | + set -x + false + scalar=value + echo "Status after scalar assignment: $?" + + false + arr=(1 2) + echo "Status after array assignment: $?" + + false + appended+=more + echo "Status after appending assignment: $?" + + false + first=1 second=2 + echo "Status after multiple assignments: $?" + + false + expanded=${undefined:-fallback} + echo "Status after expanded assignment: $?" + + - name: "Status from assignment with command substitution and xtrace" + stdin: | + set -x + f() { + echo "out$1" + return $1 + } + + captured=$(f 42) + echo "Status after substitution assignment: $?" + + true + captured=$(f 42) + echo "Status after substitution assignment: $?" + + - name: "Status within assignments" + stdin: | + f() { + return $1 + } + + x=$(f 42) y=0 z=$? w=$? + echo "x: $x; y: $y; z: $z; w: $w" + + - name: "Status in for loop" + stdin: | + function ret() { + return $1 + } + + for f in a b; do + echo "Loop entry: $?" + ret 1 + echo "Loop 1: $?" + ret 2 + echo "Loop 2: $?" + done + + echo "After loop: $?" + + - name: "Status in arithmetic for loop" + stdin: | + function ret() { + return $1 + } + + for ((i = 0; i < 2; i++)); do + echo "Loop entry: $?" + ret 1 + echo "Loop 1: $?" + ret 2 + echo "Loop 2: $?" + done + + echo "After loop: $?" diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/variables.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/variables.yaml new file mode 100644 index 0000000000..6b364b2106 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/variables.yaml @@ -0,0 +1,44 @@ +name: "Variable tests" +cases: + - name: "Appending to a variable" + stdin: | + x=something + x+=here + echo "x: ${x}" + + - name: "Appending an integer to an integer variable" + stdin: | + declare -i x=10 + echo "x: ${x}" + x+=5 + echo "x: ${x}" + x+=value + echo "x: ${x}" + + y=value + declare -i y + echo "y: ${y}" + y+=5 + echo "y: ${y}" + + - name: "Append to an unset variable" + stdin: | + declare -a myvar + myvar+=abc + echo "myvar: ${myvar}" + + declare -i myint + myint+=abc + echo "myint: ${myint}" + + - name: "Parameter length of unset variable with nounset" + ignore_stderr: true + stdin: | + set -u + echo "${#unset_var}" 2>&1 || true + arr=() + echo "${#arr[0]}" + echo "${#arr[@]}" + arr2=(hello world) + echo "${#arr2[5]}" + echo "${#arr2[0]}" diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/well_known_vars.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/well_known_vars.yaml new file mode 100644 index 0000000000..5c7e9cdeba --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/well_known_vars.yaml @@ -0,0 +1,258 @@ +name: "Well-known variable tests" +cases: + - name: "Basic defaulted PATH var" + stdin: | + if [[ -z "$PATH" ]]; then + echo "PATH is not set or is empty" + else + echo "PATH is set and non-empty" + fi + + - name: "Basic variables" + known_failure: true # Not fully implemented + stdin: | + declare -p > ./vars.txt + # Filter out variables set by test infrastructure. + # Filter out brush-specific variables. + cat vars.txt | grep '^declare ' | grep -v LLVM | grep -v BRUSH | sed -e 's/=.*//' + rm vars.txt + + - name: "Static well-known variables" + stdin: | + echo "EUID: $EUID" + echo "UID: $UID" + + [[ $BASHPID == $$ ]] + echo 'BASHPID == \$$: ' $? + + [[ $BASH_ARGV0 == $0 ]] + echo 'BASH_ARGV0 == $0: ' $? + + [[ $MACHTYPE == ${BASH_VERSINFO[5]} ]] + echo 'MACHTYPE == ${BASH_VERSINFO[5]: ' $? + + - name: "OSTYPE pattern match for current platform" + stdin: | + case "$OSTYPE" in + linux*) echo "prefix=linux" ;; + darwin*) echo "prefix=darwin" ;; + freebsd*) echo "prefix=freebsd" ;; + netbsd*) echo "prefix=netbsd" ;; + openbsd*) echo "prefix=openbsd" ;; + dragonfly*) echo "prefix=dragonfly" ;; + solaris*) echo "prefix=solaris" ;; + cygwin*|msys*|windows*) + echo "prefix=windows-like" ;; + *) echo "prefix=other:$OSTYPE" ;; + esac + + - name: "dollar-_" + stdin: | + # Check on start + [[ "$0" == "$_" ]] && echo '$_ matches $0 on start' + + # Now do something + echo first middle last >/dev/null + echo "\$_ after first echo command: $_" + echo + echo "\$_ after second echo command: $_" + + # See if a child process sees _ set to its resolved pathname. + echo "Spawning child process..." + env -v | grep '^_=' + echo "\$_ after child process exited: $_" + + - name: "BASH_ALIASES" + stdin: | + echo "Initial BASH_ALIASES: " ${BASH_ALIASES[@]} + alias x=y + echo "Updated BASH_ALIASES: " ${BASH_ALIASES[@]} + unalias x + echo "Final BASH_ALIASES: " ${BASH_ALIASES[@]} + + - name: "BASHOPTS" + min_oracle_version: "5.3" # Changed in bash 5.3 + stdin: | + # Workaround for brush forcing on extglob + shopt -u extglob + + echo "BASHOPTS: $BASHOPTS" + + - name: "BASH_SOURCE" + max_oracle_version: "5.2" # Behavior changed in bash 5.3; still needs investigation + stdin: | + echo "Input entry"; declare -p BASH_SOURCE + + function myfunc() { + echo "In function: \$1: $1" + declare -p BASH_SOURCE + + if [[ $1 -gt 0 ]]; then + myfunc $(( $1 - 1 )) + fi + } + + myfunc 4 + + echo "Input exit"; declare -p BASH_SOURCE + + - name: "FUNCNAME" + stdin: | + echo "Input entry"; declare -p FUNCNAME + + function myfunc() { + echo "In function: \$1: $1" + declare -p FUNCNAME + + if [[ $1 -gt 0 ]]; then + myfunc $(( $1 - 1 )) + fi + } + + myfunc 4 + + echo "Input exit"; declare -p FUNCNAME + + # Regression test for https://github.com/reubeno/brush/issues/1212: BASH_ARGV + # and BASH_ARGC are populated for script-level frames (the top-level invocation + # plus sourced scripts) even without extended debugging mode. A sourced script + # invoked with no positional arguments contributes its own name. + - name: "BASH_ARGV/BASH_ARGC - sourced script without arguments" + test_files: + - path: "argv.sh" + contents: | + declare -p BASH_ARGV BASH_ARGC + stdin: | + . ./argv.sh + + - name: "BASH_ARGV/BASH_ARGC - nested sourced scripts without arguments" + test_files: + - path: "outer.sh" + contents: | + . ./inner.sh + - path: "inner.sh" + contents: | + declare -p BASH_ARGV BASH_ARGC + stdin: | + . ./outer.sh + + - name: "BASH_ARGV/BASH_ARGC - empty inside function without extdebug" + stdin: | + myfunc() { + declare -p BASH_ARGV BASH_ARGC + } + myfunc a b c + + - name: "BASH_ARGV/BASH_ARGC - sourced script without arguments with extdebug" + test_files: + - path: "argv.sh" + contents: | + declare -p BASH_ARGV BASH_ARGC + stdin: | + shopt -s extdebug + . ./argv.sh + + - name: "BASH_SUBSHELL" + stdin: | + echo "Initial BASH_SUBSHELL: $BASH_SUBSHELL" + (echo "Subshell BASH_SUBSHELL: $BASH_SUBSHELL") + ( + ( echo "Nested subshell BASH_SUBSHELL: $BASH_SUBSHELL" ) + ) + + echo $(echo "Command substitution BASH_SUBSHELL: $BASH_SUBSHELL") + + - name: "BASH_VERSINFO" + stdin: | + shopt -s extglob + + echo "len(BASH_VERSINFO): " ${#BASH_VERSINFO[@]} + + # Only inspect the first 4 elements. + for ((i = 0; i < 4; i++)); do + cleaned=${BASH_VERSINFO[i]//+([0-9])/d} + echo "Cleaned BASH_VERSINFO[$i]: $cleaned" + done + + - name: "BASH_VERSION" + stdin: | + shopt -s extglob + + # Replace specific numbers with placeholder text. + cleaned=${BASH_VERSION//+([0-9])/d} + echo "Cleaned BASH_VERSION: $cleaned" + + - name: "COMP_WORDBREAKS" + stdin: | + dump_var() { + echo -n "COMP_WORDBREAKS:" + for ((i=0; i<${#COMP_WORDBREAKS}; i++)); do + c="${COMP_WORDBREAKS:i:1}" + echo " ${c@Q}" + done | sort + } + + dump_var + + - name: "EPOCHSECONDS" + stdin: | + external=$(date +%s) + + es1=${EPOCHSECONDS} + [[ $es1 =~ ^[0-9]+$ ]] && echo "EPOCHSECONDS is a number" + + es2=${EPOCHSECONDS} + [[ $es2 =~ ^[0-9]+$ ]] && echo "EPOCHSECONDS is still a number" + + (( es1 >= external )) && echo "Time is moving forward" + (( es1 >= es2 )) && echo "Time is moving forward within the shell" + + - name: "GROUPS" + skip: true # TODO(well-known-vars): macOS failure needs investigation (passes elsewhere) + stdin: | + (for group in ${GROUPS[@]}; do + echo $group + done) | sort + + - name: "LINENO" + stdin: | + echo "LINENO: $LINENO" + echo "LINENO: $LINENO" + echo "LINENO: $LINENO" + + - name: "LINENO with multi-line input" + stdin: | + for f in 1 2 3; do + echo "LINENO: $LINENO" + done + + - name: "RANDOM" + stdin: | + first=${RANDOM} + second=${RANDOM} + + [[ $first != $second ]] && echo "Confirmed RANDOM at least isn't static" + [[ $first -ge 0 && $first -lt 32768 ]] && echo "RANDOM value is within expected range" + [[ $second -ge 0 && $second -lt 32768 ]] && echo "RANDOM value is within expected range" + + - name: "SHELL" + stdin: | + declare -p SHELL + echo "SHELL: $SHELL" + + - name: "SHELLOPTS" + stdin: | + echo "SHELLOPTS: $SHELLOPTS" + + - name: "SHLVL" + stdin: | + echo "SHLVL: $SHLVL" + bash -c 'echo "bash SHLVL: $SHLVL"' + $0 -c 'echo "nested SHLVL: $SHLVL"' + + - name: "SRANDOM" + stdin: | + first=${SRANDOM} + second=${SRANDOM} + + [[ $first != $second ]] && echo "Confirmed SRANDOM at least isn't static" diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/word_expansion/arrays.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/word_expansion/arrays.yaml new file mode 100644 index 0000000000..94b78fa70d --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/word_expansion/arrays.yaml @@ -0,0 +1,134 @@ +name: "Array expansion" +cases: + - name: "Array access" + stdin: | + y=(a b c) + + echo ${y[@]} + echo ${y[*]} + + echo "\${y[@]}: ${y[@]}" + echo "\${y[*]}: ${y[*]}" + + var1="${y[@]}" + echo "var1: ${var1}" + echo $var1 + + var2=${y[@]} + echo "var2: ${var2}" + echo $var2 + + var3="${y[*]}" + echo "var3: ${var3}" + echo $var3 + + var4=${y[*]} + echo "var4: ${var4}" + echo $var4 + + for f in "${y[@]}"; do + echo "quoted-@ => Element: $f" + done + + for f in ${y[@]}; do + echo "@ => Element: $f" + done + + for f in "${y[*]}"; do + echo "quoted-* => Element: $f" + done + + for f in ${y[*]}; do + echo "* => Element: $f" + done + + - name: "Empty arrays" + stdin: | + myarray=() + + for f in "${myarray[@]}"; do + echo "Quoted at-sign element: |$f|" + done + + for f in ${myarray[@]}; do + echo "Unquoted at-sign element: |$f|" + done + + for f in "${myarray[*]}"; do + echo "Quoted at-sign element: |$f|" + done + + for f in ${myarray[*]}; do + echo "Unquoted at-sign element: |$f|" + done + + - name: "Mixing array elements and other words" + stdin: | + myarray=(a b c) + + for f in "${myarray[@]} d e f"; do + echo "ELEMENT: $f" + done + + for f in ${myarray[@]} d e f; do + echo "ELEMENT: $f" + done + + - name: "Passing array elements to func" + stdin: | + myfunc() { + echo "In function" + for arg in $@; do + echo "ARG: |${arg}|" + done + } + + arr=(a b c "" "e f") + myfunc ${arr[@]} + myfunc "${arr[@]}" + + - name: "Array length" + stdin: | + y=(a b c) + echo "len(y) = ${#y}" + echo "len(y[*]) = ${#y[*]}" + echo "len(y[@]) = ${#y[@]}" + + - name: "Array length: single item" + stdin: | + z=("something here") + echo "len(z) = ${#z}" + echo "len(z[*]) = ${#z[*]}" + echo "len(z[@]) = ${#z[@]}" + + - name: "Array keys: indexed array" + stdin: | + arr=("element1" "element2" "element3") + echo "@: ${!arr[@]}" + echo "*: ${!arr[*]}" + + echo "Dumping [@]" + for i in "${!arr[@]}"; do + echo "@i: $i" + done + + echo "Dumping [*]" + for i in "${!arr[*]}"; do + echo "*i: $i" + done + + - name: "Array keys: empty array" + stdin: | + arr=() + echo "@: ${!arr[@]}" + echo "*: ${!arr[*]}" + + echo "Dumping [@]" + for i in "${!arr[@]}"; do + echo "@i: $i" + done + + echo "Dumping [*]" + for i in "${!arr[*]}"; do + echo "*i: $i" + done diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/word_expansion/braces.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/word_expansion/braces.yaml new file mode 100644 index 0000000000..8006eaec91 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/word_expansion/braces.yaml @@ -0,0 +1,145 @@ +name: "Brace expansion" +cases: + - name: "Expansion with curly braces: commas" + stdin: | + echo "{a,b} =>" + echo {a,b} + echo + + echo "{a} =>" + echo {a} + echo + + echo "{a} =>" + echo {a} + echo + + echo "a{,}b =>" + echo a{,}b + echo + + echo "{} =>" + echo {} + echo + + echo "1{a,b}2 =>" + echo 1{a,b}2 + echo + + echo "1{a,b}2{c,d}3 =>" + echo 1{a,b}2{c,d}3 + echo + + echo "{a,b}{1,2} =>" + echo {a,b}{1,2} + echo + + echo 'W\{1,2}X =>' + echo W\{1,2}X + echo + + echo 'W{1,2\}X =>' + echo W{1,2\}X + echo + + echo '"W{1,2}X" =>' + echo "W{1,2}X" + echo + + var=value + bar=other + echo '{$var,$ba}r =>' + echo {$va,$ba}r + echo + + echo '\${a,b} =>' + echo \${a,b} + echo + + - name: "Expansion with curly braces: alpha ranges" + stdin: | + echo "{a..f} =>" + echo {a..f} + echo + + echo "{a..f..2} =>" + echo {a..f..2} + echo + + echo "{a..f..0} =>" + echo {a..f..0} + echo + + echo "{m..f} =>" + echo {m..f} + echo + + - name: "Expansion with curly braces: numeric ranges" + stdin: | + echo "{2..9} =>" + echo {2..9} + echo + + echo "{+2..+9} =>" + echo {+2..+9} + echo + + echo "{2..2} =>" + echo {2..2} + echo + + echo "{2..1} =>" + echo {2..1} + echo + + echo "{10..2..0} =>" + echo {10..2..0} + echo + + echo "{10..2..2} =>" + echo {10..2..2} + echo + + echo "{10..2..-2} =>" + echo {10..2..-2} + echo + + echo "{2..9..2} =>" + echo {2..9..2} + echo + + echo "{2..9..0} =>" + echo {2..9..0} + echo + + echo "{-1..-10} =>" + echo {-1..-10} + echo + + echo "{-10..-1} =>" + echo {-10..-1} + echo + + echo "{-1..-10..2} =>" + echo {-1..-10..2} + echo + + - name: "Expansion with curly braces: nested" + stdin: | + echo "W{{1..3},a,x}Y =>" + echo W{{1..3},a,x}Y + echo + + - name: "Iterate through modified array" + stdin: | + array=("aa" "ba" "ca") + + echo "With quotes:" + for i in "${array[@]%a}"; do + echo "Element: '$i'" + done + + echo "Without quotes:" + for i in ${array[@]%a}; do + echo "Element: '$i'" + done diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/word_expansion/command_substitution.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/word_expansion/command_substitution.yaml new file mode 100644 index 0000000000..45951b9b42 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/word_expansion/command_substitution.yaml @@ -0,0 +1,222 @@ +name: "Command substitution" +cases: + - name: "Command substitution" + stdin: | + var="value" + + echo "1:" + echo $(echo hi) + echo "2:" + echo "$(echo hi)" + echo "3:" + echo "$(echo "hi")" + echo "4:" + echo "$(var="updated"; echo ${var})" + echo "var=${var}" + + - name: "Command substitution with embedded parens" + stdin: | + x=$(echo foo | (wc -l; echo hi)) + echo "\$x: $x" + + - name: "Command substitution with subshell" + stdin: | + x=$( (echo hi) ) + echo "\$x: $x" + + - name: "Command substitution exit code" + stdin: | + x=$(false) && echo "1. Made it past false" + y=$(true) && echo "2. Made it past true" + + - name: "Command substitution with exec" + stdin: | + x=$(exec echo hi) + echo "x: $x" + + - name: "Backtick command substitution" + stdin: | + echo `echo hi` + + - name: "Backtick command substitution with escaping" + stdin: | + echo `echo \`echo hi\`` + + - name: "Backtick command substitution with backslashes" + stdin: | + echo `echo \\` + + - name: "Backtick command substitution in double quotes" + stdin: | + echo "`echo First line`" + echo " `echo Second line` " + echo "Third`echo line`here" + + - name: "Ignore single quote in comment in command substitution" + stdin: | + var=$( + # I'm + echo "Batman" + ) + echo $var + + - name: "Ignore double quote in comment in command substitution" + stdin: | + var=$( + # This " is not being + echo "parsed" + ) + echo $var + + - name: "Ignore parentheses in comment in command substitution" + stdin: | + var=$( + # :( + echo "Sad" + ) + echo $var + + - name: "Ignore dollar in comment in command substitution" + stdin: | + var=$( + # $ + echo "Mr. Crabs ^" + ) + echo $var + + - name: "Positional parameter count not mistaken for comment" + stdin: | + echo $(echo $#) + + - name: "Ignore single quote in comment in command substitution (backticks)" + stdin: | + var=` + # I'm + echo "Batman" + ` + echo $var + + - name: "Ignore double quote in comment in command substitution (backticks)" + stdin: | + var=` + # This " is not being + echo "parsed" + ` + echo $var + + - name: "Ignore parentheses in comment in command substitution (backticks)" + stdin: | + var=` + # :( + echo "Sad" + ` + echo $var + + - name: "Ignore dollar in comment in command substitution (backticks)" + stdin: | + var=` + # $ + echo "Mr. Crabs ^" + ` + echo $var + + - name: "Positional parameter count not mistaken for comment (backticks)" + stdin: | + echo `echo $#` + + - name: "Command substitution with only input redir" + stdin: | + echo "Some text" >file.txt + x=$(file.txt + x=` file.txt + x=$( result: $?" + echo "\${empty?error message} : ${empty?error message}" + echo " -> result: $?" + echo "\${declared?error message} : ${declared?error message}" + echo " -> result: $?" + echo "\${undeclared?error message} : ${undeclared?error message}" + echo " -> result: $?" + + echo "\${value?} : ${value?}" + echo " -> result: $?" + echo "\${empty?} : ${empty?}" + echo " -> result: $?" + echo "\${declared?} : ${declared?}" + echo " -> result: $?" + echo "\${undeclared?} : ${undeclared?}" + echo " -> result: $?" + + # :? + echo "\${value:?error message} : ${value:?error message}" + echo " -> result: $?" + echo "\${empty:?error message} : ${empty:?error message}" + echo " -> result: $?" + echo "\${declared:?error message} : ${declared:?error message}" + echo " -> result: $?" + echo "\${undeclared:?error message} : ${undeclared:?error message}" + echo " -> result: $?" + + echo "\${value:?} : ${value:?}" + echo " -> result: $?" + echo "\${empty:?} : ${empty:?}" + echo " -> result: $?" + echo "\${declared:?} : ${declared:?}" + echo " -> result: $?" + echo "\${undeclared:?} : ${undeclared:?}" + echo " -> result: $?" + + - name: "Parameter expression: error on condition (non-interactive)" + ignore_stderr: true + stdin: | + echo "${non_existent_var?error message}" + echo "This should never execute" + + - name: "Parameter expression: expanded array as alternate value" + stdin: | + declare -a var=("abc" "def" "ghi" "") + + for item in "${var[@]}"; do + echo "Item: '${item}'" + done + + echo "Expression 1: '${var+"${var[@]}"}'" + for item in "${var+"${var[@]}"}"; do + echo " -> '${item}'" + done + + echo "Expression 2: '${var+${var[@]}}'" + for item in "${var+${var[@]}}"; do + echo " -> '${item}'" + done + + echo "Expression 3: '${var+"${var[@]}"}'" + for item in ${var+"${var[@]}"}; do + echo " -> '${item}'" + done + + echo "Expression 4: '${var+${var[@]}}'" + for item in ${var+${var[@]}}; do + echo " -> '${item}'" + done + + echo "Expression 5: '${var+"${var[*]}"}'" + for item in "${var+"${var[*]}"}"; do + echo " -> '${item}'" + done + + echo "Expression 6: '${var+${var[*]}}'" + for item in "${var+${var[*]}}"; do + echo " -> '${item}'" + done + + echo "Expression 7: '${var+"${var[*]}"}'" + for item in ${var+"${var[*]}"}; do + echo " -> '${item}'" + done + + echo "Expression 8: '${var+${var[*]}}'" + for item in ${var+${var[*]}}; do + echo " -> '${item}'" + done + + - name: "Parameter expression: expanded array as default value" + stdin: | + declare -a var=("abc" "def" "ghi" "") + + for item in "${var[@]}"; do + echo "Item: '${item}'" + done + + echo "Expression 1: ${nonexistent-${var[@]}}" + for item in ${nonexistent-${var[@]}}; do + echo " -> '${item}'" + done + + echo "Expression 2: ${nonexistent-"${var[@]}"}" + for item in ${nonexistent-"${var[@]}"}; do + echo " -> '${item}'" + done + + echo "Expression 3: ${nonexistent2=${var[@]}}" + for item in ${nonexistent3=${var[@]}}; do + echo " -> '${item}'" + done + + echo "Expression 4: ${nonexistent4="${var[@]}"}" + for item in ${nonexistent5-"${var[@]}"}; do + echo " -> '${item}'" + done + + - name: "Remove prefix/suffix" + stdin: | + var="prepre-abc-sufsuf" + + # Smallest suffix + shopt -u nocasematch + echo "\${var%}: ${var%}" + echo "\${var%pre}: ${var%pre}" + echo "\${var%suf}: ${var%suf}" + echo "\${var%SUF}: ${var%SUF}" + shopt -s nocasematch + echo "\${var%SUF}(nocasematch): ${var%SUF}" + + # Largest suffix + shopt -u nocasematch + echo "\${var%%}: ${var%%}" + echo "\${var%%pre}: ${var%%pre}" + echo "\${var%%suf}: ${var%%suf}" + echo "\${var%%SUF}: ${var%%SUF}" + shopt -s nocasematch + echo "\${var%%SUF}(nocasematch): ${var%%SUF}" + + # Smallest prefix + shopt -u nocasematch + echo "\${var#}: ${var#}" + echo "\${var#pre}: ${var#pre}" + echo "\${var#suf}: ${var#suf}" + echo "\${var#PRE}: ${var#PRE}" + shopt -s nocasematch + echo "\${var#PRE}(nocasematch): ${var#PRE}" + + # Largest prefix + shopt -u nocasematch + echo "\${var##}: ${var##}" + echo "\${var##pre}: ${var##pre}" + echo "\${var##suf}: ${var##suf}" + echo "\${var##PRE}: ${var##PRE}" + shopt -s nocasematch + echo "\${var##PRE}(nocasematch): ${var##PRE}" + + - name: "Indirect variable references" + stdin: | + var="Hello" + ref="var" + echo "${!ref}" + echo "${!ref//l/o}" + + - name: "Indirect variable references with special parameters" + stdin: | + set a b c + + ref="2" + echo "${!ref}" + + - name: "Indirect variable references with array" + stdin: | + arr=("element1" "element2" "element3") + + ref="arr[1]" + echo "${!ref}" + + ref="arr[10]" + echo "${!ref}" + + - name: "Variable prefix match" + stdin: | + var1="Hello" + var2="World" + + echo "${!var*}" + echo "${!var@}" + + echo "Dumping *" + for i in "${!var*}"; do + echo "i: $i" + done + + echo "Dumping @" + for i in "${!var@}"; do + echo "i: $i" + done + + - name: "Uppercase first character" + stdin: | + var="hello" + + shopt -u nocasematch + echo "\${var^}: ${var^}" + echo "\${var^h}: ${var^h}" + echo "\${var^H}: ${var^H}" + echo "\${var^l}: ${var^l}" + echo "\${var^h*}: ${var^h*}" + echo "\${var^he}: ${var^he}" + echo "\${var^?}: ${var^?}" + echo "\${var^*}: ${var^*}" + + shopt -s nocasematch + echo "\${var^H}(nocasematch): ${var^H}" + + arr=("hello" "world") + echo "\${arr^}: ${arr^}" + echo "\${arr[@]^}: ${arr[@]^}" + echo "\${arr[*]^}: ${arr[*]^}" + + - name: "Uppercase matching pattern" + stdin: | + var="hello" + + shopt -u nocasematch + echo "\${var^^}: ${var^^}" + echo "\${var^^l}: ${var^^l}" + echo "\${var^^L}: ${var^^L}" + echo "\${var^^m}: ${var^^m}" + + shopt -s nocasematch + echo "\${var^^L}(nocasematch): ${var^^L}" + + arr=("hello" "world") + echo "\${arr^^}: ${arr^^}" + echo "\${arr[@]^^}: ${arr[@]^^}" + echo "\${arr[*]^^}: ${arr[*]^^}" + + - name: "Lowercase first character" + stdin: | + var="HELLO" + + shopt -u nocasematch + echo "\${var,}: ${var,}" + echo "\${var,h}: ${var,h}" + echo "\${var,H}: ${var,H}" + echo "\${var,L}: ${var,L}" + echo "\${var,H*}: ${var,H*}" + echo "\${var,HE}: ${var,HE}" + echo "\${var,?}: ${var,?}" + echo "\${var,*}: ${var,*}" + + shopt -s nocasematch + echo "\${var,h}(nocasematch): ${var,h}" + + arr=("HELLO" "WORLD") + echo "\${arr,}: ${arr,}" + echo "\${arr[@],}: ${arr[@],}" + echo "\${arr[*],}: ${arr[*],}" + + - name: "Lowercase matching pattern" + stdin: | + var="HELLO" + + shopt -u nocasematch + echo "\${var,,}: ${var,,}" + echo "\${var,,M}: ${var,,M}" + echo "\${var,,L}: ${var,,L}" + echo "\${var,,l}: ${var,,l}" + + shopt -s nocasematch + echo "\${var,,l}(nocasematch): ${var,,l}" + + arr=("HELLO" "WORLD") + echo "\${arr,,}: ${arr,,}" + echo "\${arr[@],,}: ${arr[@],,}" + echo "\${arr[*],,}: ${arr[*],,}" + + - name: "Substring replacement" + stdin: | + var="Hello, world!" + echo "\${var/world/WORLD}: ${var/world/WORLD}" + + arr=("world" "world") + echo "\${arr/world/WORLD}: ${arr/world/WORLD}" + echo "\${arr[@]/world/WORLD}: ${arr[@]/world/WORLD}" + echo "\${arr[*]/world/WORLD}: ${arr[*]/world/WORLD}" + + - name: "Substring replacement with slashes" + stdin: | + var="Hello, world!" + echo "\${var/world//world}: ${var/world//world}" + + - name: "Substring replacement with patterns" + stdin: | + var="a[bc]d" + pattern="[bc]" + echo "Replacement 1: ${var/${pattern}/}" + echo "Replacement 2: ${var/"${pattern}"/}" + + - name: "Prefix substring replacement" + stdin: | + var="Hello, world!" + echo "\${var/#world/WORLD}: ${var/#world/WORLD}" + echo "\${var/#Hello/HELLO}: ${var/#Hello/HELLO}" + + arr=("world" "world") + echo "\${arr/#world/WORLD}: ${arr/#world/WORLD}" + echo "\${arr[@]/#world/WORLD}: ${arr[@]/#world/WORLD}" + echo "\${arr[*]/#world/WORLD}: ${arr[*]/#world/WORLD}" + + - name: "Suffix substring replacement" + stdin: | + var="Hello, world!" + echo "\${var/%Hello/HELLO}: ${var/%Hello/HELLO}" + echo "\${var/%world!/WORLD!}: ${var/%world!/WORLD!}" + + arr=("world" "world") + echo "\${arr/%world/WORLD}: ${arr/%world/WORLD}" + echo "\${arr[@]/%world/WORLD}: ${arr[@]/%world/WORLD}" + echo "\${arr[*]/%world/WORLD}: ${arr[*]/%world/WORLD}" + + - name: "Global substring replacement" + stdin: | + var="Hello, world, world!" + echo "\${var//world/WORLD}: ${var//world/WORLD}" + + arr=("world world" "world world") + echo "\${arr//world/WORLD}: ${arr//world/WORLD}" + echo "\${arr[@]//world/WORLD}: ${arr[@]//world/WORLD}" + echo "\${arr[*]//world/WORLD}: ${arr[*]//world/WORLD}" + + - name: "Global substring removal" + stdin: | + var="That is not all" + + shopt -u nocasematch + echo "\${var//not }: ${var//not}" + + shopt -s nocasematch + echo "\${var//NOT }: ${var//NOT }" + + - name: "Substring from offset" + stdin: | + var="Hello, world!" + echo "\${var:0}: ${var:0}" + echo "\${var:7}: ${var:7}" + echo "\${var: 7 }: ${var: 7 }" + echo "\${var:50}: ${var:50}" + echo "\${var:-1}: ${var:-1}" + echo "\${var: -1}: ${var: -1}" + echo "\${var: 2 + 3 }: ${var: 2 + 3 }" + + - name: "Substring with length" + stdin: | + var="Hello, world!" + echo "\${var:0:1}: ${var:0:1}" + echo "\${var:0:0}: ${var:0:0}" + echo "\${var:0:50}: ${var:0:50}" + echo "\${var:0:-1}: ${var:0:-1}" + echo "\${var:0:-3}: ${var:0:-3}" + echo "\${var:7:3}: ${var:7:3}" + echo "\${var:50:2}: ${var:50:2}" + echo "\${var:-1:1}: ${var:-1:1}" + echo "\${var:-3:1}: ${var:-3:1}" + + - name: "Substring on string with multi-byte chars" + skip: true # TODO(expansion): succeeds on macOS, fails on Linux + stdin: | + var="7❌🖌️✅😂🔴⛔ABC" + echo "${var:2}" | hexdump -C + + - name: "Substring operator on arrays" + stdin: | + set abcde fghij klmno pqrst uvwxy z + echo "\${@:2:2}: ${@:2:2}" + echo "\${@:2}: ${@:2}" + + myarray=(abcde fghij klmno pqrst uvwxy z) + echo "\${myarray[@]:2:2}: ${myarray[@]:2:2}" + echo "\${myarray[@]:2}: ${myarray[@]:2}" + + - name: "Substring operator on arrays with negative index" + stdin: | + set abcde fghij klmno pqrst uvwxy z + echo "\${@: -2:2}: ${@: -2:2}" + echo "\${@: -2}: ${@: -2}" + + myarray=(abcde fghij klmno pqrst uvwxy z) + echo "\${myarray[@]: -2:2}: ${myarray[@]: -2:2}" + echo "\${myarray[@]: -2}: ${myarray[@]: -2}" + + - name: "Substring operator on single-element array" + stdin: | + myarray=("abcde fghij klmno pqrst uvwxy z") + echo "\${myarray[@]:0:1}: ${myarray[@]:0:1}" + echo "\${myarray[@]:1}: ${myarray[@]:1}" + + - name: "Substring operator past end of array" + stdin: | + set a b c + declare -a result1=("${@:3}") + declare -p result1 + + myarray=(a b c) + declare -a result2=("${myarray[@]:3}") + declare -p result2 + + - name: "Substring with length (with nested expressions)" + stdin: | + var="Hello, world!" + offset=7 + length=5 + echo "\${var:\$offset:\${length}}: ${var:$offset:${length}}" diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/word_expansion/quotes.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/word_expansion/quotes.yaml new file mode 100644 index 0000000000..cb584b2972 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/word_expansion/quotes.yaml @@ -0,0 +1,84 @@ +name: "Quotes" +cases: + - name: "Single quotes" + stdin: | + var=xyz + echo 'Quoted string' + echo 'abc ${var} def' + + - name: "Double quotes" + stdin: | + echo "\"" + + - name: "Double quotes: escaping" + stdin: | + echo "1. \'" | hexdump -C + echo "2. \x" | hexdump -C + echo "\n" + + - name: "ANSI-C quotes" + stdin: | + single_quoted='\n' + echo "Single quoted len: ${#single_quoted}" + echo -n '\n' | hexdump -C + + ansi_c_quoted=$'\n' + echo "ANSI-C quoted len: ${#ansi_c_quoted}" + echo -n $'\n' | hexdump -C + + - name: "ANSI-C quotes with escape sequences" + stdin: | + echo -n "0. "$'\x' | hexdump -C + echo -n "1. "$'\x65' | hexdump -C + echo -n "2. "$'\xgg' | hexdump -C + echo -n "3. "$'\x{65}' | hexdump -C + echo -n "4. "$'\x{65' | hexdump -C + echo -n "5. "$'\x{65R}' | hexdump -C + echo -n "6. "$'\x{}R' | hexdump -C + echo -n "7. "$'\x{ }' | hexdump -C + echo -n "8. "$'\x{965}' | hexdump -C + echo -n "9. "$'\'\"' | hexdump -C + echo -n "10. "$'🐢' | hexdump -C + echo -n "11. "$'\101' | hexdump -C + echo -n "12. "$'\10' | hexdump -C + echo -n "13. "$'\1' | hexdump -C + echo -n "14. "$'\0' | hexdump -C + echo -n "15. "$'\001' | hexdump -C + echo -n "16. "$'abc \0 def' | hexdump -C + echo -n "17. "$'\ca' | hexdump -C + echo -n "18. "$'\cz' | hexdump -C + echo -n "19. "$'\cA' | hexdump -C + echo -n "20. "$'\cZ' | hexdump -C + echo -n "21. "$'\c ' | hexdump -C + echo -n "22. "$'\c!' | hexdump -C + echo -n "23. "$'\c/' | hexdump -C + echo -n "24. "$'\c0' | hexdump -C + echo -n "25. "$'\c9' | hexdump -C + echo -n "26. "$'\c:' | hexdump -C + echo -n "27. "$'\c@' | hexdump -C + echo -n "28. "$'\c[' | hexdump -C + echo -n "29. "$'\c]' | hexdump -C + echo -n "30. "$'\c`' | hexdump -C + echo -n "31. "$'\c{' | hexdump -C + echo -n "32. "$'\c?' | hexdump -C + echo -n "33. "$'\c*' | hexdump -C + echo -n "34. "$'\c^' | hexdump -C + echo -n "35. "$'\c_' | hexdump -C + echo -n "36. "$'\c' | hexdump -C + echo -n "37. "$'\c\\' | hexdump -C + echo -n "38. "$'\c\n' | hexdump -C + echo -n "39. "$'\\' | hexdump -C + + - name: "ANSI-C quote syntax inside double quotes (literal, not processed)" + stdin: | + # When $'...' appears inside double quotes, it should be treated as literal text + echo "$'\t'" + echo "$'\'abcd\''" + x="$'\n'" + echo "len: ${#x}" + echo "$x" + + - name: "gettext style quotes" + stdin: | + quoted=$"Hello, world" + echo "Content: [${quoted}]" diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/word_expansion/tilde.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/word_expansion/tilde.yaml new file mode 100644 index 0000000000..338a4ce0fe --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/word_expansion/tilde.yaml @@ -0,0 +1,151 @@ +name: "Tilde expansion" +cases: + - name: "Tilde expansion: basic" + home_dir: . + stdin: | + test_root=$(realpath $(pwd)) + + mkdir subdir + cd subdir + + D=~ + D=$(realpath $D) + echo "~: ${D//${test_root}/TEST_ROOT}" + + - name: "Tilde expansion: explicit user" + home_dir: . + stdin: | + test_root=$(pwd) + + D=~root + echo "~root: $D" + + D=~nonexistentuserwhodoesnotexist + echo "~: $D" + + - name: "Tilde expansion: working dirs" + home_dir: . + stdin: | + test_root=$(pwd) + + mkdir subdir + cd subdir + + D=~+ + echo "~+: ${D//${test_root}/TEST_ROOT}" + + D=~- + echo "~-: ${D//${test_root}/TEST_ROOT}" + + unset OLDPWD + D=~- + echo "~- (without OLDPWD): ${D//${test_root}/TEST_ROOT}" + + - name: "Tilde expansion: dir stack" + stdin: | + test_root=$(pwd) + + for name in a b c d e f g; do + mkdir $name + pushd $name >/dev/null 2>&1 + done + + D=~2 + echo "~2: ${D//${test_root}/TEST_ROOT}" + + D=~9 + echo "~9: ${D//${test_root}/TEST_ROOT}" + + D=~+2 + echo "~+2: ${D//${test_root}/TEST_ROOT}" + + D=~+9 + echo "~+9: ${D//${test_root}/TEST_ROOT}" + + D=~-2 + echo "~-2: ${D//${test_root}/TEST_ROOT}" + + D=~-9 + echo "~-9: ${D//${test_root}/TEST_ROOT}" + + - name: "Tilde expansion: after colons" + home_dir: . + stdin: | + test_root=~ + + D=something:~ + echo "D: ${D//${test_root}/TEST_ROOT}" + + D=something":"~ + echo "D: ${D//${test_root}/TEST_ROOT}" + + D=something:"~" + echo "D: ${D//${test_root}/TEST_ROOT}" + + D=something:~:another:~ + echo "D: ${D//${test_root}/TEST_ROOT}" + + - name: "Tilde expansion: unexpanded tildes" + home_dir: . + stdin: | + test_root=$(pwd) + + D="~" + echo "D: ${D//${test_root}/TEST_ROOT}" + + D=x~ + echo "D: ${D//${test_root}/TEST_ROOT}" + + - name: "Tilde expansion: colon context sensitivity" + env: + HOME: /testhome + stdin: | + # Tilde at word start always expands (in both contexts) + echo "echo tilde: $(echo ~)" + var=~ + echo "assignment tilde: ${var}" + + # Tilde after colon: should NOT expand in non-assignment context + echo "echo with colon: $(echo ~:~/bin)" + echo ~:~/bin + + # But SHOULD expand in assignment context + var=~:~/bin + echo "assignment with colon: ${var}" + + # Complex PATH-like example + PATH=/usr/bin:~:/bin:~/local + echo "PATH: ${PATH}" + + - name: "Tilde expansion: parameter defaults" + env: + HOME: /testhome + stdin: | + echo "Use default value with tilde" + unset myvar + echo "Default: ${myvar:-~}" + echo "myvar after :-: ${myvar}" + + echo "Assign default value with tilde" + unset myvar + echo "Assign default: ${myvar:=~}" + echo "myvar after :=: ${myvar}" + + echo "Use default with colon-tilde" + unset myvar2 + echo "Default colon-tilde: ${myvar2:-~:~/bin}" + + echo "Assign default with colon-tilde" + unset myvar3 + echo "Assign colon-tilde: ${myvar3:=~:~/bin}" + echo "myvar3 after := ${myvar3}" + + - name: "Tilde expansion in list" + known_failure: true + env: + HOME: . + test_files: + - path: "file.txt" + - path: "file.txt.bak" + stdin: | + echo ~/{file.txt,file.txt.bak} diff --git a/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/word_expansion/vars.yaml b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/word_expansion/vars.yaml new file mode 100644 index 0000000000..1953fdf574 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/cases/compat/word_expansion/vars.yaml @@ -0,0 +1,77 @@ +name: "Variable expansion" +cases: + - name: "Undefined variables" + stdin: | + echo "Undefined: ${undefined}" + echo "Undefined: $undefined" + + - name: "Defined variables" + stdin: | + var=xyz + echo "Defined: ${var}" + echo "Defined: var" + echo "Defined: prefix${var}suffix" + echo "Defined: prefix$var" + + - name: "Undefined positional params" + stdin: | + echo "Param: $9" + echo "Param: ${9}" + + - name: "High-numbered positional params" + stdin: | + echo "Param: $99" + echo "Param: ${99}" + + - name: "Nameref indirect expansion returns target name" + known_failure: true + stdin: | + target="the_value" + declare -n ref=target + echo "indirect: ${!ref}" + echo "direct: $ref" + + - name: "Nameref with string operations" + known_failure: true + stdin: | + target="Hello World" + declare -n ref=target + echo "upper: ${ref^^}" + echo "lower: ${ref,,}" + echo "length: ${#ref}" + echo "substring: ${ref:0:5}" + echo "replace: ${ref/World/Bash}" + + - name: "Nameref with default value expansion" + known_failure: true + stdin: | + declare -n ref=unset_var + echo "default: ${ref:-default_value}" + echo "assign default: ${ref:=assigned}" + echo "unset_var: $unset_var" + + - name: "Nameref with error expansion" + known_failure: true + ignore_stderr: true + stdin: | + declare -n ref=unset_var + echo "${ref:?should error}" 2>/dev/null || echo "caught error" + + - name: "Nameref with alternative expansion" + known_failure: true + stdin: | + target="exists" + declare -n ref=target + echo "${ref:+alternative}" + unset target + echo "'${ref:+alternative}'" + + - name: "Nameref with pattern removal" + known_failure: true + stdin: | + target="/usr/local/bin/bash" + declare -n ref=target + echo "## */: ${ref##*/}" + echo "%% /*: ${ref%%/*}" + echo "# */: ${ref#*/}" + echo "% /*: ${ref%/*}" diff --git a/local/recipes/shells/brush/source/brush-shell/tests/compat_tests.rs b/local/recipes/shells/brush/source/brush-shell/tests/compat_tests.rs new file mode 100644 index 0000000000..58380e840e --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/compat_tests.rs @@ -0,0 +1,152 @@ +//! Compatibility test harness for brush shell. +//! +//! This test harness runs YAML-based test cases comparing brush output against +//! bash (the oracle shell) to validate compatibility. + +#![cfg(any(unix, windows))] + +use anyhow::Result; +use brush_test_harness::{ + OracleConfig, RunnerConfig, ShellConfig, TestMode, TestOptions, TestRunner, WhichShell, +}; +use clap::Parser; +use std::path::{Path, PathBuf}; + +const BASH_CONFIG_NAME: &str = "bash"; +const SH_CONFIG_NAME: &str = "sh"; + +fn get_bash_version_str(bash_path: &Path) -> Result { + brush_test_harness::util::get_bash_version_str(bash_path) +} + +fn create_bash_oracle(options: &TestOptions) -> Result { + let bash_version_str = get_bash_version_str(&options.bash_path)?; + if options.verbose { + eprintln!("Detected bash version: {bash_version_str}"); + } + + Ok(OracleConfig { + name: String::from(BASH_CONFIG_NAME), + shell: ShellConfig { + which: WhichShell::NamedShell(options.bash_path.clone()), + default_args: vec![String::from("--norc"), String::from("--noprofile")], + default_path_var: options.test_path_var.clone(), + launcher: None, + }, + version_str: Some(bash_version_str), + }) +} + +fn create_sh_oracle(options: &TestOptions) -> OracleConfig { + OracleConfig { + name: String::from(SH_CONFIG_NAME), + shell: ShellConfig { + which: WhichShell::NamedShell(PathBuf::from("sh")), + default_args: vec![], + default_path_var: options.test_path_var.clone(), + launcher: None, + }, + version_str: None, + } +} + +fn create_test_shell_config(options: &TestOptions, oracle_name: &str) -> Result { + let mut config = options.create_test_shell_config()?; + + // Add --sh flag when testing against sh oracle. + if oracle_name == SH_CONFIG_NAME { + config.default_args.insert(0, "--sh".into()); + } + + Ok(config) +} + +async fn run_compat_tests(mut options: TestOptions) -> Result { + // Resolve path to the shell-under-test. + if options.brush_path.is_empty() { + options.brush_path = assert_cmd::cargo::cargo_bin!("brush") + .to_string_lossy() + .to_string(); + } + if !Path::new(&options.brush_path).exists() { + return Err(anyhow::anyhow!( + "brush binary not found: {}", + options.brush_path + )); + } + + // Resolve bash path to absolute path to avoid issues when env is cleared. + if options.bash_path.is_relative() || options.bash_path.as_path() == Path::new("bash") { + if let Ok(resolved) = which::which(&options.bash_path) { + options.bash_path = resolved; + } + } + + // Resolve test cases directory (now under compat/). + let test_cases_dir = options.test_cases_path.as_deref().map_or_else( + || PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/cases/compat"), + |p| p.to_owned(), + ); + + let mut all_passed = true; + + // Run tests for each enabled config + if options.should_enable_config(BASH_CONFIG_NAME, &[BASH_CONFIG_NAME]) { + let oracle = create_bash_oracle(&options)?; + let test_shell = create_test_shell_config(&options, &oracle.name)?; + + let config = RunnerConfig::new(PathBuf::from(&options.brush_path), test_cases_dir.clone()) + .with_oracle(oracle) + .with_mode(TestMode::Oracle) + .with_platform_tags(options.platform_tags()); + + let config = RunnerConfig { + test_shell, + ..config + }; + + let runner = TestRunner::new(config, options.clone()); + if !runner.run().await? { + all_passed = false; + } + } + + if options.should_enable_config(SH_CONFIG_NAME, &[BASH_CONFIG_NAME]) { + let oracle = create_sh_oracle(&options); + let test_shell = create_test_shell_config(&options, &oracle.name)?; + + let config = RunnerConfig::new(PathBuf::from(&options.brush_path), test_cases_dir.clone()) + .with_oracle(oracle) + .with_mode(TestMode::Oracle) + .with_platform_tags(options.platform_tags()); + + let config = RunnerConfig { + test_shell, + ..config + }; + + let runner = TestRunner::new(config, options.clone()); + if !runner.run().await? { + all_passed = false; + } + } + + Ok(all_passed) +} + +fn main() -> Result<()> { + let unparsed_args: Vec<_> = std::env::args().collect(); + let options = TestOptions::parse_from(unparsed_args); + + let success = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .worker_threads(32) + .build()? + .block_on(run_compat_tests(options))?; + + if !success { + std::process::exit(1); + } + + Ok(()) +} diff --git a/local/recipes/shells/brush/source/brush-shell/tests/completion_tests.rs b/local/recipes/shells/brush/source/brush-shell/tests/completion_tests.rs new file mode 100644 index 0000000000..c06a04409d --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/completion_tests.rs @@ -0,0 +1,584 @@ +//! Completion integration tests for brush shell. + +// For now, only compile this for Linux. +#![cfg(target_os = "linux")] +#![cfg(test)] +#![allow(clippy::panic_in_result_fn)] + +use anyhow::Result; +use assert_fs::prelude::*; +use brush_builtins::ShellBuilderExt; +use std::path::PathBuf; + +struct TestShellWithBashCompletion { + shell: brush_core::Shell, + temp_dir: assert_fs::TempDir, +} + +const DEFAULT_BASH_COMPLETION_SCRIPT: &str = "/usr/share/bash-completion/bash_completion"; + +impl TestShellWithBashCompletion { + async fn new() -> Result { + let mut shell = brush_core::Shell::builder() + .profile(brush_core::ProfileLoadBehavior::Skip) + .rc(brush_core::RcLoadBehavior::Skip) + .default_builtins(brush_builtins::BuiltinSet::BashMode) + .build() + .await?; + + let temp_dir = assert_fs::TempDir::new()?; + let bash_completion_script_path = Self::find_bash_completion_script()?; + + let exec_params = shell.default_exec_params(); + let source_result = shell + .source_script( + bash_completion_script_path.as_path(), + std::iter::empty::(), + &exec_params, + ) + .await?; + + if !source_result.is_success() { + return Err(anyhow::anyhow!("failed to source bash completion script")); + } + + shell.set_working_dir(temp_dir.path())?; + + Ok(Self { shell, temp_dir }) + } + + fn find_bash_completion_script() -> Result { + // See if an environmental override was provided. + let script_path = std::env::var("BASH_COMPLETION_PATH").map_or_else( + |_| PathBuf::from(DEFAULT_BASH_COMPLETION_SCRIPT), + PathBuf::from, + ); + + if script_path.exists() { + Ok(script_path) + } else { + Err(anyhow::anyhow!( + "bash completion script not found: {}", + script_path.display() + )) + } + } + + pub async fn complete_end_of_line(&mut self, line: &str) -> Result> { + self.complete(line, line.len()).await + } + + pub async fn complete(&mut self, line: &str, pos: usize) -> Result> { + let completions = self.shell.complete(line, pos).await?; + Ok(completions + .candidates + .into_iter() + .collect::>() + .into_iter() + .collect()) + } + + pub fn set_var(&mut self, name: &str, value: &str) -> Result<()> { + self.shell + .env_mut() + .set_global(name, brush_core::ShellVariable::new(value))?; + Ok(()) + } +} + +#[test_with::file(/usr/share/bash-completion/bash_completion)] +#[tokio::test(flavor = "multi_thread")] +async fn complete_relative_file_path() -> Result<()> { + let mut test_shell = TestShellWithBashCompletion::new().await?; + + // Create file and dir. + test_shell.temp_dir.child("item1").touch()?; + test_shell.temp_dir.child("item2").create_dir_all()?; + test_shell.temp_dir.child(".dot_item1").touch()?; + test_shell.temp_dir.child("..dot_item2").touch()?; + + // Complete; expect to see the two files. + let mut results = test_shell.complete_end_of_line("ls item").await?; + + assert_eq!(results, ["item1", "item2"]); + + results = test_shell.complete_end_of_line("ls .").await?; + // Some versions of bash-completion filter out "." and ".." via + // `-X '?(*/)@(.|..)'`; others don't. Accept either outcome. + assert!( + results == ["..dot_item2", ".dot_item1"] + || results == [".", "..", "..dot_item2", ".dot_item1"], + "unexpected completions for 'ls .': {results:?}" + ); + + results = test_shell.complete_end_of_line("ls ..").await?; + assert_eq!(results, ["..", "..dot_item2"]); + + Ok(()) +} + +#[test_with::file(/usr/share/bash-completion/bash_completion)] +#[tokio::test(flavor = "multi_thread")] +async fn complete_relative_file_path_ignoring_case() -> Result<()> { + let mut test_shell = TestShellWithBashCompletion::new().await?; + test_shell + .shell + .options_mut() + .case_insensitive_pathname_expansion = true; + + // Create file and dir. + test_shell.temp_dir.child("ITEM1").touch()?; + test_shell.temp_dir.child("item2").create_dir_all()?; + + // Complete; expect to see the two files. + let results = test_shell.complete_end_of_line("ls item").await?; + + assert_eq!(results, ["ITEM1", "item2"]); + + Ok(()) +} + +#[test_with::file(/usr/share/bash-completion/bash_completion)] +#[tokio::test(flavor = "multi_thread")] +async fn complete_relative_dir_path() -> Result<()> { + let mut test_shell = TestShellWithBashCompletion::new().await?; + + // Create file and dir. + test_shell.temp_dir.child("item1").touch()?; + test_shell.temp_dir.child("item2").create_dir_all()?; + + // Complete; expect to see just the dir. + let results = test_shell.complete_end_of_line("cd item").await?; + + assert_eq!(results, ["item2"]); + + Ok(()) +} + +#[test_with::file(/usr/share/bash-completion/bash_completion)] +#[tokio::test(flavor = "multi_thread")] +async fn complete_under_empty_dir() -> Result<()> { + let mut test_shell = TestShellWithBashCompletion::new().await?; + + // Create file and dir. + test_shell.temp_dir.child("empty").create_dir_all()?; + + // Complete; expect to see nothing. + let results = test_shell.complete_end_of_line("ls empty/").await?; + + assert_eq!(results, Vec::::new()); + + Ok(()) +} + +#[test_with::file(/usr/share/bash-completion/bash_completion)] +#[tokio::test(flavor = "multi_thread")] +async fn complete_nonexistent_relative_path() -> Result<()> { + let mut test_shell = TestShellWithBashCompletion::new().await?; + + // Complete; expect to see nothing. + let results = test_shell.complete_end_of_line("ls item").await?; + + assert_eq!(results, Vec::::new()); + + Ok(()) +} + +#[test_with::file(/usr/share/bash-completion/bash_completion)] +#[tokio::test(flavor = "multi_thread")] +async fn complete_absolute_paths() -> Result<()> { + let mut test_shell = TestShellWithBashCompletion::new().await?; + + // Create file and dir. + test_shell.temp_dir.child("item1").touch()?; + test_shell.temp_dir.child("item2").create_dir_all()?; + + // Complete; expect to see just the dir. + let input = std::format!("ls {}", test_shell.temp_dir.path().join("item").display()); + let results = test_shell.complete_end_of_line(input.as_str()).await?; + + assert_eq!( + results, + [ + test_shell + .temp_dir + .child("item1") + .path() + .display() + .to_string(), + test_shell + .temp_dir + .child("item2") + .path() + .display() + .to_string(), + ] + ); + + Ok(()) +} + +#[test_with::file(/usr/share/bash-completion/bash_completion)] +#[tokio::test(flavor = "multi_thread")] +async fn complete_path_with_var() -> Result<()> { + let mut test_shell = TestShellWithBashCompletion::new().await?; + + // Create file and dir. + test_shell.temp_dir.child("item1").touch()?; + test_shell.temp_dir.child("item2").create_dir_all()?; + + // Complete; expect to see the two files. + let results = test_shell.complete_end_of_line("ls $PWD/item").await?; + + assert_eq!( + results, + [ + test_shell + .temp_dir + .child("item1") + .path() + .display() + .to_string(), + test_shell + .temp_dir + .child("item2") + .path() + .display() + .to_string(), + ] + ); + + Ok(()) +} + +#[test_with::file(/usr/share/bash-completion/bash_completion)] +#[tokio::test(flavor = "multi_thread")] +async fn complete_path_with_tilde() -> Result<()> { + let mut test_shell = TestShellWithBashCompletion::new().await?; + + // Set HOME to the temp dir so we can use ~ to reference it. + test_shell.set_var( + "HOME", + test_shell + .temp_dir + .path() + .to_string_lossy() + .to_string() + .as_str(), + )?; + + // Create file and dir. + test_shell.temp_dir.child("item1").touch()?; + test_shell.temp_dir.child("item2").create_dir_all()?; + + // Complete; expect to see the two files. + let results = test_shell.complete_end_of_line("ls ~/item").await?; + + assert_eq!( + results, + [ + test_shell + .temp_dir + .child("item1") + .path() + .display() + .to_string(), + test_shell + .temp_dir + .child("item2") + .path() + .display() + .to_string(), + ] + ); + + Ok(()) +} + +#[test_with::file(/usr/share/bash-completion/bash_completion)] +#[tokio::test(flavor = "multi_thread")] +async fn complete_variable_names() -> Result<()> { + let mut test_shell = TestShellWithBashCompletion::new().await?; + + // Set a few vars. + test_shell.set_var("TESTVAR1", "")?; + test_shell.set_var("TESTVAR2", "")?; + + // Complete. + let results = test_shell.complete_end_of_line("echo $TESTVAR").await?; + assert_eq!(results, ["$TESTVAR1", "$TESTVAR2"]); + + Ok(()) +} + +#[test_with::file(/usr/share/bash-completion/bash_completion)] +#[tokio::test(flavor = "multi_thread")] +async fn complete_variable_names_with_braces() -> Result<()> { + let mut test_shell = TestShellWithBashCompletion::new().await?; + + // Set a few vars. + test_shell.set_var("TESTVAR1", "")?; + test_shell.set_var("TESTVAR2", "")?; + + // Complete. + let results = test_shell.complete_end_of_line("echo ${TESTVAR").await?; + assert_eq!(results, ["${TESTVAR1}", "${TESTVAR2}"]); + + Ok(()) +} + +#[test_with::file(/usr/share/bash-completion/bash_completion)] +#[tokio::test(flavor = "multi_thread")] +async fn complete_help_topic() -> Result<()> { + let mut test_shell = TestShellWithBashCompletion::new().await?; + + // Complete. + let results = test_shell.complete_end_of_line("help expor").await?; + assert_eq!(results, ["export"]); + + Ok(()) +} + +#[test_with::file(/usr/share/bash-completion/bash_completion)] +#[tokio::test(flavor = "multi_thread")] +async fn complete_command_option() -> Result<()> { + let mut test_shell = TestShellWithBashCompletion::new().await?; + + // Complete. + let results = test_shell.complete_end_of_line("ls --hel").await?; + assert_eq!(results, ["--help"]); + + Ok(()) +} + +/// Tests completion with some well-known programs that have been good manual test cases +/// for us in the past. +#[test_with::file(/usr/share/bash-completion/bash_completion)] +#[tokio::test(flavor = "multi_thread")] +async fn complete_path_args_to_well_known_programs() -> Result<()> { + let mut test_shell = TestShellWithBashCompletion::new().await?; + + // Create file and dir. + test_shell.temp_dir.child("item1").touch()?; + test_shell.temp_dir.child("item2").create_dir_all()?; + + // Complete. + let results = test_shell.complete_end_of_line("tar tvf ./item").await?; + + assert_eq!(results, ["./item2"]); + + Ok(()) +} + +/// Tests some 'find' completion. +#[test_with::file(/usr/share/bash-completion/bash_completion)] +#[tokio::test(flavor = "multi_thread")] +async fn complete_find_command() -> Result<()> { + let mut test_shell = TestShellWithBashCompletion::new().await?; + + // Complete. + let results = test_shell.complete_end_of_line("find . -na").await?; + + assert_eq!(results, ["-name"]); + + Ok(()) +} + +#[test_with::file(/usr/share/bash-completion/bash_completion)] +#[tokio::test(flavor = "multi_thread")] +async fn complete_quoted_filenames() -> Result<()> { + let mut test_shell = TestShellWithBashCompletion::new().await?; + + test_shell.temp_dir.child("item1 item2").touch()?; + test_shell.temp_dir.child("item1'item2").touch()?; + + let mut results = test_shell.complete_end_of_line("ls item1\\ ").await?; + assert_eq!(results, ["item1 item2"]); + + results = test_shell.complete_end_of_line("ls item1'").await?; + assert_eq!(results, ["item1 item2", "item1'item2"]); + + results = test_shell.complete_end_of_line("ls item1").await?; + assert_eq!(results, ["item1 item2", "item1'item2"]); + + results = test_shell.complete_end_of_line("ls 'item1 ").await?; + assert_eq!(results, ["item1 item2"]); + + results = test_shell.complete_end_of_line("ls \"item1 ").await?; + assert_eq!(results, ["item1 item2"]); + + Ok(()) +} + +/// Tests that interactive completion sets `COMP_KEY` and `COMP_TYPE` to 9 (TAB). +#[tokio::test(flavor = "multi_thread")] +async fn interactive_completion_sets_comp_key_and_comp_type() -> Result<()> { + let mut shell = brush_core::Shell::builder() + .profile(brush_core::ProfileLoadBehavior::Skip) + .rc(brush_core::RcLoadBehavior::Skip) + .default_builtins(brush_builtins::BuiltinSet::BashMode) + .build() + .await?; + + // Register a completion function that captures COMP_KEY and COMP_TYPE. + let exec_params = shell.default_exec_params(); + let source_info = brush_core::SourceInfo::default(); + shell + .run_string( + r" +_test_comp() { + CAPTURED_COMP_KEY=$COMP_KEY + CAPTURED_COMP_TYPE=$COMP_TYPE + COMPREPLY=(done) +} +complete -F _test_comp mycmd +" + .to_string(), + &source_info, + &exec_params, + ) + .await?; + + // Trigger interactive completion. + let _completions = shell.complete("mycmd ", 6).await?; + + // Check the captured values. + let comp_key = shell + .env() + .get("CAPTURED_COMP_KEY") + .map(|(_, v)| v.value().to_cow_str(&shell).to_string()); + let comp_type = shell + .env() + .get("CAPTURED_COMP_TYPE") + .map(|(_, v)| v.value().to_cow_str(&shell).to_string()); + + assert_eq!(comp_key.as_deref(), Some("9"), "COMP_KEY should be 9 (TAB)"); + assert_eq!( + comp_type.as_deref(), + Some("9"), + "COMP_TYPE should be 9 (TAB)" + ); + + Ok(()) +} + +// Tests for native completion fallback (without bash-completion installed) + +struct TestShellNative { + shell: brush_core::Shell, + temp_dir: assert_fs::TempDir, +} + +impl TestShellNative { + async fn new() -> Result { + let mut shell = brush_core::Shell::builder() + .profile(brush_core::ProfileLoadBehavior::Skip) + .rc(brush_core::RcLoadBehavior::Skip) + .default_builtins(brush_builtins::BuiltinSet::BashMode) + .build() + .await?; + + let temp_dir = assert_fs::TempDir::new()?; + shell.set_working_dir(temp_dir.path())?; + + Ok(Self { shell, temp_dir }) + } + + pub async fn complete_end_of_line_full( + &mut self, + line: &str, + ) -> Result { + Ok(self.shell.complete(line, line.len()).await?) + } + + pub fn set_var(&mut self, name: &str, value: &str) -> Result<()> { + self.shell + .env_mut() + .set_global(name, brush_core::ShellVariable::new(value))?; + Ok(()) + } +} + +/// Tests native variable completion without braces (e.g., $VAR) +#[tokio::test(flavor = "multi_thread")] +async fn native_complete_variable_names() -> Result<()> { + let mut test_shell = TestShellNative::new().await?; + + // Set test variables. + test_shell.set_var("TESTVAR1", "value1")?; + test_shell.set_var("TESTVAR2", "value2")?; + + // Complete. + let completions = test_shell + .complete_end_of_line_full("echo $TESTVAR") + .await?; + let results: Vec = completions.candidates.into_iter().collect(); + assert_eq!(results, ["$TESTVAR1", "$TESTVAR2"]); + + // Variable completions should not be treated as filenames (to avoid escaping $) + assert!( + !completions.options.treat_as_filenames, + "variable completions should not be treated as filenames" + ); + + Ok(()) +} + +/// Tests native variable completion with braces (e.g., ${VAR}) +#[tokio::test(flavor = "multi_thread")] +async fn native_complete_variable_names_with_braces() -> Result<()> { + let mut test_shell = TestShellNative::new().await?; + + // Set test variables. + test_shell.set_var("TESTVAR1", "value1")?; + test_shell.set_var("TESTVAR2", "value2")?; + + // Complete. + let completions = test_shell + .complete_end_of_line_full("echo ${TESTVAR") + .await?; + let results: Vec = completions.candidates.into_iter().collect(); + assert_eq!(results, ["${TESTVAR1}", "${TESTVAR2}"]); + + // Variable completions should not be treated as filenames (to avoid escaping $) + assert!( + !completions.options.treat_as_filenames, + "variable completions should not be treated as filenames" + ); + + Ok(()) +} + +/// Tests that file completion works after a variable (e.g., $VAR/path) +#[tokio::test(flavor = "multi_thread")] +async fn native_complete_path_after_variable() -> Result<()> { + let mut test_shell = TestShellNative::new().await?; + + // Create files in temp dir. + test_shell.temp_dir.child("file1.txt").touch()?; + test_shell.temp_dir.child("file2.txt").touch()?; + + // Set a variable pointing to the temp dir. + let temp_path = test_shell.temp_dir.path().to_str().unwrap().to_owned(); + test_shell.set_var("MYDIR", &temp_path)?; + + // Complete files after variable expansion. + // The completion system expands variables before completing, so results use expanded paths. + let completions = test_shell + .complete_end_of_line_full("ls $MYDIR/file") + .await?; + let results: Vec = completions.candidates.into_iter().collect(); + let expected = [ + std::format!("{temp_path}/file1.txt"), + std::format!("{temp_path}/file2.txt"), + ]; + assert_eq!(results, expected); + + // Path completions should be treated as filenames + assert!( + completions.options.treat_as_filenames, + "path completions should be treated as filenames" + ); + + Ok(()) +} diff --git a/local/recipes/shells/brush/source/brush-shell/tests/images/.gitignore b/local/recipes/shells/brush/source/brush-shell/tests/images/.gitignore new file mode 100644 index 0000000000..58cddeb27d --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/images/.gitignore @@ -0,0 +1,17 @@ +# mkosi build outputs +mkosi.output/ +mkosi.builddir/ +mkosi.cache/ +mkosi.pkgcache/ + +# mkosi local overrides and secrets +mkosi.local.conf +mkosi.local/ +mkosi.rootpw +mkosi.passphrase +mkosi.key +mkosi.crt +mkosi.nspawn + +# stale top-level image from before OutputDirectory was configured +*.raw diff --git a/local/recipes/shells/brush/source/brush-shell/tests/images/README.md b/local/recipes/shells/brush/source/brush-shell/tests/images/README.md new file mode 100644 index 0000000000..c3c477c350 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/images/README.md @@ -0,0 +1,39 @@ +# Brush Test Images + +mkosi configs for building minimal Fedora images with brush as `/bin/sh` and the default login shell. + +## Prerequisites + +Build brush first: + +```bash +cargo build --release +``` + +## Container (systemd-nspawn) + +```bash +mkosi --profile=container build +sudo mkosi --profile=container boot +``` + +## VM (KVM-accelerated) + +```bash +mkosi --profile=vm build +mkosi --profile=vm vm +``` + +## Options + +Use a debug build: + +```bash +mkosi --environment=BRUSH_PROFILE=debug --profile=container build +``` + +Force rebuild: + +```bash +mkosi -f --profile=container build +``` diff --git a/local/recipes/shells/brush/source/brush-shell/tests/images/mkosi.conf b/local/recipes/shells/brush/source/brush-shell/tests/images/mkosi.conf new file mode 100644 index 0000000000..86c44c1381 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/images/mkosi.conf @@ -0,0 +1,26 @@ +[Distribution] +Distribution=fedora +Release=43 + +[Output] +OutputDirectory=mkosi.output +CompressOutput=no + +[Content] +Packages=systemd + util-linux + dnf5 +Autologin=yes +RootPassword=hashed: +RootShell=/usr/bin/brush +WithDocs=no +CleanPackageMetadata=no +RemoveFiles=/usr/share/locale + +[Build] +BuildSources=.:, + ../../../target:brush-target +Environment=BRUSH_PROFILE=release + +[Validation] +SecureBoot=no diff --git a/local/recipes/shells/brush/source/brush-shell/tests/images/mkosi.postinst b/local/recipes/shells/brush/source/brush-shell/tests/images/mkosi.postinst new file mode 100755 index 0000000000..8144dd5af0 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/images/mkosi.postinst @@ -0,0 +1,22 @@ +#!/bin/bash +set -euo pipefail + +BRUSH_BINARY="${SRCDIR}/brush-target/${BRUSH_PROFILE}/brush" + +if [[ ! -f "${BRUSH_BINARY}" ]]; then + echo "error: brush binary not found at: ${BRUSH_BINARY}" >&2 + echo "hint: run 'cargo build --${BRUSH_PROFILE}' first, or override with:" >&2 + echo " mkosi --environment=BRUSH_PROFILE=debug build" >&2 + exit 1 +fi + +# Install the pre-built brush binary +install -Dm755 "${BRUSH_BINARY}" "${BUILDROOT}/usr/bin/brush" + +# Register brush as a valid login shell +grep -qxF /usr/bin/brush "${BUILDROOT}/etc/shells" 2>/dev/null \ + || echo /usr/bin/brush >> "${BUILDROOT}/etc/shells" + +# Make brush available as /bin/sh and as /bin/bash +ln -sf brush "${BUILDROOT}/usr/bin/sh" +ln -sf brush "${BUILDROOT}/usr/bin/bash" diff --git a/local/recipes/shells/brush/source/brush-shell/tests/images/mkosi.profiles/container/mkosi.conf b/local/recipes/shells/brush/source/brush-shell/tests/images/mkosi.profiles/container/mkosi.conf new file mode 100644 index 0000000000..5e6392d316 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/images/mkosi.profiles/container/mkosi.conf @@ -0,0 +1,6 @@ +[Output] +Format=directory +ImageId=brush-container + +[Content] +Bootable=no diff --git a/local/recipes/shells/brush/source/brush-shell/tests/images/mkosi.profiles/vm/mkosi.conf b/local/recipes/shells/brush/source/brush-shell/tests/images/mkosi.profiles/vm/mkosi.conf new file mode 100644 index 0000000000..f5edafe94e --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/images/mkosi.profiles/vm/mkosi.conf @@ -0,0 +1,21 @@ +[Output] +Format=disk +ImageId=brush-vm + +[Content] +Bootable=yes +Bootloader=systemd-boot +Packages=systemd-udev + systemd-boot + kernel-core + +KernelCommandLine=console=hvc0 + quiet + systemd.show_status=error + +[Runtime] +Firmware=linux +KVM=auto +CPUs=2 +RAM=1G +Console=interactive diff --git a/local/recipes/shells/brush/source/brush-shell/tests/integration_tests.rs b/local/recipes/shells/brush/source/brush-shell/tests/integration_tests.rs new file mode 100644 index 0000000000..f2de5c4600 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/integration_tests.rs @@ -0,0 +1,63 @@ +//! Brush-only test harness. +//! +//! This test harness runs YAML-based test cases with inline expectations +//! or insta snapshots, without comparing against an oracle shell. + +#![cfg(any(unix, windows))] + +use anyhow::Result; +use brush_test_harness::{RunnerConfig, TestMode, TestOptions, TestRunner}; +use clap::Parser; +use std::path::{Path, PathBuf}; + +async fn run_brush_tests(mut options: TestOptions) -> Result { + // Resolve path to the shell-under-test. + if options.brush_path.is_empty() { + options.brush_path = assert_cmd::cargo::cargo_bin!("brush") + .to_string_lossy() + .to_string(); + } + if !Path::new(&options.brush_path).exists() { + return Err(anyhow::anyhow!( + "brush binary not found: {}", + options.brush_path + )); + } + + // Resolve test cases directory (in cases/brush/). + let test_cases_dir = options.test_cases_path.as_deref().map_or_else( + || PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/cases/brush"), + |p| p.to_owned(), + ); + + let test_shell = options.create_test_shell_config()?; + + let config = RunnerConfig::new(PathBuf::from(&options.brush_path), test_cases_dir) + .with_mode(TestMode::Expectation) + .with_platform_tags(options.platform_tags()); + + let config = RunnerConfig { + test_shell, + ..config + }; + + let runner = TestRunner::new(config, options); + runner.run().await +} + +fn main() -> Result<()> { + let unparsed_args: Vec<_> = std::env::args().collect(); + let options = TestOptions::parse_from(unparsed_args); + + let success = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .worker_threads(32) + .build()? + .block_on(run_brush_tests(options))?; + + if !success { + std::process::exit(1); + } + + Ok(()) +} diff --git a/local/recipes/shells/brush/source/brush-shell/tests/interactive_tests.rs b/local/recipes/shells/brush/source/brush-shell/tests/interactive_tests.rs new file mode 100644 index 0000000000..21f2c41821 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/interactive_tests.rs @@ -0,0 +1,212 @@ +//! Interactive integration tests for brush shell + +// Only compile this for platforms supported by expectrl's pty backend. +#![cfg(any( + target_os = "linux", + target_os = "android", + target_os = "macos", + target_os = "freebsd" +))] +#![cfg(test)] +#![allow(clippy::panic_in_result_fn)] + +use anyhow::Context; +use std::os::unix::process::CommandExt as _; + +use expectrl::{ + Expect, Session, + process::unix::{PtyStream, UnixProcess}, + repl::ReplSession, + stream::log::LogStream, +}; + +#[test_with::executable(ping)] +#[test] +fn run_suspend_and_fg() -> anyhow::Result<()> { + let mut session = start_shell_session()?; + + // Ping localhost in a loop; wait for at least one response. + session.expect_prompt()?; + session.send_line("ping -c 1000000 127.0.0.1")?; + session + .expect("bytes from") + .context("Initial ping invocation output")?; + + // Suspend and resume a handful of times to make sure it pauses and + // resumes reliably. + for _ in 0..5 { + // Suspend. + session.suspend()?; + session.expect_prompt()?; + + // Run `jobs` to see the suspended job. + let jobs_output = session.exec_output("jobs")?; + assert!(jobs_output.contains("ping")); + + // Bring the job to the foreground. + session.send_line("fg")?; + session.expect("ping").context("Foregrounded ping")?; + } + + // Ctrl+C to cancel the ping. + session.interrupt()?; + session.expect("loss")?; + session.expect_prompt()?; + + // Exit the shell. + session.exit()?; + + Ok(()) +} + +#[test_with::executable(ping)] +#[test] +fn run_in_bg_then_fg() -> anyhow::Result<()> { + let mut session = start_shell_session()?; + + // Ping localhost in a loop; wait for at least one response. + session.expect_prompt()?; + session.send_line("ping -c 1000000 127.0.0.1")?; + session.expect("bytes from")?; + + // Suspend and send to background. + session.suspend()?; + session.expect_prompt()?; + + // Run `jobs` to see the suspended job. + let jobs_output = session.exec_output("jobs")?; + assert!(jobs_output.contains("ping")); + + // Send the job to the background. + session.send_line("bg")?; + session.expect_prompt()?; + + // Make sure ping is still running asynchronously. + session.expect("bytes from")?; + + // Kill the job; make sure it's done. + session.send_line("kill %1")?; + session.expect_prompt()?; + session.send_line("wait")?; + session.expect_prompt()?; + + // Make sure the jobs are gone. + let jobs_output = session.exec_output("jobs")?; + assert_eq!(jobs_output.trim(), ""); + + // Exit the shell. + session.exit()?; + + Ok(()) +} + +#[test_with::executable(less)] +#[test] +fn run_pipeline_interactively() -> anyhow::Result<()> { + let mut session = start_shell_session()?; + + // Run a pipeline interactively. + session.expect_prompt()?; + session.send_line("echo hello | TERM=linux less")?; + session + .expect("hello") + .context("Echoed text didn't show up")?; + session.send("h")?; + session + .expect("SUMMARY") + .context("less help didn't show up")?; + session.send("q")?; + session.send("q")?; + session + .expect_prompt() + .context("Final prompt didn't show up")?; + + // Exit the shell. + session.exit()?; + + Ok(()) +} + +#[test] +fn login_shell_via_argv0_shows_prompt() -> anyhow::Result<()> { + let mut session = start_shell_session_with(|cmd| { + cmd.arg0("-brush"); + })?; + + session.expect_prompt()?; + let login_shell_output = session.exec_output("shopt -q login_shell && echo login")?; + assert!(login_shell_output.contains("login")); + + session.exit()?; + + Ok(()) +} + +// +// Helpers +// + +type ShellSession = ReplSession>>; +// N.B. Comment out the above line and uncomment out the following line to disable logging of the +// session. type ShellSession = ReplSession>; + +trait SessionExt { + fn suspend(&mut self) -> anyhow::Result<()>; + fn interrupt(&mut self) -> anyhow::Result<()>; + fn exec_output>(&mut self, cmd: S) -> anyhow::Result; +} + +impl SessionExt for ShellSession { + fn suspend(&mut self) -> anyhow::Result<()> { + // Send Ctrl+Z to suspend. + self.send(expectrl::ControlCode::Substitute)?; + Ok(()) + } + + fn interrupt(&mut self) -> anyhow::Result<()> { + // Send Ctrl+C to interrupt. + self.send(expectrl::ControlCode::EndOfText)?; + Ok(()) + } + + fn exec_output>(&mut self, cmd: S) -> anyhow::Result { + let output = self.execute(cmd)?; + let output_str = String::from_utf8(output)?; + Ok(output_str) + } +} + +fn start_shell_session() -> anyhow::Result { + start_shell_session_with(|_| {}) +} + +fn start_shell_session_with( + configure: impl FnOnce(&mut std::process::Command), +) -> anyhow::Result { + const DEFAULT_PROMPT: &str = "brush> "; + let shell_path = assert_cmd::cargo::cargo_bin!("brush"); + + let mut cmd = std::process::Command::new(shell_path); + cmd.args([ + "--norc", + "--noprofile", + "--no-config", + "--disable-bracketed-paste", + "--disable-color", + "--input-backend=basic", + ]); + cmd.env("PS1", DEFAULT_PROMPT); + cmd.env("TERM", "linux"); + configure(&mut cmd); + + let session = expectrl::session::Session::spawn(cmd)?; + + // N.B. Comment out this line to disable logging of the session (along with a similar line + // above). + let session = expectrl::session::log(session, std::io::stdout())?; + + let mut session = expectrl::repl::ReplSession::new(session, DEFAULT_PROMPT); + session.set_echo(true); + + Ok(session) +} diff --git a/local/recipes/shells/brush/source/brush-shell/tests/utils/process-helpers.sh b/local/recipes/shells/brush/source/brush-shell/tests/utils/process-helpers.sh new file mode 100644 index 0000000000..d05f01bb73 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/utils/process-helpers.sh @@ -0,0 +1,27 @@ +function get-proc-stat-value() { + cat /proc/self/stat | cut -d ' ' --output-delimiter=, -f$1 +} + +function get-pid() { + get-proc-stat-value 1 +} + +function get-ppid() { + get-proc-stat-value 4 +} + +function get-pgrp() { + get-proc-stat-value 5 +} + +function get-session-id() { + get-proc-stat-value 6 +} + +function get-tty-nr() { + get-proc-stat-value 7 +} + +function get-term-pgid() { + get-proc-stat-value 8 +} diff --git a/local/recipes/shells/brush/source/brush-shell/tests/version_tests.rs b/local/recipes/shells/brush/source/brush-shell/tests/version_tests.rs new file mode 100644 index 0000000000..d1ea123792 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-shell/tests/version_tests.rs @@ -0,0 +1,48 @@ +//! Integration tests for brush shell +//! +//! Most CLI tests have been moved to YAML format in tests/cases/brush/cli.yaml. +//! This file contains tests that require dynamic value comparison. + +// For now, only compile this for Unix-like platforms (Linux, macOS). +#![cfg(unix)] +#![cfg(test)] +#![allow(clippy::panic_in_result_fn)] + +use anyhow::Context; + +#[test] +fn get_version_variables() -> anyhow::Result<()> { + let shell_path = assert_cmd::cargo::cargo_bin!("brush"); + let brush_ver_str = get_variable(shell_path, /* shell_is_brush */ true, "BRUSH_VERSION")?; + let bash_ver_str = get_variable(shell_path, /* shell_is_brush */ false, "BASH_VERSION")?; + + assert_eq!(brush_ver_str, env!("CARGO_PKG_VERSION")); + assert_ne!( + brush_ver_str, bash_ver_str, + "Should differ for scripting use-case" + ); + + Ok(()) +} + +fn get_variable( + shell_path: &std::path::Path, + shell_is_brush: bool, + var: &str, +) -> anyhow::Result { + let mut cmd = std::process::Command::new(shell_path); + + if shell_is_brush { + cmd.arg("--no-config"); + } + + let output = cmd + .arg("--norc") + .arg("--noprofile") + .arg("-c") + .arg(format!("echo -n ${{{var}}}")) + .output() + .with_context(|| format!("failed to retrieve {var}"))? + .stdout; + Ok(String::from_utf8(output)?) +} diff --git a/local/recipes/shells/brush/source/brush-test-harness/Cargo.toml b/local/recipes/shells/brush/source/brush-test-harness/Cargo.toml new file mode 100644 index 0000000000..074271e85e --- /dev/null +++ b/local/recipes/shells/brush/source/brush-test-harness/Cargo.toml @@ -0,0 +1,57 @@ +[package] +name = "brush-test-harness" +description = "Test harness library for brush shell integration tests" +version = "0.1.0" +authors.workspace = true +categories.workspace = true +edition.workspace = true +keywords.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +publish = false + +[lib] +bench = false + +[lints] +workspace = true + +[features] +default = [] +# Enable insta snapshot support +insta = ["dep:insta"] + +[dependencies] +anyhow = "1.0.102" +assert_cmd = "2.2.0" +assert_fs = "1.1.3" +clap = { version = "4.6.0", features = ["derive", "env"] } +colored = "3.1.1" +descape = "3.0.0" +diff = "0.1.13" +glob = "0.3.3" +indent = "0.1.1" +insta = { version = "1.47.1", features = ["yaml"], optional = true } +junit-report = "0.9.0" +os-release = "0.1.0" +regex = "1.12.3" +serde = { version = "1.0.228", features = ["derive"] } +serde_yaml = "0.9.34" +strip-ansi-escapes = "0.2.1" +tokio = { version = "1.52.3", features = ["rt", "rt-multi-thread", "sync"] } +version-compare = "0.2.1" +walkdir = "2.5.0" + +[target.'cfg(unix)'.dependencies] +nix = { version = "0.31.2", features = ["process"] } + +[build-dependencies] +# Defines the `pty` cfg (see build.rs) used to gate pty-based test code. +cfg_aliases = "0.2.1" + +# expectrl's pty support (via ptyprocess) is limited to these platforms; +# pty-based tests are reported as unsupported elsewhere. Keep this list in sync +# with the `pty` cfg in build.rs (which gates the code using this dependency). +[target.'cfg(any(target_os = "linux", target_os = "android", target_os = "macos", target_os = "freebsd"))'.dependencies] +expectrl = "0.8.0" diff --git a/local/recipes/shells/brush/source/brush-test-harness/LICENSE b/local/recipes/shells/brush/source/brush-test-harness/LICENSE new file mode 120000 index 0000000000..ea5b60640b --- /dev/null +++ b/local/recipes/shells/brush/source/brush-test-harness/LICENSE @@ -0,0 +1 @@ +../LICENSE \ No newline at end of file diff --git a/local/recipes/shells/brush/source/brush-test-harness/build.rs b/local/recipes/shells/brush/source/brush-test-harness/build.rs new file mode 100644 index 0000000000..97a3c1bf54 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-test-harness/build.rs @@ -0,0 +1,11 @@ +//! Defines a `pty` cfg for platforms that have a working PTY backend (i.e. +//! where the `expectrl` library we use for pty-based tests is supported). +//! +//! Keep this predicate in sync with the `[target.'cfg(...)'.dependencies]` +//! section in `Cargo.toml` (Cargo manifests can't reference custom cfg names). + +fn main() { + cfg_aliases::cfg_aliases! { + pty: { any(target_os = "linux", target_os = "android", target_os = "macos", target_os = "freebsd") }, + } +} diff --git a/local/recipes/shells/brush/source/brush-test-harness/src/comparison.rs b/local/recipes/shells/brush/source/brush-test-harness/src/comparison.rs new file mode 100644 index 0000000000..b3154cc645 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-test-harness/src/comparison.rs @@ -0,0 +1,374 @@ +//! Comparison types for test results. + +use std::{io::Read, path::PathBuf, process::ExitStatus}; + +/// Comparison of durations between oracle and test runs. +#[derive(Default)] +pub struct DurationComparison { + /// Duration of the oracle run. + pub oracle: std::time::Duration, + /// Duration of the test run. + pub test: std::time::Duration, +} + +/// Comparison of exit statuses. +pub enum ExitStatusComparison { + /// Exit status was ignored. + Ignored, + /// Exit statuses match. + Same(ExitStatus), + /// Exit statuses differ. + TestDiffers { + /// Exit status from the test shell. + test_exit_status: ExitStatus, + /// Exit status from the oracle shell. + oracle_exit_status: ExitStatus, + }, +} + +impl ExitStatusComparison { + /// Returns whether this comparison indicates a failure. + pub const fn is_failure(&self) -> bool { + matches!( + self, + Self::TestDiffers { + test_exit_status: _, + oracle_exit_status: _ + } + ) + } +} + +/// Comparison of string outputs (stdout/stderr). +pub enum StringComparison { + /// Output was ignored. + Ignored { + /// Output from the test shell. + test_string: String, + /// Output from the oracle shell. + oracle_string: String, + }, + /// Outputs match. + Same(String), + /// Outputs differ. + TestDiffers { + /// Output from the test shell. + test_string: String, + /// Output from the oracle shell. + oracle_string: String, + }, +} + +impl StringComparison { + /// Returns whether this comparison indicates a failure. + pub const fn is_failure(&self) -> bool { + matches!( + self, + Self::TestDiffers { + test_string: _, + oracle_string: _ + } + ) + } +} + +/// A single entry in a directory comparison. +pub enum DirComparisonEntry { + /// File exists only in the left (oracle) directory. + LeftOnly(PathBuf), + /// File exists only in the right (test) directory. + RightOnly(PathBuf), + /// Files differ between directories. + Different(PathBuf, String, PathBuf, String), +} + +/// Comparison of directory contents. +pub enum DirComparison { + /// Directory comparison was ignored. + Ignored, + /// Directory contents match. + Same, + /// Directory contents differ. + TestDiffers(Vec), +} + +impl DirComparison { + /// Returns whether this comparison indicates a failure. + pub const fn is_failure(&self) -> bool { + matches!(self, Self::TestDiffers(_)) + } +} + +/// Full comparison between oracle and test shell runs. +pub struct OracleComparison { + /// Comparison of exit statuses. + pub exit_status: ExitStatusComparison, + /// Comparison of stdout. + pub stdout: StringComparison, + /// Comparison of stderr. + pub stderr: StringComparison, + /// Comparison of temporary directory contents. + pub temp_dir: DirComparison, + /// Comparison of durations. + pub duration: DurationComparison, +} + +impl OracleComparison { + /// Returns whether this comparison indicates a failure. + pub const fn is_failure(&self) -> bool { + self.exit_status.is_failure() + || self.stdout.is_failure() + || self.stderr.is_failure() + || self.temp_dir.is_failure() + } + + /// Creates an ignored comparison (all fields ignored). + pub fn ignored() -> Self { + Self { + exit_status: ExitStatusComparison::Ignored, + stdout: StringComparison::Ignored { + test_string: String::new(), + oracle_string: String::new(), + }, + stderr: StringComparison::Ignored { + test_string: String::new(), + oracle_string: String::new(), + }, + temp_dir: DirComparison::Ignored, + duration: DurationComparison::default(), + } + } +} + +/// Comparison of a single expectation. +#[derive(Debug)] +pub enum SingleExpectationComparison { + /// Expectation was not specified (ignored). + NotSpecified, + /// Actual matches expected. + Matches, + /// Actual differs from expected. + Differs { + /// The expected value. + expected: String, + /// The actual value. + actual: String, + }, +} + +impl SingleExpectationComparison { + /// Returns whether this comparison indicates a failure. + pub const fn is_failure(&self) -> bool { + matches!(self, Self::Differs { .. }) + } +} + +/// Comparison against inline expectations. +#[derive(Debug)] +pub struct ExpectationComparison { + /// Comparison of exit code. + pub exit_code: SingleExpectationComparison, + /// Comparison of stdout. + pub stdout: SingleExpectationComparison, + /// Comparison of stderr. + pub stderr: SingleExpectationComparison, + /// Whether snapshot comparison was used. + pub snapshot_used: bool, + /// Snapshot comparison result (if used). + pub snapshot_result: Option, +} + +impl ExpectationComparison { + /// Creates an empty expectation comparison (all not specified). + pub const fn not_specified() -> Self { + Self { + exit_code: SingleExpectationComparison::NotSpecified, + stdout: SingleExpectationComparison::NotSpecified, + stderr: SingleExpectationComparison::NotSpecified, + snapshot_used: false, + snapshot_result: None, + } + } + + /// Returns whether this comparison indicates a failure. + pub fn is_failure(&self) -> bool { + self.exit_code.is_failure() + || self.stdout.is_failure() + || self.stderr.is_failure() + || self + .snapshot_result + .as_ref() + .is_some_and(|r| r.is_failure()) + } + + /// Returns whether any expectations were checked. + pub const fn has_any_checks(&self) -> bool { + !matches!(self.exit_code, SingleExpectationComparison::NotSpecified) + || !matches!(self.stdout, SingleExpectationComparison::NotSpecified) + || !matches!(self.stderr, SingleExpectationComparison::NotSpecified) + || self.snapshot_used + } +} + +/// Result of a snapshot comparison. +#[derive(Debug)] +pub enum SnapshotResult { + /// Snapshot matches. + Matches, + /// Snapshot differs (new snapshot created or update needed). + Differs { + /// Description of the difference. + message: String, + }, +} + +impl SnapshotResult { + /// Returns whether this result indicates a failure. + pub const fn is_failure(&self) -> bool { + matches!(self, Self::Differs { .. }) + } +} + +/// Combined test comparison result. +pub struct TestComparison { + /// Oracle comparison (if oracle mode was used). + pub oracle: Option, + /// Expectation comparison (if expectations were defined). + pub expectation: ExpectationComparison, + /// Duration of the test run. + pub duration: std::time::Duration, +} + +impl TestComparison { + /// Returns whether this comparison indicates a failure. + pub fn is_failure(&self) -> bool { + self.oracle.as_ref().is_some_and(|o| o.is_failure()) || self.expectation.is_failure() + } + + /// Creates a skipped comparison. + pub fn skipped() -> Self { + Self { + oracle: None, + expectation: ExpectationComparison::not_specified(), + duration: std::time::Duration::default(), + } + } +} + +/// Compares two strings, optionally ignoring whitespace. +pub fn output_matches(oracle: &str, test: &str, ignore_whitespace: bool) -> bool { + if ignore_whitespace { + let whitespace_re = regex::Regex::new(r"\s+").unwrap(); + + let cleaned_oracle = whitespace_re.replace_all(oracle, " ").to_string(); + let cleaned_test = whitespace_re.replace_all(test, " ").to_string(); + + cleaned_oracle == cleaned_test + } else { + oracle == test + } +} + +/// Compares directory contents between oracle and test. +pub fn diff_dirs( + oracle_path: &std::path::Path, + test_path: &std::path::Path, +) -> anyhow::Result { + use std::collections::HashMap; + use std::fs; + + fn get_dir_entries( + dir_path: &std::path::Path, + ) -> anyhow::Result> { + let mut entries = HashMap::new(); + for entry in fs::read_dir(dir_path)? { + let entry = entry?; + let file_type = entry.file_type()?; + let filename = entry.file_name().to_string_lossy().to_string(); + + // Ignore raw coverage profile data files. + if filename.ends_with(".profraw") { + continue; + } + + entries.insert(filename, file_type); + } + + Ok(entries) + } + + let mut entries = vec![]; + + let oracle_entries = get_dir_entries(oracle_path)?; + let test_entries = get_dir_entries(test_path)?; + + // Look through all the files in the oracle directory + for (filename, file_type) in &oracle_entries { + if !test_entries.contains_key(filename) { + for left_only_file in walkdir::WalkDir::new(oracle_path.join(filename)) { + let entry = left_only_file?; + let left_only_path = entry.path(); + entries.push(DirComparisonEntry::LeftOnly(left_only_path.to_owned())); + } + + continue; + } + + let oracle_file_path = oracle_path.join(filename); + let test_file_path = test_path.join(filename); + + if file_type.is_file() { + let mut oracle_file = std::fs::OpenOptions::new() + .read(true) + .open(&oracle_file_path)?; + let mut oracle_bytes = vec![]; + oracle_file.read_to_end(&mut oracle_bytes)?; + + let mut test_file = std::fs::OpenOptions::new() + .read(true) + .open(&test_file_path)?; + let mut test_bytes = vec![]; + test_file.read_to_end(&mut test_bytes)?; + + if oracle_bytes != test_bytes { + let oracle_display_text = String::from_utf8_lossy(&oracle_bytes); + let test_display_text = String::from_utf8_lossy(&test_bytes); + + entries.push(DirComparisonEntry::Different( + oracle_file_path, + oracle_display_text.to_string(), + test_file_path, + test_display_text.to_string(), + )); + } + } else if file_type.is_dir() { + let subdir_comparison = + diff_dirs(oracle_file_path.as_path(), test_file_path.as_path())?; + if let DirComparison::TestDiffers(subdir_entries) = subdir_comparison { + entries.extend(subdir_entries); + } + } + } + + for (filename, file_type) in &test_entries { + if oracle_entries.contains_key(filename) { + continue; + } + + if file_type.is_dir() { + for right_only_file in walkdir::WalkDir::new(test_path.join(filename)) { + let entry = right_only_file?; + let right_only_path = entry.path(); + entries.push(DirComparisonEntry::RightOnly(right_only_path.to_owned())); + } + } else { + entries.push(DirComparisonEntry::RightOnly(test_path.join(filename))); + } + } + + if entries.is_empty() { + Ok(DirComparison::Same) + } else { + Ok(DirComparison::TestDiffers(entries)) + } +} diff --git a/local/recipes/shells/brush/source/brush-test-harness/src/config.rs b/local/recipes/shells/brush/source/brush-test-harness/src/config.rs new file mode 100644 index 0000000000..d5a4a6dbba --- /dev/null +++ b/local/recipes/shells/brush/source/brush-test-harness/src/config.rs @@ -0,0 +1,382 @@ +//! Configuration types for the test harness. + +use clap::Parser; +use std::{collections::HashSet, ffi::OsString, path::PathBuf}; + +/// Which shell to use for a test. +#[derive(Clone, Debug)] +pub enum WhichShell { + /// The shell under test (brush). + ShellUnderTest(PathBuf), + /// A named shell (e.g., bash, sh). + NamedShell(PathBuf), +} + +/// Configuration for a shell. +#[derive(Clone, Debug)] +pub struct ShellConfig { + /// Which shell this is. + pub which: WhichShell, + /// Default arguments to pass to this shell. + pub default_args: Vec, + /// Default PATH variable for this shell. + pub default_path_var: Option, + /// Optional launcher command to prepend (e.g., `["wasmtime", "run", "--"]` for wasm + /// targets). The first element is the program to execute; the rest are leading arguments + /// inserted before the shell binary path. + pub launcher: Option>, +} + +impl ShellConfig { + /// Computes the PATH variable to use for tests. + pub fn compute_test_path_var(&self) -> OsString { + let mut dirs = vec![]; + + // Start with any default we were provided. + if let Some(default_path_var) = &self.default_path_var { + dirs.extend(std::env::split_paths(default_path_var)); + } + + // Add hard-coded paths that will work on *most* Unix-like systems. + dirs.extend([ + "/usr/local/sbin".into(), + "/usr/local/bin".into(), + "/usr/sbin".into(), + "/usr/bin".into(), + "/sbin".into(), + "/bin".into(), + ]); + + // Handle systems that store their standard POSIX binaries elsewhere. + // For example, NixOS has an interesting set of paths that must be consulted. + if let Some(host_path) = std::env::var_os("PATH") { + for path in std::env::split_paths(&host_path) { + if !dirs.contains(&path) && path.join("sh").is_file() { + dirs.push(path); + } + } + } + + std::env::join_paths(dirs).unwrap_or_else(|_| PathBuf::from("").into()) + } +} + +/// Configuration for the oracle shell (e.g., bash). +#[derive(Clone, Debug)] +pub struct OracleConfig { + /// Name of this oracle configuration (e.g., "bash", "sh"). + pub name: String, + /// Shell configuration for the oracle. + pub shell: ShellConfig, + /// Version string of the oracle. + pub version_str: Option, +} + +/// The mode in which to run tests. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum TestMode { + /// Compare test shell output against an oracle shell. + #[default] + Oracle, + /// Validate against inline expectations or snapshots only. + Expectation, + /// Both oracle comparison and expectation validation. + Hybrid, +} + +/// Configuration for the test runner. +#[derive(Clone, Debug)] +pub struct RunnerConfig { + /// The test mode to use. + pub mode: TestMode, + /// Configuration for the oracle shell (if using oracle mode). + pub oracle: Option, + /// Configuration for the test shell (brush). + pub test_shell: ShellConfig, + /// Directory containing test case YAML files. + pub test_cases_dir: PathBuf, + /// Directory for storing snapshots (relative to test case YAML files). + pub snapshot_dir_name: String, + /// Host OS ID (for filtering incompatible tests). + pub host_os_id: Option, + /// Active runtime platform tags (e.g., "wasi", "wasm"). Tests that + /// declare any of these in `incompatible_platforms` will be skipped. + pub platform_tags: HashSet, +} + +impl RunnerConfig { + /// Creates a new runner config with minimal safe defaults. + /// + /// N.B. Callers typically override `test_shell` via + /// `TestOptions::create_test_shell_config()`, which adds + /// platform-appropriate flags like `--input-backend=basic`. + pub fn new(test_shell_path: PathBuf, test_cases_dir: PathBuf) -> Self { + Self { + mode: TestMode::Expectation, + oracle: None, + test_shell: ShellConfig { + which: WhichShell::ShellUnderTest(test_shell_path), + default_args: vec![ + "--norc".into(), + "--noprofile".into(), + "--no-config".into(), + "--disable-bracketed-paste".into(), + "--disable-color".into(), + ], + default_path_var: None, + launcher: None, + }, + test_cases_dir, + snapshot_dir_name: String::from("snaps"), + host_os_id: crate::util::get_host_os_id(), + platform_tags: HashSet::new(), + } + } + + /// Sets the active runtime platform tags. + #[must_use] + pub fn with_platform_tags(mut self, tags: HashSet) -> Self { + self.platform_tags = tags; + self + } + + /// Sets the oracle configuration, enabling oracle comparison mode. + #[must_use] + pub fn with_oracle(mut self, oracle: OracleConfig) -> Self { + self.oracle = Some(oracle); + self.mode = TestMode::Oracle; + self + } + + /// Sets the test mode. + #[must_use] + pub const fn with_mode(mut self, mode: TestMode) -> Self { + self.mode = mode; + self + } + + /// Sets the snapshot directory name. + #[must_use] + pub fn with_snapshot_dir_name(mut self, name: impl Into) -> Self { + self.snapshot_dir_name = name.into(); + self + } + + /// Sets the default PATH variable for the test shell. + #[must_use] + pub fn with_test_path_var(mut self, path_var: Option) -> Self { + self.test_shell.default_path_var = path_var; + self + } +} + +/// Output format for test results. +#[derive(Clone, Copy, Default, clap::ValueEnum, Debug)] +pub enum OutputFormat { + /// Human-readable colored output. + #[default] + Pretty, + /// `JUnit` XML format. + Junit, + /// Minimal output. + Terse, +} + +/// Command-line options for the test harness. +#[derive(Clone, Parser, Debug)] +#[clap(version, about, disable_help_flag = true, disable_version_flag = true)] +pub struct TestOptions { + /// Display usage information. + #[clap(long = "help", action = clap::ArgAction::HelpLong)] + pub help: Option, + + /// Output format for test results. + #[clap(long = "format", default_value = "pretty")] + pub format: OutputFormat, + + /// Display full details on known failures. + #[clap(long = "known-failure-details")] + pub display_known_failure_details: bool, + + /// Display details regarding successful test cases. + #[clap(short = 'v', long = "verbose", env = "BRUSH_VERBOSE")] + pub verbose: bool, + + /// Enable a specific configuration. + #[clap(long = "enable-config")] + pub enabled_configs: Vec, + + /// List available tests without running them. + #[clap(long = "list")] + pub list_tests_only: bool, + + /// Exactly match filters (not just substring match). + #[clap(long = "exact")] + pub exact_match: bool, + + /// Optionally specify a non-default path for bash. + #[clap(long = "bash-path", default_value = "bash", env = "BASH_PATH")] + pub bash_path: PathBuf, + + /// Optionally specify a non-default path for brush. + #[clap(long = "brush-path", default_value = "", env = "BRUSH_PATH")] + pub brush_path: String, + + /// Optionally specify additional arguments for brush. + #[clap(long = "brush-args", default_value = "", env = "BRUSH_ARGS")] + pub brush_args: String, + + /// Optionally specify a launcher command to prepend when invoking brush + /// (e.g., "wasmtime run --" to execute a wasm build under wasmtime). + /// The string is split on whitespace; the first token becomes the program + /// to execute and the remainder are passed as leading arguments before + /// the brush binary path. + #[clap(long = "brush-launcher", default_value = "", env = "BRUSH_LAUNCHER")] + pub brush_launcher: String, + + /// Runtime platform tags (e.g., "wasi", "wasm") describing the + /// environment in which brush is being executed. Test cases that + /// declare any of these tags in `incompatible_platforms` will be + /// skipped. May be specified multiple times on the CLI or as a + /// space-separated value in the environment variable. + #[clap( + long = "brush-platform-tags", + value_delimiter = ' ', + env = "BRUSH_PLATFORM_TAGS" + )] + pub brush_platform_tags: Vec, + + /// Optionally specify path to test cases. + #[clap(long = "test-cases-path", env = "BRUSH_TEST_CASES")] + pub test_cases_path: Option, + + /// Optionally specify PATH variable to use in shells. + #[clap(long = "test-path-var", env = "BRUSH_TEST_PATH_VAR")] + pub test_path_var: Option, + + /// Show output from test cases (for compatibility only, has no effect). + #[clap(long = "show-output")] + pub show_output: bool, + + /// Capture output? (for compatibility only, has no effect). + #[clap(long = "nocapture")] + pub no_capture: bool, + + /// Colorize output? (for compatibility only, has no effect). + #[clap(long = "color", default_value_t = clap::ColorChoice::Auto)] + pub color: clap::ColorChoice, + + /// Run skipped tests only. + #[clap(long = "ignored")] + pub skipped_tests_only: bool, + + /// Unstable flags (for compatibility only, has no effect). + #[clap(short = 'Z')] + pub unstable_flag: Vec, + + /// Patterns for tests to be excluded. + #[clap(long = "skip")] + pub exclude_filters: Vec, + + /// Patterns for tests to be included. + pub include_filters: Vec, +} + +impl TestOptions { + /// Returns the configured platform tags as a set. + pub fn platform_tags(&self) -> HashSet { + self.brush_platform_tags.iter().cloned().collect() + } + + /// Builds the default `ShellConfig` for the shell under test based on + /// the common options (path, launcher, platform tags, extra args). + /// + /// Resolves the launcher binary to an absolute path (if one is + /// configured) because the test harness clears env vars — including + /// `PATH` — before spawning child processes. + pub fn create_test_shell_config(&self) -> anyhow::Result { + let mut default_args: Vec = vec![ + "--norc".into(), + "--noprofile".into(), + "--no-config".into(), + "--disable-bracketed-paste".into(), + "--disable-color".into(), + ]; + + // Use the basic input backend for native builds. WASI builds are + // compiled with `--features minimal` which doesn't include the basic + // backend, so passing this flag would cause a startup error. Omitting + // it lets brush pick its own default (Minimal on wasm targets). + if !self.platform_tags().contains("wasi") { + default_args.push("--input-backend=basic".into()); + } + + // Append any additional brush args specified by the caller. + self.brush_args.split_whitespace().for_each(|arg| { + default_args.push(arg.into()); + }); + + let launcher = if self.brush_launcher.is_empty() { + None + } else { + let mut tokens: Vec = self + .brush_launcher + .split_whitespace() + .map(Into::into) + .collect(); + crate::util::resolve_launcher_path(&mut tokens)?; + Some(tokens) + }; + + Ok(ShellConfig { + which: WhichShell::ShellUnderTest(PathBuf::from(&self.brush_path)), + default_args, + default_path_var: self.test_path_var.clone(), + launcher, + }) + } + + /// Returns whether the given config name should be enabled. + pub fn should_enable_config(&self, config: &str, default_configs: &[&str]) -> bool { + let enabled_configs = if self.enabled_configs.is_empty() { + default_configs.iter().map(|s| String::from(*s)).collect() + } else { + self.enabled_configs.clone() + }; + + enabled_configs.contains(&config.to_string()) + } + + /// Returns whether a test should run based on include/exclude filters. + pub fn should_run_test(&self, qualified_name: &str) -> bool { + if self.include_filters.is_empty() && self.exclude_filters.is_empty() { + return true; + } + + // If any include filters were given, then we are in opt-in mode. + if !self.include_filters.is_empty() + && !self.test_matches_filters(qualified_name, &self.include_filters) + { + return false; + } + + // In all cases, exclude filters may be used to exclude tests. + if !self.exclude_filters.is_empty() + && self.test_matches_filters(qualified_name, &self.exclude_filters) + { + return false; + } + + true + } + + fn test_matches_filters(&self, qualified_test_name: &str, filters: &[String]) -> bool { + if self.exact_match { + filters.iter().any(|f| f == qualified_test_name) + } else { + filters + .iter() + .any(|filter| qualified_test_name.contains(filter)) + } + } +} diff --git a/local/recipes/shells/brush/source/brush-test-harness/src/execution.rs b/local/recipes/shells/brush/source/brush-test-harness/src/execution.rs new file mode 100644 index 0000000000..2ed6cde0aa --- /dev/null +++ b/local/recipes/shells/brush/source/brush-test-harness/src/execution.rs @@ -0,0 +1,332 @@ +//! Execution logic for running shell commands. + +use crate::config::{ShellConfig, WhichShell}; +use crate::testcase::{ShellInvocation, TestCase, TestCaseSet, TestFile}; +use anyhow::{Context, Result}; +use assert_fs::fixture::{FileWriteStr, PathChild}; +#[cfg(pty)] +use std::os::unix::process::ExitStatusExt; +#[cfg(unix)] +use std::os::unix::{fs::PermissionsExt, process::CommandExt}; +use std::{path::PathBuf, process::ExitStatus}; + +/// Default timeout for test commands in seconds. +pub const DEFAULT_TIMEOUT_IN_SECONDS: u64 = 15; + +/// Result of running a shell command. +#[derive(Debug)] +pub struct RunResult { + /// Exit status of the command. + pub exit_status: ExitStatus, + /// Standard output. + pub stdout: String, + /// Standard error. + pub stderr: String, + /// Duration of the command. + pub duration: std::time::Duration, +} + +impl TestCase { + /// Runs this test case with the given shell configuration. + pub async fn run_shell( + &self, + shell_config: &ShellConfig, + working_dir: &assert_fs::TempDir, + ) -> Result { + let test_cmd = self.create_command_for_shell(shell_config, working_dir); + + let result = if self.pty { + self.run_command_with_pty(test_cmd).await? + } else { + self.run_command_with_stdin(test_cmd).await? + }; + + Ok(result) + } + + /// Creates the test files in the given temporary directory. + pub fn create_test_files_in( + &self, + temp_dir: &assert_fs::TempDir, + test_case_set: &TestCaseSet, + ) -> Result<()> { + for test_file in test_case_set + .common_test_files + .iter() + .chain(self.test_files.iter()) + { + Self::create_test_file(temp_dir, test_file, &test_case_set.source_dir)?; + } + + Ok(()) + } + + fn create_test_file( + temp_dir: &assert_fs::TempDir, + test_file: &TestFile, + source_dir: &std::path::Path, + ) -> Result<()> { + let test_file_path = temp_dir.child(test_file.path.as_path()); + + if let Some(source_path) = &test_file.source_path { + if !test_file.contents.is_empty() { + return Err(anyhow::anyhow!( + "test file {} has both contents and source_path", + test_file_path.to_string_lossy() + )); + } + + if source_path.is_absolute() { + return Err(anyhow::anyhow!( + "source_path {} is not a relative path", + source_path.to_string_lossy() + )); + } + + let abs_source_path = source_dir.join(source_path); + + let source_contents = std::fs::read_to_string(&abs_source_path) + .with_context(|| format!("reading {}", abs_source_path.to_string_lossy()))?; + + test_file_path.write_str(source_contents.as_str())?; + } else { + test_file_path.write_str(test_file.contents.as_str())?; + } + + #[cfg(unix)] + if test_file.executable { + // chmod u+x + let mut perms = test_file_path.metadata()?.permissions(); + perms.set_mode(perms.mode() | 0o100); + std::fs::set_permissions(test_file_path, perms)?; + } + + Ok(()) + } + + /// Constructs a `Command` to invoke the given shell binary, optionally + /// prepending a launcher (e.g., `["wasmtime", "run", "--"]`). When a + /// launcher is provided, the first element becomes the program to execute + /// and the rest are passed as leading arguments before the shell binary path. + fn new_shell_command( + shell_path: &std::path::Path, + launcher: Option<&[String]>, + ) -> std::process::Command { + if let Some([program, leading_args @ ..]) = launcher { + let mut cmd = std::process::Command::new(program); + cmd.args(leading_args); + cmd.arg(shell_path); + cmd + } else { + std::process::Command::new(shell_path) + } + } + + fn create_command_for_shell( + &self, + shell_config: &ShellConfig, + working_dir: &assert_fs::TempDir, + ) -> std::process::Command { + let (mut test_cmd, coverage_target_dir) = match self.invocation { + ShellInvocation::ExecShellBinary => match &shell_config.which { + WhichShell::ShellUnderTest(name) => { + let cli_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let default_target_dir = || cli_dir.parent().unwrap().join("target"); + let target_dir = std::env::var("CARGO_TARGET_DIR") + .ok() + .map_or_else(default_target_dir, PathBuf::from); + ( + Self::new_shell_command(name, shell_config.launcher.as_deref()), + Some(target_dir), + ) + } + // Launcher only applies to the shell under test; the oracle is invoked directly. + WhichShell::NamedShell(name) => (Self::new_shell_command(name, None), None), + }, + ShellInvocation::ExecScript(_) => unimplemented!("exec script test"), + }; + + if matches!(shell_config.which, WhichShell::ShellUnderTest(_)) { + for arg in &self.additional_test_args { + test_cmd.arg(arg); + } + } + + for arg in &shell_config.default_args { + if !self.removed_default_args.contains(arg) { + test_cmd.arg(arg); + } + } + + // Clear all environment vars for consistency. + test_cmd.args(&self.args).env_clear(); + + // Set locale to C for consistent behavior across systems. + test_cmd.env("LC_ALL", "C"); + // Hard-code a well known prompt for PS1. + test_cmd.env("PS1", "test$ "); + // Try to get decent backtraces when problems get hit. + test_cmd.env("RUST_BACKTRACE", "1"); + // Compute a PATH that contains what we need. + test_cmd.env("PATH", shell_config.compute_test_path_var()); + + // Set up any env vars needed for collecting coverage data. + if let Some(coverage_target_dir) = &coverage_target_dir { + test_cmd.env("CARGO_LLVM_COV_TARGET_DIR", coverage_target_dir); + test_cmd.env( + "LLVM_PROFILE_FILE", + coverage_target_dir.join("brush-%p-%40m.profraw"), + ); + } + + for (k, v) in &self.env { + test_cmd.env(k, v); + } + + if let Some(home_dir) = &self.home_dir { + let abs_home_dir = if home_dir.is_relative() { + working_dir.join(home_dir) + } else { + home_dir.to_owned() + }; + + test_cmd.env("HOME", abs_home_dir.to_string_lossy().to_string()); + } + + test_cmd.current_dir(working_dir.to_string_lossy().to_string()); + + test_cmd + } + + #[expect(clippy::unused_async)] + #[cfg(not(pty))] + async fn run_command_with_pty(&self, _cmd: std::process::Command) -> Result { + Err(anyhow::anyhow!("pty test not supported on this platform")) + } + + #[expect(clippy::unused_async)] + #[cfg(pty)] + async fn run_command_with_pty(&self, cmd: std::process::Command) -> Result { + use crate::util::{make_expectrl_output_readable, read_expectrl_log}; + use expectrl::{Expect, process::Termios as _}; + + let mut log = Vec::new(); + let writer = std::io::Cursor::new(&mut log); + + let start_time = std::time::Instant::now(); + let mut p = expectrl::session::log(expectrl::Session::spawn(cmd)?, writer)?; + p.set_echo(true)?; + + if let Some(stdin) = &self.stdin { + for line in stdin.lines() { + if let Some(expectation) = line.strip_prefix("#expect:") { + if let Err(inner) = p.expect(expectation) { + return Ok(RunResult { + exit_status: ExitStatus::from_raw(1), + stdout: read_expectrl_log(log).unwrap_or_default(), + stderr: std::format!("failed to expect '{expectation}': {inner}"), + duration: start_time.elapsed(), + }); + } + } else if let Some(control_code) = line.strip_prefix("#send:") { + match control_code.to_lowercase().as_str() { + "ctrl+d" => p.send(expectrl::ControlCode::EndOfTransmission)?, + "tab" => p.send(expectrl::ControlCode::HorizontalTabulation)?, + "enter" => p.send(expectrl::ControlCode::LineFeed)?, + _ => (), + } + } else if line.trim() == "#expect-prompt" { + if let Err(inner) = p.expect("test$ ") { + return Ok(RunResult { + exit_status: ExitStatus::from_raw(1), + stdout: read_expectrl_log(log).unwrap_or_default(), + stderr: std::format!("failed to expect prompt: {inner}"), + duration: start_time.elapsed(), + }); + } + } else { + p.send(line)?; + } + } + } + + if let Err(inner) = p.expect(expectrl::Eof) { + return Ok(RunResult { + exit_status: ExitStatus::from_raw(1), + stdout: read_expectrl_log(log).unwrap_or_default(), + stderr: std::format!("failed to expect EOF: {inner}"), + duration: start_time.elapsed(), + }); + } + + let mut wait_status = p.get_process().status()?; + + if matches!(wait_status, expectrl::process::unix::WaitStatus::StillAlive) { + // Try to terminate it safely. + p.get_process_mut() + .kill(expectrl::process::unix::Signal::SIGTERM)?; + wait_status = p.get_process().wait()?; + } + + let duration = start_time.elapsed(); + let output = read_expectrl_log(log)?; + let cleaned = make_expectrl_output_readable(output); + + match wait_status { + expectrl::process::unix::WaitStatus::Exited(_, code) => Ok(RunResult { + exit_status: ExitStatus::from_raw(code), + stdout: cleaned, + stderr: String::new(), + duration, + }), + expectrl::process::unix::WaitStatus::Signaled(_, _, _) => { + Err(anyhow::anyhow!("process was signaled")) + } + _ => Err(anyhow::anyhow!( + "unexpected status for process: {wait_status:?}" + )), + } + } + + #[expect(clippy::unused_async)] + #[allow(unused_mut, reason = "only mutated on some platforms")] + async fn run_command_with_stdin(&self, mut cmd: std::process::Command) -> Result { + // SAFETY: + // To avoid bash trying to directly access /dev/tty and generate tty-related signals, + // we create a new session for the child process. The standard library has a setsid() + // API but it's unstable, so we use nix here. Calling pre_exec can be unsafe as + // it runs in the child process after fork() but before exec(), and there are constraints + // around what can be safely done in that context. However, calling setsid() is generally + // considered safe as it doesn't allocate memory or perform complex operations to forked + // state. + #[cfg(unix)] + unsafe { + cmd.pre_exec(|| { + let _ = nix::unistd::setsid(); + Ok(()) + }) + }; + + let mut test_cmd = assert_cmd::Command::from_std(cmd); + + test_cmd.timeout(std::time::Duration::from_secs( + self.timeout_in_seconds + .unwrap_or(DEFAULT_TIMEOUT_IN_SECONDS), + )); + + if let Some(stdin) = &self.stdin { + test_cmd.write_stdin(stdin.as_bytes()); + } + + let start_time = std::time::Instant::now(); + let cmd_result = test_cmd.output()?; + let duration = start_time.elapsed(); + + Ok(RunResult { + exit_status: cmd_result.status, + stdout: String::from_utf8_lossy(cmd_result.stdout.as_slice()).to_string(), + stderr: String::from_utf8_lossy(cmd_result.stderr.as_slice()).to_string(), + duration, + }) + } +} diff --git a/local/recipes/shells/brush/source/brush-test-harness/src/lib.rs b/local/recipes/shells/brush/source/brush-test-harness/src/lib.rs new file mode 100644 index 0000000000..de875c82e3 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-test-harness/src/lib.rs @@ -0,0 +1,40 @@ +//! Test harness library for brush shell integration tests. +//! +//! This crate provides a unified framework for running YAML-based integration tests +//! that support both oracle-based comparison (comparing brush output against bash) +//! and expectation-based testing (inline expectations or insta snapshots). +//! +//! # Modes of Operation +//! +//! 1. **Oracle comparison**: Runs both an oracle shell (e.g., bash) and the test shell (brush), +//! comparing their outputs. This is the traditional compatibility testing mode. +//! +//! 2. **Expectation-based**: Runs only the test shell and compares against inline expectations +//! specified in the YAML or against insta snapshots. +//! +//! 3. **Hybrid**: Combines both modes - runs oracle comparison AND validates against expectations. +//! Both must pass for the test to succeed. + +#![cfg(any(unix, windows))] +#![allow(clippy::missing_panics_doc)] +#![allow(clippy::unwrap_used)] + +mod comparison; +mod config; +mod execution; +mod reporting; +mod runner; +mod testcase; +pub mod util; + +pub use comparison::{ + DirComparison, DirComparisonEntry, DurationComparison, ExitStatusComparison, + ExpectationComparison, OracleComparison, SingleExpectationComparison, SnapshotResult, + StringComparison, TestComparison, +}; +pub use config::{ + OracleConfig, OutputFormat, RunnerConfig, ShellConfig, TestMode, TestOptions, WhichShell, +}; +pub use execution::RunResult; +pub use runner::TestRunner; +pub use testcase::{ShellInvocation, TestCase, TestCaseSet, TestFile}; diff --git a/local/recipes/shells/brush/source/brush-test-harness/src/reporting.rs b/local/recipes/shells/brush/source/brush-test-harness/src/reporting.rs new file mode 100644 index 0000000000..0252723097 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-test-harness/src/reporting.rs @@ -0,0 +1,486 @@ +//! Reporting utilities for test results. + +use crate::comparison::{ + DirComparison, DirComparisonEntry, ExitStatusComparison, ExpectationComparison, + OracleComparison, SingleExpectationComparison, StringComparison, TestComparison, +}; +use crate::config::{OutputFormat, TestOptions}; +use crate::util::{make_expectrl_output_readable, write_diff}; +use anyhow::Result; +use colored::Colorize; +use std::io::Write; + +/// Result of running a single test case. +pub struct TestCaseResult { + /// Name of the test case. + pub name: Option, + /// Whether the test succeeded. + pub success: bool, + /// Whether the test was skipped. + pub skip: bool, + /// Whether this is a known failure. + pub known_failure: bool, + /// The comparison result. + pub comparison: TestComparison, +} + +impl TestCaseResult { + /// Reports this result in pretty format. + pub fn report_pretty(&self, options: &TestOptions) -> Result<()> { + self.write_details(std::io::stderr(), options) + } + + /// Writes the details of this result to a writer. + pub fn write_details(&self, mut writer: W, options: &TestOptions) -> Result<()> { + if self.skip { + return Ok(()); + } + + if !options.verbose { + if (!self.comparison.is_failure() && !self.known_failure) + || (self.comparison.is_failure() && self.known_failure) + { + return Ok(()); + } + } + + write!( + writer, + "* {}: [{}]... ", + "Test case".bright_yellow(), + self.name + .as_ref() + .map_or_else(|| "(unnamed)", |n| n.as_str()) + .italic() + )?; + + if !self.comparison.is_failure() { + if self.known_failure { + writeln!(writer, "{}", "unexpected success.".bright_red())?; + } else { + writeln!(writer, "{}", "ok.".bright_green())?; + return Ok(()); + } + } else if self.known_failure { + writeln!(writer, "{}", "known failure.".bright_magenta())?; + if !options.display_known_failure_details { + return Ok(()); + } + } + + writeln!(writer)?; + + // Report oracle comparison if present + if let Some(oracle) = &self.comparison.oracle { + self.write_oracle_details(&mut writer, oracle, options)?; + } + + // Report expectation comparison + if self.comparison.expectation.has_any_checks() { + self.write_expectation_details(&mut writer, &self.comparison.expectation)?; + } + + if !self.success { + writeln!(writer, " {}", "FAILED.".bright_red())?; + } + + Ok(()) + } + + #[expect(clippy::too_many_lines)] + #[expect(clippy::unused_self)] + fn write_oracle_details( + &self, + writer: &mut W, + oracle: &OracleComparison, + options: &TestOptions, + ) -> Result<()> { + writeln!(writer, " {} comparison:", "Oracle".cyan())?; + + match oracle.exit_status { + ExitStatusComparison::Ignored => writeln!(writer, " status {}", "ignored".cyan())?, + ExitStatusComparison::Same(status) => { + writeln!( + writer, + " status matches ({}) {}", + format!("{status}").green(), + "✔️".green() + )?; + } + ExitStatusComparison::TestDiffers { + test_exit_status, + oracle_exit_status, + } => { + writeln!( + writer, + " status mismatch: {} from oracle vs. {} from test", + format!("{oracle_exit_status}").cyan(), + format!("{test_exit_status}").bright_red() + )?; + } + } + + match &oracle.stdout { + StringComparison::Ignored { + test_string, + oracle_string, + } => { + writeln!(writer, " stdout {}", "ignored".cyan())?; + + writeln!( + writer, + " {}", + "------ Oracle: stdout ---------------------------------".cyan() + )?; + writeln!(writer, "{}", indent::indent_all_by(10, oracle_string))?; + + writeln!( + writer, + " {}", + "------ Oracle: stdout [cleaned]------------------------".cyan() + )?; + writeln!( + writer, + "{}", + indent::indent_all_by(10, make_expectrl_output_readable(oracle_string)) + )?; + + writeln!( + writer, + " {}", + "------ Test: stdout ---------------------------------".cyan() + )?; + writeln!(writer, "{}", indent::indent_all_by(10, test_string))?; + + writeln!( + writer, + " {}", + "------ Test: stdout [cleaned]------------------------".cyan() + )?; + + writeln!( + writer, + "{}", + indent::indent_all_by(10, make_expectrl_output_readable(test_string)) + )?; + } + StringComparison::Same(s) => { + writeln!(writer, " stdout matches {}", "✔️".green())?; + + if options.verbose { + writeln!( + writer, + " {}", + "------ Oracle <> Test: stdout ---------------------------------".cyan() + )?; + + writeln!(writer, "{}", indent::indent_all_by(10, s))?; + } + } + StringComparison::TestDiffers { + test_string: t, + oracle_string: o, + } => { + writeln!(writer, " stdout {}", "DIFFERS:".bright_red())?; + + writeln!( + writer, + " {}", + "------ Oracle <> Test: stdout ---------------------------------".cyan() + )?; + + write_diff(writer, 10, o.as_str(), t.as_str())?; + + writeln!( + writer, + " {}", + "---------------------------------------------------------------".cyan() + )?; + } + } + + match &oracle.stderr { + StringComparison::Ignored { .. } => { + writeln!(writer, " stderr {}", "ignored".cyan())?; + } + StringComparison::Same(s) => { + writeln!(writer, " stderr matches {}", "✔️".green())?; + + if options.verbose { + writeln!( + writer, + " {}", + "------ Oracle <> Test: stderr ---------------------------------".cyan() + )?; + + writeln!(writer, "{}", indent::indent_all_by(10, s))?; + } + } + StringComparison::TestDiffers { + test_string: t, + oracle_string: o, + } => { + writeln!(writer, " stderr {}", "DIFFERS:".bright_red())?; + + writeln!( + writer, + " {}", + "------ Oracle <> Test: stderr ---------------------------------".cyan() + )?; + + write_diff(writer, 10, o.as_str(), t.as_str())?; + + writeln!( + writer, + " {}", + "---------------------------------------------------------------".cyan() + )?; + } + } + + match &oracle.temp_dir { + DirComparison::Ignored => writeln!(writer, " temp dir {}", "ignored".cyan())?, + DirComparison::Same => writeln!(writer, " temp dir matches {}", "✔️".green())?, + DirComparison::TestDiffers(entries) => { + writeln!(writer, " temp dir {}", "DIFFERS".bright_red())?; + + for entry in entries { + const INDENT: &str = " "; + match entry { + DirComparisonEntry::Different( + left_path, + left_contents, + right_path, + right_contents, + ) => { + writeln!( + writer, + "{INDENT}oracle file {} differs from test file {}", + left_path.to_string_lossy(), + right_path.to_string_lossy() + )?; + + writeln!( + writer, + "{INDENT}{}", + "------ Oracle <> Test: file ---------------------------------" + .cyan() + )?; + + write_diff( + writer, + 10, + left_contents.as_str(), + right_contents.as_str(), + )?; + + writeln!( + writer, + " {}", + "---------------------------------------------------------------" + .cyan() + )?; + } + DirComparisonEntry::LeftOnly(p) => { + writeln!( + writer, + "{INDENT}file missing from test dir: {}", + p.to_string_lossy() + )?; + } + DirComparisonEntry::RightOnly(p) => { + writeln!( + writer, + "{INDENT}unexpected file in test dir: {}", + p.to_string_lossy() + )?; + } + } + } + } + } + + Ok(()) + } + + #[expect(clippy::unused_self)] + fn write_expectation_details( + &self, + writer: &mut W, + expectation: &ExpectationComparison, + ) -> Result<()> { + writeln!(writer, " {} check:", "Expectation".cyan())?; + + // Exit code + match &expectation.exit_code { + SingleExpectationComparison::NotSpecified => {} + SingleExpectationComparison::Matches => { + writeln!(writer, " exit code matches {}", "✔️".green())?; + } + SingleExpectationComparison::Differs { expected, actual } => { + writeln!( + writer, + " exit code {}: expected {}, got {}", + "DIFFERS".bright_red(), + expected.cyan(), + actual.bright_red() + )?; + } + } + + // Stdout + match &expectation.stdout { + SingleExpectationComparison::NotSpecified => {} + SingleExpectationComparison::Matches => { + writeln!(writer, " stdout matches {}", "✔️".green())?; + } + SingleExpectationComparison::Differs { expected, actual } => { + writeln!(writer, " stdout {}", "DIFFERS:".bright_red())?; + write_diff(writer, 8, expected.as_str(), actual.as_str())?; + } + } + + // Stderr + match &expectation.stderr { + SingleExpectationComparison::NotSpecified => {} + SingleExpectationComparison::Matches => { + writeln!(writer, " stderr matches {}", "✔️".green())?; + } + SingleExpectationComparison::Differs { expected, actual } => { + writeln!(writer, " stderr {}", "DIFFERS:".bright_red())?; + write_diff(writer, 8, expected.as_str(), actual.as_str())?; + } + } + + // Snapshot + if expectation.snapshot_used { + if let Some(result) = &expectation.snapshot_result { + match result { + crate::comparison::SnapshotResult::Matches => { + writeln!(writer, " snapshot matches {}", "✔️".green())?; + } + crate::comparison::SnapshotResult::Differs { message } => { + writeln!( + writer, + " snapshot {}: {}", + "DIFFERS".bright_red(), + message + )?; + } + } + } + } + + Ok(()) + } +} + +/// Results from running a set of test cases. +pub struct TestCaseSetResults { + /// Name of the test case set. + pub name: Option, + /// Name of the configuration used. + pub config_name: String, + /// Number of successful tests. + pub success_count: u32, + /// Number of skipped tests. + pub skip_count: u32, + /// Number of known failures. + pub known_failure_count: u32, + /// Number of failed tests. + pub fail_count: u32, + /// Individual test case results. + pub test_case_results: Vec, + /// Total duration comparison for successful tests. + pub success_duration: std::time::Duration, +} + +impl TestCaseSetResults { + /// Reports these results in pretty format. + pub fn report_pretty(&self, options: &TestOptions) -> Result<()> { + self.write_details(std::io::stderr(), options) + } + + fn write_details(&self, mut writer: W, options: &TestOptions) -> Result<()> { + if options.verbose { + writeln!( + writer, + "=================== {}: [{}/{}] ===================", + "Running test case set".blue(), + self.name + .as_ref() + .map_or_else(|| "(unnamed)", |n| n.as_str()) + .italic(), + self.config_name.magenta(), + )?; + } + + for test_case_result in &self.test_case_results { + test_case_result.report_pretty(options)?; + } + + if options.verbose { + writeln!( + writer, + " successful cases ran in {:?}", + self.success_duration + )?; + } + + Ok(()) + } +} + +/// Reports test results based on the configured output format. +pub fn report_results(results: Vec, options: &TestOptions) -> Result<()> { + match options.format { + OutputFormat::Pretty => report_results_pretty(results, options), + OutputFormat::Junit => report_results_junit(results, options), + OutputFormat::Terse => Ok(()), + } +} + +fn report_results_pretty(results: Vec, options: &TestOptions) -> Result<()> { + for result in results { + result.report_pretty(options)?; + } + Ok(()) +} + +fn report_results_junit(results: Vec, options: &TestOptions) -> Result<()> { + let mut report = junit_report::Report::new(); + + for result in results { + let mut suite = junit_report::TestSuite::new(result.name.unwrap_or(String::new()).as_str()); + for r in result.test_case_results { + let test_case_name = r.name.as_deref().unwrap_or(""); + let mut test_case: junit_report::TestCase = if r.success { + junit_report::TestCase::success(test_case_name, r.comparison.duration.try_into()?) + } else if r.known_failure { + junit_report::TestCase::skipped(test_case_name) + } else { + junit_report::TestCase::failure( + test_case_name, + r.comparison.duration.try_into()?, + "test failure", + "failed", + ) + }; + + let mut output_buf: Vec = vec![]; + r.write_details(&mut output_buf, options)?; + + let output_as_string = String::from_utf8(output_buf)?; + test_case.set_system_out(strip_ansi_escapes::strip_str(output_as_string).as_str()); + + suite.add_testcase(test_case); + } + + report.add_testsuite(suite); + } + + report.write_xml(std::io::stdout())?; + writeln!(std::io::stdout())?; + + Ok(()) +} diff --git a/local/recipes/shells/brush/source/brush-test-harness/src/runner.rs b/local/recipes/shells/brush/source/brush-test-harness/src/runner.rs new file mode 100644 index 0000000000..258d115662 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-test-harness/src/runner.rs @@ -0,0 +1,717 @@ +//! Test runner implementation. + +use crate::comparison::{ + DirComparison, DurationComparison, ExitStatusComparison, ExpectationComparison, + OracleComparison, SingleExpectationComparison, SnapshotResult, StringComparison, + TestComparison, diff_dirs, output_matches, +}; +use crate::config::{RunnerConfig, TestMode, TestOptions}; +use crate::reporting::{TestCaseResult, TestCaseSetResults}; +use crate::testcase::{TestCase, TestCaseSet}; +use anyhow::{Context, Result}; +use colored::Colorize; + +/// The main test runner. +pub struct TestRunner { + config: RunnerConfig, + options: TestOptions, +} + +impl TestRunner { + /// Creates a new test runner with the given configuration and options. + pub const fn new(config: RunnerConfig, options: TestOptions) -> Self { + Self { config, options } + } + + /// Runs all tests and returns success/failure. + pub async fn run(&self) -> Result { + let mut success_count = 0; + let mut skip_count = 0; + let mut known_failure_count = 0; + let mut fail_count = 0; + let mut join_handles = vec![]; + let mut success_duration = std::time::Duration::default(); + + // Generate a glob pattern to find all the YAML test case files. + let glob_pattern = self + .config + .test_cases_dir + .join("**/*.yaml") + .to_string_lossy() + .to_string(); + + if self.options.verbose { + eprintln!("Running test cases: {glob_pattern}"); + } + + // Spawn each test case set separately. + for entry in glob::glob(glob_pattern.as_ref()).unwrap() { + let entry = entry.unwrap(); + + let yaml_file = std::fs::File::open(entry.as_path())?; + let mut test_case_set: TestCaseSet = serde_yaml::from_reader(yaml_file) + .context(format!("parsing {}", entry.to_string_lossy()))?; + + test_case_set.source_dir = entry.parent().unwrap().to_path_buf(); + test_case_set.source_file.clone_from(&entry); + + if self.options.list_tests_only { + for test_case in &test_case_set.cases { + let case_is_skipped = self.should_skip_test(&test_case_set, test_case)?; + if case_is_skipped == self.options.skipped_tests_only { + println!( + "{}::{}: test", + test_case_set.name.as_deref().unwrap_or("unnamed"), + test_case.name.as_deref().unwrap_or("unnamed"), + ); + } + } + } else { + let config = self.config.clone(); + let options = self.options.clone(); + + join_handles.push(tokio::spawn(async move { + run_test_case_set(test_case_set, config, options).await + })); + } + } + + if self.options.list_tests_only { + return Ok(true); + } + + // Await all results. + let mut all_results = vec![]; + for join_handle in join_handles { + let results = join_handle.await??; + + success_count += results.success_count; + skip_count += results.skip_count; + known_failure_count += results.known_failure_count; + fail_count += results.fail_count; + success_duration += results.success_duration; + + all_results.push(results); + } + + crate::reporting::report_results(all_results, &self.options)?; + + if matches!(self.options.format, crate::config::OutputFormat::Pretty) { + let formatted_fail_count = if fail_count > 0 { + fail_count.to_string().red() + } else { + fail_count.to_string().green() + }; + + let formatted_known_failure_count = if known_failure_count > 0 { + known_failure_count.to_string().magenta() + } else { + known_failure_count.to_string().green() + }; + + let formatted_skip_count = if skip_count > 0 { + skip_count.to_string().cyan() + } else { + skip_count.to_string().green() + }; + + eprintln!( + "================================================================================" + ); + eprintln!( + "{} test case(s) ran: {} succeeded, {} failed, {} known to fail, {} skipped.", + success_count + fail_count + known_failure_count, + success_count.to_string().green(), + formatted_fail_count, + formatted_known_failure_count, + formatted_skip_count, + ); + eprintln!("duration of successful tests: {success_duration:?}"); + eprintln!( + "================================================================================" + ); + } + + Ok(fail_count == 0) + } + + fn should_skip_test(&self, test_case_set: &TestCaseSet, test_case: &TestCase) -> Result { + // Check incompatible configs (set-level and per-case). + if let Some(oracle) = &self.config.oracle { + if test_case_set.incompatible_configs.contains(&oracle.name) + || test_case.incompatible_configs.contains(&oracle.name) + { + return Ok(true); + } + } + + // Check incompatible OS + if let Some(host_os_id) = &self.config.host_os_id { + if test_case.incompatible_os.contains(host_os_id) { + return Ok(true); + } + } + + // Check incompatible runtime platforms (set-level and per-case). + if test_case_set + .incompatible_platforms + .iter() + .chain(test_case.incompatible_platforms.iter()) + .any(|p| self.config.platform_tags.contains(p)) + { + return Ok(true); + } + + // Check oracle version constraints + if let Some(oracle) = &self.config.oracle { + if test_case.min_oracle_version.is_some() || test_case.max_oracle_version.is_some() { + if let Some(actual_oracle_version_str) = &oracle.version_str { + let actual_oracle_version = + version_compare::Version::from(actual_oracle_version_str.as_str()) + .ok_or_else(|| anyhow::anyhow!("failed to parse oracle version"))?; + + if let Some(min_oracle_version_str) = &test_case.min_oracle_version { + let min_oracle_version = version_compare::Version::from( + min_oracle_version_str, + ) + .ok_or_else(|| anyhow::anyhow!("failed to parse min oracle version"))?; + + if matches!( + actual_oracle_version.compare(min_oracle_version), + version_compare::Cmp::Lt + ) { + return Ok(true); + } + } + + if let Some(max_oracle_version_str) = &test_case.max_oracle_version { + let max_oracle_version = version_compare::Version::from( + max_oracle_version_str, + ) + .ok_or_else(|| anyhow::anyhow!("failed to parse max oracle version"))?; + + if matches!( + actual_oracle_version.compare(max_oracle_version), + version_compare::Cmp::Gt + ) { + return Ok(true); + } + } + } + } + } + + // Check filters + let test_case_set_name = test_case_set.name.as_deref().unwrap_or(""); + let test_case_name = test_case.name.as_deref().unwrap_or(""); + + if test_case_set_name.is_empty() || test_case_name.is_empty() { + return Ok(false); + } + + let qualified_name = format!("{test_case_set_name}::{test_case_name}"); + if !self.options.should_run_test(&qualified_name) { + return Ok(true); + } + + Ok(test_case.skip) + } +} + +async fn run_test_case_set( + test_case_set: TestCaseSet, + config: RunnerConfig, + options: TestOptions, +) -> Result { + let mut success_count = 0; + let mut skip_count = 0; + let mut known_failure_count = 0; + let mut fail_count = 0; + let mut success_duration = std::time::Duration::default(); + let mut test_case_results = vec![]; + + for test_case in &test_case_set.cases { + let runner = TestRunner::new(config.clone(), options.clone()); + let case_is_skipped = runner.should_skip_test(&test_case_set, test_case)?; + + let test_case_result = if case_is_skipped == options.skipped_tests_only { + run_single_test(&test_case_set, test_case, &config).await? + } else { + TestCaseResult { + success: true, + comparison: TestComparison::skipped(), + name: test_case.name.clone(), + skip: true, + known_failure: test_case.known_failure, + } + }; + + if test_case_result.skip { + skip_count += 1; + } else if test_case_result.success { + if test_case.known_failure { + fail_count += 1; + } else { + success_count += 1; + success_duration += test_case_result.comparison.duration; + } + } else if test_case.known_failure { + known_failure_count += 1; + } else { + fail_count += 1; + } + + test_case_results.push(test_case_result); + } + + let config_name = config + .oracle + .map_or_else(|| String::from("brush"), |o| o.name); + + Ok(TestCaseSetResults { + name: test_case_set.name.clone(), + config_name, + test_case_results, + success_count, + skip_count, + known_failure_count, + fail_count, + success_duration, + }) +} + +async fn run_single_test( + test_case_set: &TestCaseSet, + test_case: &TestCase, + config: &RunnerConfig, +) -> Result { + let start_time = std::time::Instant::now(); + + // Determine what comparisons to perform + let should_run_oracle = config.oracle.is_some() + && !test_case.skip_oracle + && matches!(config.mode, TestMode::Oracle | TestMode::Hybrid); + + let should_check_expectations = test_case.has_expectations() + || matches!(config.mode, TestMode::Expectation | TestMode::Hybrid); + + // Run oracle comparison if needed + let (oracle_comparison, test_result, test_temp_dir) = if should_run_oracle { + let oracle_config = config.oracle.as_ref().unwrap(); + let (oracle_comp, test_res) = + run_oracle_comparison(test_case_set, test_case, config, oracle_config).await?; + (Some(oracle_comp), Some(test_res), None) + } else { + // Run test shell only + let test_temp_dir = assert_fs::TempDir::new()?; + test_case.create_test_files_in(&test_temp_dir, test_case_set)?; + let test_res = test_case + .run_shell(&config.test_shell, &test_temp_dir) + .await?; + (None, Some(test_res), Some(test_temp_dir)) + }; + + // Check expectations + let expectation_comparison = if should_check_expectations { + if let Some(test_res) = &test_result { + check_expectations( + test_case_set, + test_case, + test_res, + test_temp_dir.as_ref(), + config, + ) + } else { + ExpectationComparison::not_specified() + } + } else { + ExpectationComparison::not_specified() + }; + + let duration = start_time.elapsed(); + + let comparison = TestComparison { + oracle: oracle_comparison, + expectation: expectation_comparison, + duration, + }; + + let success = !comparison.is_failure(); + + Ok(TestCaseResult { + success, + comparison, + name: test_case.name.clone(), + skip: false, + known_failure: test_case.known_failure, + }) +} + +async fn run_oracle_comparison( + test_case_set: &TestCaseSet, + test_case: &TestCase, + config: &RunnerConfig, + oracle_config: &crate::config::OracleConfig, +) -> Result<(OracleComparison, crate::execution::RunResult)> { + // Run oracle + let oracle_temp_dir = assert_fs::TempDir::new()?; + test_case.create_test_files_in(&oracle_temp_dir, test_case_set)?; + let oracle_result = test_case + .run_shell(&oracle_config.shell, &oracle_temp_dir) + .await?; + + // Run test shell + let test_temp_dir = assert_fs::TempDir::new()?; + test_case.create_test_files_in(&test_temp_dir, test_case_set)?; + let test_result = test_case + .run_shell(&config.test_shell, &test_temp_dir) + .await?; + + // Build comparison + let mut comparison = OracleComparison { + exit_status: ExitStatusComparison::Ignored, + stdout: StringComparison::Ignored { + test_string: String::new(), + oracle_string: String::new(), + }, + stderr: StringComparison::Ignored { + test_string: String::new(), + oracle_string: String::new(), + }, + temp_dir: DirComparison::Ignored, + duration: DurationComparison { + oracle: oracle_result.duration, + test: test_result.duration, + }, + }; + + // Compare exit status + if test_case.ignore_exit_status { + comparison.exit_status = ExitStatusComparison::Ignored; + } else if oracle_result.exit_status == test_result.exit_status { + comparison.exit_status = ExitStatusComparison::Same(oracle_result.exit_status); + } else { + comparison.exit_status = ExitStatusComparison::TestDiffers { + test_exit_status: test_result.exit_status, + oracle_exit_status: oracle_result.exit_status, + }; + } + + // Compare stdout + if test_case.ignore_stdout { + comparison.stdout = StringComparison::Ignored { + test_string: test_result.stdout.clone(), + oracle_string: oracle_result.stdout, + }; + } else if output_matches( + &oracle_result.stdout, + &test_result.stdout, + test_case.ignore_whitespace, + ) { + comparison.stdout = StringComparison::Same(oracle_result.stdout); + } else { + comparison.stdout = StringComparison::TestDiffers { + test_string: test_result.stdout.clone(), + oracle_string: oracle_result.stdout, + }; + } + + // Compare stderr + if test_case.ignore_stderr { + comparison.stderr = StringComparison::Ignored { + test_string: test_result.stderr.clone(), + oracle_string: oracle_result.stderr, + }; + } else if output_matches( + &oracle_result.stderr, + &test_result.stderr, + test_case.ignore_whitespace, + ) { + comparison.stderr = StringComparison::Same(oracle_result.stderr); + } else { + comparison.stderr = StringComparison::TestDiffers { + test_string: test_result.stderr.clone(), + oracle_string: oracle_result.stderr, + }; + } + + // Compare temporary directory contents + comparison.temp_dir = diff_dirs(oracle_temp_dir.path(), test_temp_dir.path())?; + + Ok((comparison, test_result)) +} + +fn check_expectations( + test_case_set: &TestCaseSet, + test_case: &TestCase, + test_result: &crate::execution::RunResult, + test_temp_dir: Option<&assert_fs::TempDir>, + config: &RunnerConfig, +) -> ExpectationComparison { + let mut comparison = ExpectationComparison::not_specified(); + + // Check inline expectations + if let Some(expected_exit_code) = test_case.expected_exit_code { + let actual_code = test_result.exit_status.code().unwrap_or(-1); + if actual_code == expected_exit_code { + comparison.exit_code = SingleExpectationComparison::Matches; + } else { + comparison.exit_code = SingleExpectationComparison::Differs { + expected: expected_exit_code.to_string(), + actual: actual_code.to_string(), + }; + } + } + + if let Some(expected_stdout) = &test_case.expected_stdout { + if output_matches( + expected_stdout, + &test_result.stdout, + test_case.ignore_whitespace, + ) { + comparison.stdout = SingleExpectationComparison::Matches; + } else { + comparison.stdout = SingleExpectationComparison::Differs { + expected: expected_stdout.clone(), + actual: test_result.stdout.clone(), + }; + } + } + + if let Some(expected_stderr) = &test_case.expected_stderr { + if output_matches( + expected_stderr, + &test_result.stderr, + test_case.ignore_whitespace, + ) { + comparison.stderr = SingleExpectationComparison::Matches; + } else { + comparison.stderr = SingleExpectationComparison::Differs { + expected: expected_stderr.clone(), + actual: test_result.stderr.clone(), + }; + } + } + + // Check snapshot if enabled + if test_case.snapshot { + comparison.snapshot_used = true; + comparison.snapshot_result = Some(check_snapshot( + test_case_set, + test_case, + test_result, + test_temp_dir, + config, + )); + } + + comparison +} + +#[cfg(feature = "insta")] +fn check_snapshot( + test_case_set: &TestCaseSet, + test_case: &TestCase, + test_result: &crate::execution::RunResult, + test_temp_dir: Option<&assert_fs::TempDir>, + config: &RunnerConfig, +) -> SnapshotResult { + // Collect files from the temp directory + let files = collect_temp_dir_files(test_temp_dir, test_case); + + // Build snapshot content manually with literal block style for better readability + let snapshot_content = format_snapshot_yaml( + test_result.exit_status.code().unwrap_or(-1), + &test_result.stdout, + &test_result.stderr, + &files, + ); + + // Compute snapshot path + let snapshot_dir = test_case_set.source_dir.join(&config.snapshot_dir_name); + let yaml_stem = test_case_set + .source_file + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("unknown"); + let test_name = test_case.name.as_deref().unwrap_or("unnamed"); + + // Convert spaces to underscores in snapshot name for filesystem compatibility + let snapshot_name = format!("{}_{}", yaml_stem, test_name.replace(' ', "_")); + + // Use insta's settings to configure snapshot location + let mut settings = insta::Settings::clone_current(); + settings.set_snapshot_path(&snapshot_dir); + settings.set_prepend_module_to_snapshot(false); + + settings.bind(|| { + // Use assert_snapshot with raw string for block-style YAML + let result = std::panic::catch_unwind(|| { + insta::assert_snapshot!(snapshot_name.clone(), snapshot_content); + }); + + match result { + Ok(()) => SnapshotResult::Matches, + Err(_) => SnapshotResult::Differs { + message: format!( + "Snapshot '{snapshot_name}' differs or is new. Run `cargo insta review` to update." + ), + }, + } + }) +} + +/// Format snapshot data as YAML with literal block style for multiline strings. +#[cfg(feature = "insta")] +fn format_snapshot_yaml( + exit_code: i32, + stdout: &str, + stderr: &str, + files: &std::collections::BTreeMap, +) -> String { + use std::fmt::Write; + + let mut output = String::new(); + + writeln!(output, "exit_code: {exit_code}").ok(); + + // Format stdout + write!(output, "stdout: ").ok(); + format_yaml_string(&mut output, stdout, 0); + + // Format stderr + write!(output, "stderr: ").ok(); + format_yaml_string(&mut output, stderr, 0); + + // Format files if any + if !files.is_empty() { + writeln!(output, "files:").ok(); + for (filename, contents) in files { + write!(output, " {filename}: ").ok(); + format_yaml_string(&mut output, contents, 2); + } + } + + output +} + +/// Format a string as YAML, using literal block style for multiline content. +#[cfg(feature = "insta")] +fn format_yaml_string(output: &mut String, s: &str, indent: usize) { + use std::fmt::Write; + + if s.is_empty() { + writeln!(output, "\"\"").ok(); + } else if s.contains('\n') { + // Use literal block style for multiline strings + if s.ends_with('\n') { + writeln!(output, "|").ok(); + } else { + writeln!(output, "|-").ok(); + } + let indent_str = " ".repeat(indent + 2); + for line in s.lines() { + writeln!(output, "{indent_str}{line}").ok(); + } + // If string ends with newline but lines() doesn't capture trailing empty line + if s.ends_with('\n') && !s.ends_with("\n\n") { + // Already handled by literal block indicator '|' + } else if s.ends_with("\n\n") { + // Multiple trailing newlines need explicit empty lines + let trailing_newlines = s.len() - s.trim_end_matches('\n').len(); + for _ in 1..trailing_newlines { + writeln!(output, "{indent_str}").ok(); + } + } + } else { + // Single line - use quoted style if contains special chars, otherwise plain + if s.contains(':') + || s.contains('#') + || s.contains('\'') + || s.contains('"') + || s.starts_with(' ') + || s.ends_with(' ') + || s == "true" + || s == "false" + || s == "null" + || s.parse::().is_ok() + { + // Use double-quoted style with escapes + let escaped = s + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\t', "\\t"); + writeln!(output, "\"{escaped}\"").ok(); + } else { + writeln!(output, "{s}").ok(); + } + } +} + +/// Collect files from the temp directory, excluding test input files. +/// Returns a map of filename -> contents (as string for text files). +#[cfg(feature = "insta")] +fn collect_temp_dir_files( + test_temp_dir: Option<&assert_fs::TempDir>, + test_case: &TestCase, +) -> std::collections::BTreeMap { + use std::collections::BTreeMap; + + let mut files = BTreeMap::new(); + + let Some(temp_dir) = test_temp_dir else { + return files; + }; + + // Get the set of input test file names to exclude + let input_files: std::collections::HashSet<_> = test_case + .test_files + .iter() + .map(|f| f.path.to_string_lossy().to_string()) + .collect(); + + // Walk the temp directory and collect files + let Ok(entries) = std::fs::read_dir(temp_dir.path()) else { + return files; + }; + + for entry in entries.flatten() { + let filename = entry.file_name().to_string_lossy().to_string(); + + // Skip input test files + if input_files.contains(&filename) { + continue; + } + + // Skip coverage profile data + if filename.ends_with(".profraw") { + continue; + } + + let path = entry.path(); + if !path.is_file() { + continue; + } + + // Read file contents - use lossy conversion for binary files + if let Ok(contents) = std::fs::read(&path) { + let text = String::from_utf8_lossy(&contents).to_string(); + files.insert(filename, text); + } + } + + files +} + +#[cfg(not(feature = "insta"))] +fn check_snapshot( + _test_case_set: &TestCaseSet, + _test_case: &TestCase, + _test_result: &crate::execution::RunResult, + _test_temp_dir: Option<&assert_fs::TempDir>, + _config: &RunnerConfig, +) -> SnapshotResult { + SnapshotResult::Differs { + message: String::from("Snapshot testing requires the 'insta' feature to be enabled"), + } +} diff --git a/local/recipes/shells/brush/source/brush-test-harness/src/testcase.rs b/local/recipes/shells/brush/source/brush-test-harness/src/testcase.rs new file mode 100644 index 0000000000..295c0e2287 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-test-harness/src/testcase.rs @@ -0,0 +1,201 @@ +//! Test case definitions and YAML schema. + +use serde::{Deserialize, Serialize}; +use std::{ + collections::{HashMap, HashSet}, + path::PathBuf, +}; + +/// How to invoke the shell for a test case. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub enum ShellInvocation { + /// Execute the shell binary directly. + #[default] + ExecShellBinary, + /// Execute a script file. + ExecScript(String), +} + +/// A file to create in the test's temporary directory. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct TestFile { + /// Relative path to test file within the temp directory. + pub path: PathBuf, + /// Contents to seed the file with. + #[serde(default)] + pub contents: String, + /// Optionally provides relative path to the source file + /// that should be used to populate this file. + pub source_path: Option, + /// Whether the file should be executable. + #[serde(default)] + pub executable: bool, +} + +/// A single test case. +#[allow( + clippy::unsafe_derive_deserialize, + reason = "the unsafe call is unrelated to deserialization" +)] +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct TestCase { + /// Name of the test case. + pub name: Option, + + /// How to invoke the shell. + #[serde(default)] + pub invocation: ShellInvocation, + + /// Command-line arguments to the shell. + #[serde(default)] + pub args: Vec, + + /// Command-line arguments to append for the shell-under-test only. + #[serde(default)] + pub additional_test_args: Vec, + + /// Default command-line shell arguments that should be *removed*. + #[serde(default)] + pub removed_default_args: HashSet, + + /// Environment variables for the shell. + #[serde(default)] + pub env: HashMap, + + /// Home directory to set for the test. + #[serde(default)] + pub home_dir: Option, + + /// Whether to skip this test. + #[serde(default)] + pub skip: bool, + + /// Whether this test requires a PTY. + #[serde(default)] + pub pty: bool, + + /// Input to provide via stdin. + #[serde(default)] + pub stdin: Option, + + /// Whether to ignore exit status differences. + #[serde(default)] + pub ignore_exit_status: bool, + + /// Whether to ignore stderr differences. + #[serde(default)] + pub ignore_stderr: bool, + + /// Whether to ignore stdout differences. + #[serde(default)] + pub ignore_stdout: bool, + + /// Whether to normalize whitespace when comparing output. + #[serde(default)] + pub ignore_whitespace: bool, + + /// Files to create in the test's temporary directory. + #[serde(default)] + pub test_files: Vec, + + /// Whether this test is a known failure. + #[serde(default)] + pub known_failure: bool, + + /// Configurations that are incompatible with this test. + #[serde(default)] + pub incompatible_configs: HashSet, + + /// Operating systems that are incompatible with this test. + #[serde(default)] + pub incompatible_os: HashSet, + + /// Runtime platform tags (e.g., "wasi", "wasm") that are incompatible + /// with this test. The test is skipped when any of these tags is present + /// in the runner's active platform tag set. + #[serde(default)] + pub incompatible_platforms: HashSet, + + /// Minimum oracle version required for this test. + #[serde(default)] + pub min_oracle_version: Option, + + /// Maximum oracle version allowed for this test. + #[serde(default)] + pub max_oracle_version: Option, + + /// Timeout for this test in seconds. + #[serde(default)] + pub timeout_in_seconds: Option, + + // ==================== Expectation fields ==================== + /// Expected stdout content (for expectation-based testing). + #[serde(default)] + pub expected_stdout: Option, + + /// Expected stderr content (for expectation-based testing). + #[serde(default)] + pub expected_stderr: Option, + + /// Expected exit code (for expectation-based testing). + #[serde(default)] + pub expected_exit_code: Option, + + /// Whether to use insta snapshot for this test's expectations. + #[serde(default)] + pub snapshot: bool, + + /// Whether to skip oracle comparison even when an oracle is configured. + #[serde(default)] + pub skip_oracle: bool, +} + +impl TestCase { + /// Returns whether this test case has any inline expectations defined. + pub const fn has_inline_expectations(&self) -> bool { + self.expected_stdout.is_some() + || self.expected_stderr.is_some() + || self.expected_exit_code.is_some() + } + + /// Returns whether this test case uses snapshots for expectations. + pub const fn uses_snapshot(&self) -> bool { + self.snapshot + } + + /// Returns whether this test case has any expectations (inline or snapshot). + pub const fn has_expectations(&self) -> bool { + self.has_inline_expectations() || self.uses_snapshot() + } +} + +/// A set of test cases loaded from a single YAML file. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct TestCaseSet { + /// Name of the test case set. + pub name: Option, + + /// The test cases in this set. + pub cases: Vec, + + /// Common test files applicable to all children test cases. + #[serde(default)] + pub common_test_files: Vec, + + /// Configurations that are incompatible with this entire test set. + #[serde(default)] + pub incompatible_configs: HashSet, + + /// Runtime platform tags (e.g., "wasi", "wasm") that are incompatible + /// with this entire test set. + #[serde(default)] + pub incompatible_platforms: HashSet, + + /// Directory containing the YAML file (computed at runtime). + #[serde(skip)] + pub source_dir: PathBuf, + + /// Path to the YAML file (computed at runtime). + #[serde(skip)] + pub source_file: PathBuf, +} diff --git a/local/recipes/shells/brush/source/brush-test-harness/src/util.rs b/local/recipes/shells/brush/source/brush-test-harness/src/util.rs new file mode 100644 index 0000000000..cb29eaa451 --- /dev/null +++ b/local/recipes/shells/brush/source/brush-test-harness/src/util.rs @@ -0,0 +1,109 @@ +//! Utility functions for the test harness. + +use anyhow::Result; +use descape::UnescapeExt; + +/// Get the OS ID from /etc/os-release file. +/// Returns the value of the ID field, which is the canonical OS identifier. +/// For example: "ubuntu", "opensuse-tumbleweed", "fedora", etc. +pub fn get_host_os_id() -> Option { + os_release::OsRelease::new().ok().and_then(|info| { + if info.id.is_empty() { + None + } else { + Some(info.id) + } + }) +} + +/// Reads and processes the expectrl log output. +#[cfg(pty)] +pub fn read_expectrl_log(log: Vec) -> Result { + let output_str = String::from_utf8(log)?; + let output: String = output_str + .lines() + .filter(|line| line.starts_with("read:")) + .map(|line| { + line.strip_prefix("read: \"") + .unwrap() + .strip_suffix('"') + .unwrap() + }) + .collect(); + + Ok(output) +} + +/// Makes expectrl output human-readable by unescaping and stripping ANSI codes. +pub fn make_expectrl_output_readable>(output: S) -> String { + // Unescape the escaping done by expectrl's logging mechanism. + let unescaped = output.as_ref().to_unescaped().unwrap().to_string(); + + // Remove VT escape sequences. + strip_ansi_escapes::strip_str(unescaped) +} + +/// Writes a diff between two strings to a writer. +pub fn write_diff( + writer: &mut impl std::io::Write, + indent: usize, + left: &str, + right: &str, +) -> Result<()> { + use colored::Colorize; + + let indent_str = " ".repeat(indent); + + let diff = diff::lines(left, right); + for d in diff { + let formatted = match d { + diff::Result::Left(l) => std::format!("{indent_str}- {l}").red(), + diff::Result::Both(l, _) => std::format!("{indent_str} {l}").bright_black(), + diff::Result::Right(r) => std::format!("{indent_str}+ {r}").green(), + }; + + writeln!(writer, "{formatted}")?; + } + + Ok(()) +} + +/// Resolves the first element of the given launcher token list to an absolute path. +/// +/// This is needed because the test harness clears env vars (including `PATH`) +/// before spawning child processes, so a launcher binary referenced by name +/// (e.g., `wasmtime`) would fail to resolve at exec time. +pub fn resolve_launcher_path(tokens: &mut [String]) -> Result<()> { + let Some(first) = tokens.first() else { + return Ok(()); + }; + if std::path::Path::new(first.as_str()).is_absolute() { + return Ok(()); + } + let resolved = std::env::var_os("PATH") + .into_iter() + .flat_map(|p| std::env::split_paths(&p).collect::>()) + .map(|d| d.join(first.as_str())) + .find(|p| p.is_file()) + .ok_or_else(|| anyhow::anyhow!("could not resolve launcher binary '{first}' in PATH"))?; + tokens[0] = resolved.to_string_lossy().into_owned(); + Ok(()) +} + +/// Gets the bash version string from the given bash path. +pub fn get_bash_version_str(bash_path: &std::path::Path) -> Result { + use anyhow::Context; + + let output = std::process::Command::new(bash_path) + .arg("--norc") + .arg("--noprofile") + .arg("-c") + .arg("echo -n ${BASH_VERSINFO[0]}.${BASH_VERSINFO[1]}.${BASH_VERSINFO[2]}") + .output() + .context("failed to retrieve bash version")? + .stdout; + + let ver_str = String::from_utf8(output)?; + + Ok(ver_str) +} diff --git a/local/recipes/shells/brush/source/brush/Cargo.toml b/local/recipes/shells/brush/source/brush/Cargo.toml new file mode 100644 index 0000000000..bb909c2404 --- /dev/null +++ b/local/recipes/shells/brush/source/brush/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "brush" +description = "Rust-implemented shell focused on POSIX and bash compatibility" +version = "0.4.0" +authors.workspace = true +categories.workspace = true +edition.workspace = true +keywords.workspace = true +license.workspace = true +readme.workspace = true +repository.workspace = true +rust-version.workspace = true + +[package.metadata.binstall] +pkg-url = "{ repo }/releases/download/brush-shell-v{ version }/brush-{ target }{ archive-suffix }" + +[[bin]] +name = "brush" +path = "src/main.rs" +bench = false + +[features] +default = ["brush-shell/default"] +basic = ["brush-shell/basic"] +minimal = ["brush-shell/minimal"] +reedline = ["brush-shell/reedline"] +experimental = ["brush-shell/experimental"] + +[lints] +workspace = true + +[dependencies] +brush-shell = { version = "^0.4.0", path = "../brush-shell", default-features = false } diff --git a/local/recipes/shells/brush/source/brush/src/main.rs b/local/recipes/shells/brush/source/brush/src/main.rs new file mode 100644 index 0000000000..5d25a93d37 --- /dev/null +++ b/local/recipes/shells/brush/source/brush/src/main.rs @@ -0,0 +1,5 @@ +//! Wrapper binary for brush-shell + +fn main() { + brush_shell::entry::run(); +} diff --git a/local/recipes/shells/brush/source/cliff.toml b/local/recipes/shells/brush/source/cliff.toml new file mode 100644 index 0000000000..3415dadb3c --- /dev/null +++ b/local/recipes/shells/brush/source/cliff.toml @@ -0,0 +1,91 @@ +# git-cliff ~ default configuration file +# https://git-cliff.org/docs/configuration +# +# Lines starting with "#" are comments. +# Configuration options are organized into tables and keys. +# See documentation for more information on available options. + +[changelog] +# changelog header +header = """ +# Changelog\n +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n +All notable changes to this project will be documented in this file.\n +""" +# template for the changelog body +# https://keats.github.io/tera/docs/#introduction +body = """ +{% if version %}\ + ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} +{% else %}\ + ## [unreleased] +{% endif %}\ +{% for group, commits in commits | group_by(attribute="group") %} + ### {{ group | striptags | trim | upper_first }} + {% for commit in commits %} + - {% if commit.scope %}*({{ commit.scope }})* {% endif %}\ + {% if commit.breaking %}[**breaking**] {% endif %}\ + {{ commit.message | upper_first }}\ + {% endfor %} +{% endfor %}\n +""" +# template for the changelog footer +footer = """ + +""" +# remove the leading and trailing s +trim = true +# postprocessors +postprocessors = [ + # { pattern = '', replace = "https://github.com/orhun/git-cliff" }, # replace repository URL +] + +[git] +# parse the commits based on https://www.conventionalcommits.org +conventional_commits = true +# filter out the commits that are not conventional +filter_unconventional = true +# process each line of a commit as an individual commit +split_commits = false +# regex for preprocessing the commit messages +commit_preprocessors = [ + # Replace issue numbers + #{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](/issues/${2}))"}, + # Check spelling of the commit with https://github.com/crate-ci/typos + # If the spelling is incorrect, it will be automatically fixed. + #{ pattern = '.*', replace_command = 'typos --write-changes -' }, +] +# regex for parsing and grouping commits +commit_parsers = [ + { message = "^feat", group = "🚀 Features" }, + { message = "^fix", group = "🐛 Bug Fixes" }, + { message = "^doc", group = "📚 Documentation" }, + { message = "^perf", group = "⚡ Performance" }, + { message = "^refactor", group = "🚜 Refactor" }, + { message = "^style", group = "🎨 Styling" }, + { message = "^test", group = "🧪 Testing" }, + { message = "^chore\\(release\\): prepare for", skip = true }, + { message = "^chore\\(deps.*\\)", skip = true }, + { message = "^chore\\(pr\\)", skip = true }, + { message = "^chore\\(pull\\)", skip = true }, + { message = "^chore|^ci", group = "⚙️ Miscellaneous Tasks" }, + { body = ".*security", group = "🛡️ Security" }, + { message = "^revert", group = "◀️ Revert" }, +] +# protect breaking changes from being skipped due to matching a skipping commit_parser +protect_breaking_commits = false +# filter out the commits that are not matched by commit parsers +filter_commits = false +# regex for matching git tags +# tag_pattern = "v[0-9].*" +# regex for skipping tags +# skip_tags = "" +# regex for ignoring tags +# ignore_tags = "" +# sort the tags topologically +topo_order = false +# sort the commits inside sections by oldest/newest order +sort_commits = "oldest" +# limit the number of commits included in the changelog. +# limit_commits = 42 diff --git a/local/recipes/shells/brush/source/clippy.toml b/local/recipes/shells/brush/source/clippy.toml new file mode 100644 index 0000000000..154626ef4e --- /dev/null +++ b/local/recipes/shells/brush/source/clippy.toml @@ -0,0 +1 @@ +allow-unwrap-in-tests = true diff --git a/local/recipes/shells/brush/source/deny.toml b/local/recipes/shells/brush/source/deny.toml new file mode 100644 index 0000000000..06961cec74 --- /dev/null +++ b/local/recipes/shells/brush/source/deny.toml @@ -0,0 +1,97 @@ +[graph] +# If 1 or more target triples (and optionally, target_features) are specified, +# only the specified targets will be checked when running `cargo deny check`. +# This means, if a particular package is only ever used as a target specific +# dependency, such as, for example, the `nix` crate only being used via the +# `target_family = "unix"` configuration, that only having windows targets in +# this list would mean the nix crate, as well as any of its exclusive +# dependencies not shared by any other crates, would be ignored, as the target +# list here is effectively saying which targets you are building for. +targets = [] +#exclude = [] +all-features = false +no-default-features = false +#features = [] + +[output] +feature-depth = 1 + +# This section is considered when running `cargo deny check advisories` +# More documentation for the advisories section can be found here: +# https://embarkstudios.github.io/cargo-deny/checks/advisories/cfg.html +[advisories] +version = 2 +yanked = "deny" +#db-path = "$CARGO_HOME/advisory-dbs" +#db-urls = ["https://github.com/rustsec/advisory-db"] +ignore = [ + # Both advisories affect quick-xml's *parsing* paths (Attributes duplicate + # check, NsReader namespace resolution). quick-xml is pulled in only via + # junit-report (a dev-dependency for test reporting), which exclusively + # *writes* XML and never parses untrusted input. Remove these once + # junit-report releases with quick-xml >= 0.41. + { id = "RUSTSEC-2026-0194", reason = "parse-only DoS; junit-report (dev-only) only writes XML" }, + { id = "RUSTSEC-2026-0195", reason = "NsReader-only DoS; junit-report (dev-only) only writes XML" }, +] +#git-fetch-with-cli = true + +# This section is considered when running `cargo deny check licenses` +# More documentation for the licenses section can be found here: +# https://embarkstudios.github.io/cargo-deny/checks/licenses/cfg.html +[licenses] +version = 2 +# List of explicitly allowed licenses +# See https://spdx.org/licenses/ for list of possible licenses +# [possible values: any SPDX 3.11 short identifier (+ optional exception)]. +allow = [ + "Apache-2.0", + "BSD-2-Clause", + "BSL-1.0", + "CC0-1.0", + "ISC", + "MIT", + "MPL-2.0", + "NCSA", + "Unicode-3.0", + "WTFPL", + "Zlib", +] +# The confidence threshold for detecting a license from license text. +# The higher the value, the more closely the license text must be to the +# canonical license text of a valid SPDX license file. +# [possible values: any between 0.0 and 1.0]. +confidence-threshold = 0.8 +# Allow 1 or more licenses on a per-crate basis, so that particular licenses +# aren't accepted for every possible crate as with the normal allow list +exceptions = [] + +[licenses.private] +ignore = false +registries = [] + +# This section is considered when running `cargo deny check bans`. +# More documentation about the 'bans' section can be found here: +# https://embarkstudios.github.io/cargo-deny/checks/bans/cfg.html +[bans] +multiple-versions = "allow" +wildcards = "allow" +highlight = "all" +workspace-default-features = "allow" +external-default-features = "allow" +# List of crates that are allowed. Use with care! +allow = [] +# List of crates to deny +deny = [] + +# This section is considered when running `cargo deny check sources`. +# More documentation about the 'sources' section can be found here: +# https://embarkstudios.github.io/cargo-deny/checks/sources/cfg.html +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] +allow-git = [] + +[sources.allow-org] +# 1 or more github.com organizations to allow git sources for +github = [] diff --git a/local/recipes/shells/brush/source/docs/README.md b/local/recipes/shells/brush/source/docs/README.md new file mode 100644 index 0000000000..52b4125b54 --- /dev/null +++ b/local/recipes/shells/brush/source/docs/README.md @@ -0,0 +1,13 @@ +# brush documentation + +The docs are grouped into: + +* [How-to guides](how-to/README.md) +* [Tutorials](tutorials/README.md) +* [Reference material](reference/README.md) + +If you're just getting started building this project, you should consult the [How to Build](how-to/build.md) guide. + +--- + +> _Note: The structure of our docs is inspired by the [Diátaxis](https://diataxis.fr/) approach; we've found over time that best helps readers find the material most relevant to them, as well as provides a rough shape for where to place the right docs._ diff --git a/local/recipes/shells/brush/source/docs/demos/demo.tape b/local/recipes/shells/brush/source/docs/demos/demo.tape new file mode 100644 index 0000000000..efed234644 --- /dev/null +++ b/local/recipes/shells/brush/source/docs/demos/demo.tape @@ -0,0 +1,146 @@ +# Works with https://github.com/charmbracelet/vhs + +Output sample.gif + +Set FontFamily "CaskaydiaMono Nerd Font Mono" +Set FontSize 20 +Set Theme "Monokai Pro" +Set Width 1600 +Set Height 600 +Set CursorBlink false + +# Setup environment to launch brush in +Env HISTFILE "" +Env PS1 '$0$ ' + +# Launch brush and set up bash-completion +Hide +Type `brush --enable-highlighting --norc --noprofile --no-config` +Enter +Type `source /usr/share/bash-completion/bash_completion && clear` +Enter +Show + +# Enable starship +Type `# Let's start with a better prompt. starship to the rescue!` +Sleep 0.8s +Enter +Type `eval "$(starship init bash)"` +Sleep 0.8s +Enter +Sleep 1s + +# git describe +Type `git d` +Tab +Sleep 1.3s +Enter +Sleep 0.4s +Type `--l` +Sleep 0.4s +Tab +Sleep 0.8s +Type `brush-she` +Sleep 0.5s +Tab +Sleep 0.2s +Right +Sleep 0.2s +Right +Sleep 0.8s +Enter +Sleep 0.8s +Enter +Sleep 1s + +# vim +Type `vim Ca` +Sleep 0.5s +Tab +Sleep 0.8s +Right +Sleep 0.5s +Enter +Sleep 1s +Enter + +Type `1G` +Type `i` +Enter +Up +Type `# Let's try suspending vim...` +Enter +Escape +Sleep 1s +Ctrl+Z +Sleep 0.7s + +Type `# Yep, it's suspended.` +Sleep 0.4s +Type ` Let's bring it back.` +Enter +Sleep 0.8s + +Type `fg` +Sleep 0.4s +Enter +Sleep 0.6s + +Type `:q!` +Sleep 0.3s +Enter +Sleep 0.5s +Ctrl+L +Sleep 0.2s + +Type `# Let's properly greet the world.` +Enter +Sleep 0.4s + +# Figure out version +Type `verline=$(help | head -n1)` +Sleep 0.2s +Enter +Sleep 0.3s +Type `[[ "${verline}" =~ ^.*version\ ([[:digit:]\.]+).*$ ]] && ver=${BASH_REMATCH[1]}` +Sleep 0.4s +Enter +Sleep 0.2s +Type `declare -p ver` +Enter +Sleep 0.4s + +# Declare function +Type `function greet() {` +Enter +Type ` echo "Hello from brush ${ver}!"` +Enter +Type `}` +Sleep 1s +Enter +Type `type greet` +Sleep 0.4s +Enter +Sleep 1s + +# Use function +Ctrl+L +Type `for ((i = 0; i < 5; i++)); do greet; done` +Sleep 1s +Enter +Sleep 0.8s + +Type `# Surely we can make that more colorful.` +Enter +Sleep 1s + +# Now with lolcat +Type `for ` +Sleep 1s +Right +Sleep 0.8s +Type ` | lolcat -F 0.3 -S 12` +Sleep 0.6s +Enter + +Sleep 3s diff --git a/local/recipes/shells/brush/source/docs/demos/fzf.tape b/local/recipes/shells/brush/source/docs/demos/fzf.tape new file mode 100644 index 0000000000..e61a344b80 --- /dev/null +++ b/local/recipes/shells/brush/source/docs/demos/fzf.tape @@ -0,0 +1,60 @@ +# Works with https://github.com/charmbracelet/vhs +# This tape assumes that fzf and brush are already pre-installed and ready to go. + +Output sample.gif + +# Set FontFamily "CaskaydiaMono Nerd Font Mono" +Set FontSize 20 +Set Theme "Monokai Pro" +Set Width 1600 +Set Height 600 +Set CursorBlink false + +# Setup environment to launch brush in +Env HISTFILE "" +Env PS1 '$0$ ' + +# Launch brush and set up bash-completion +Hide +Type `brush --enable-highlighting --norc --noprofile --no-config` +Enter +Show + +# Enable fzf +Type `# Let's enable fzf with standard bash mode` +Sleep 0.8s +Enter +Type `eval "$(fzf --bash)"` +Sleep 0.8s +Enter +Sleep 1s + +Enter +Enter + +Type `# ^^^ we see an error above. Some key combos are missing but more are functional.` +Enter + +Enter +Enter + +# Type some things. +Type `echo 'We will type something'` +Enter +Type `echo '...just to populate history'` +Enter +Type `echo 'And now, hit Ctrl+R.'` +Enter +Sleep 1s + +# Ctrl+R +Ctrl+R +Sleep 1.5s +Up +Sleep 0.8s +Enter + +Sleep 2s +Enter +Sleep 2s + diff --git a/local/recipes/shells/brush/source/docs/extras/brush-screenshot.png b/local/recipes/shells/brush/source/docs/extras/brush-screenshot.png new file mode 100644 index 0000000000..0baaee599b Binary files /dev/null and b/local/recipes/shells/brush/source/docs/extras/brush-screenshot.png differ diff --git a/local/recipes/shells/brush/source/docs/how-to/README.md b/local/recipes/shells/brush/source/docs/how-to/README.md new file mode 100644 index 0000000000..d852fa6cf5 --- /dev/null +++ b/local/recipes/shells/brush/source/docs/how-to/README.md @@ -0,0 +1,8 @@ +# How-to guides + +* [How to build](build.md) +* [How to run tests](run-tests.md) +* [How to run benchmarks](run-benchmarks.md) +* [How to release](release.md) +* [How to upgrade the MSRV](upgrade-msrv.md) +* [How to record "tapes"](record-tapes.md) diff --git a/local/recipes/shells/brush/source/docs/how-to/build.md b/local/recipes/shells/brush/source/docs/how-to/build.md new file mode 100644 index 0000000000..864a6fa1c5 --- /dev/null +++ b/local/recipes/shells/brush/source/docs/how-to/build.md @@ -0,0 +1,5 @@ +# How to build and run + +1. Install Rust toolchain. We recommend using [rustup](https://rustup.rs/). +1. Build `brush`: `cargo build` +1. Run `brush`: `cargo run` diff --git a/local/recipes/shells/brush/source/docs/how-to/record-tapes.md b/local/recipes/shells/brush/source/docs/how-to/record-tapes.md new file mode 100644 index 0000000000..fe2ec9b4c7 --- /dev/null +++ b/local/recipes/shells/brush/source/docs/how-to/record-tapes.md @@ -0,0 +1,16 @@ +# How to record "tapes" + +Under the `docs/demos` directory of this repo, we have some `.tape` files checked in. +These are interactive scripts for recording screencast-style demos of `brush` using +the [`VHS` tool](https://github.com/charmbracelet/vhs). + +## Install `vhs` + +You first need to install `vhs`. For consistency, we've found it easiest to install +`golang` and then follow the instructions on the `vhs` github page to install via +`go install`. (Also note that there are some native prerequisites required.) + +## Run `vhs` + +To run `vhs` against the `.tape` file you may need to use `VHS_NO_SANDBOX=1`. For more +details see [this issue on GitHub](https://github.com/charmbracelet/vhs/issues/504). \ No newline at end of file diff --git a/local/recipes/shells/brush/source/docs/how-to/release.md b/local/recipes/shells/brush/source/docs/how-to/release.md new file mode 100644 index 0000000000..4ba7b95272 --- /dev/null +++ b/local/recipes/shells/brush/source/docs/how-to/release.md @@ -0,0 +1,14 @@ +# How to release + +_(This is only relevant for project maintainers.)_ + +* Install [release-plz](https://github.com/MarcoIeni/release-plz) +* Checkout the `main` branch (with a clean working tree). +* Run: `release-plz update`. Review its changes, notable including the changelog updates. +* PR through any generated changes with a `chore: prepare release` commit summary. +* After the changes have merged into `main`, update your local `main` branch. +* Acquire GitHub and `crates.io` tokens that have sufficient permissions to publish. +* Authenticate with `crates.io` by running: `cargo login`. +* Run: `release-plz release --backend github --git-token `. +* Update the published GitHub release to include an auto-generated changelog. +* Run: `cargo install --locked brush-shell` to verify the release. diff --git a/local/recipes/shells/brush/source/docs/how-to/run-benchmarks.md b/local/recipes/shells/brush/source/docs/how-to/run-benchmarks.md new file mode 100644 index 0000000000..59269095d6 --- /dev/null +++ b/local/recipes/shells/brush/source/docs/how-to/run-benchmarks.md @@ -0,0 +1,31 @@ +# How to run benchmarks + +## Using xtask (Recommended) + +The project provides `cargo xtask` commands for running benchmarks: + +```bash +# Run benchmarks +cargo xtask analyze bench + +# Run benchmarks and save output to a file +cargo xtask analyze bench --output benchmarks.txt +``` + +## Manual Approach (Alternate) + +To run performance benchmarks: + +```bash +cargo bench --workspace --benches +``` + +## Collecting flamegraphs + +To collect flamegraphs from performance benchmarks (running for 10 seconds): + +```bash +cargo bench --workspace --benches -- --profile-time 10 +``` + +The flamegraphs will be created as `.svg` files and placed under `target/criterion//profile`. diff --git a/local/recipes/shells/brush/source/docs/how-to/run-tests.md b/local/recipes/shells/brush/source/docs/how-to/run-tests.md new file mode 100644 index 0000000000..c0c83ef878 --- /dev/null +++ b/local/recipes/shells/brush/source/docs/how-to/run-tests.md @@ -0,0 +1,48 @@ +# How to run tests + +## Using xtask (Recommended) + +The project provides `cargo xtask` commands for 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 code coverage +cargo xtask test integration --coverage --coverage-output codecov.xml +``` + +## CI Workflows + +For comprehensive validation, use the CI workflows: + +```bash +# Quick inner-loop checks (~7s warm): fmt, build, lint, unit tests +cargo xtask ci quick + +# Full pre-commit checks (~45s warm): quick + deps, schemas, integration tests +cargo xtask ci pre-commit +``` + +## Manual Approach (Alternate) + +To run all workspace tests: + +```bash +cargo test --workspace +``` + +To run just bash compatibility tests: + +```bash +cargo test --test brush-compat-tests +``` + +To run a specific compatibility test case + +```bash +cargo test --test brush-compat-tests -- '' +``` diff --git a/local/recipes/shells/brush/source/docs/how-to/upgrade-msrv.md b/local/recipes/shells/brush/source/docs/how-to/upgrade-msrv.md new file mode 100644 index 0000000000..826f13438a --- /dev/null +++ b/local/recipes/shells/brush/source/docs/how-to/upgrade-msrv.md @@ -0,0 +1,69 @@ +# How to upgrade MSRV + +This document outlines the process for upgrading the Minimum Supported Rust Version (MSRV) for the `brush` project. + +## Overview + +Before upgrading MSRV, review the [MSRV Policy](../reference/msrv-policy.md) to ensure the update aligns with project guidelines. + +## Process + +### 1. Find all MSRV references + +Search for the current MSRV version throughout the codebase: + +```bash +grep -r "" . +``` + +Typically, MSRV is specified in: +- `Cargo.toml` (workspace `rust-version` field) +- `.github/workflows/ci.yaml` (CI test matrix) +- `.github/copilot-instructions.md` (GitHub Copilot instructions) + +### 2. Update MSRV references + +Update all occurrences to the new version: + +- **`Cargo.toml`**: Update the `rust-version` field under `[workspace.package]` +- **`.github/workflows/ci.yaml`**: Update the version in the test matrix + +### 3. Verify the build + +Test that the project builds successfully with the updated MSRV: + +```bash +cargo check --workspace +``` + +### 4. Run tests + +Verify that tests pass with the new MSRV: + +```bash +cargo test --workspace +``` + +### 5. Run static checks + +After upgrading MSRV, it's important to rerun all static checks, as newer Rust versions may introduce new lints and clippy warnings that weren't present in the previous MSRV. These warnings need to be resolved to maintain code quality. + +Run clippy with all warnings treated as errors: + +```bash +cargo clippy --workspace --all-targets --all-features -- -D warnings +``` + +Also run other static checks such as formatting: + +```bash +cargo fmt --all -- --check +``` + +**Note**: Upgrading MSRV often enables new clippy lints (particularly in nursery categories) that may flag code patterns that were previously acceptable. Review and fix these warnings, as they often suggest improvements like adding `const` to functions or other optimizations that are newly available in the updated Rust version. + +### 6. Update documentation + +When merging the MSRV update: +- Call out the update in an appropriate Conventional Commit commit description +- Include justification for the change in the release notes diff --git a/local/recipes/shells/brush/source/docs/reference/README.md b/local/recipes/shells/brush/source/docs/reference/README.md new file mode 100644 index 0000000000..67ec1f2b39 --- /dev/null +++ b/local/recipes/shells/brush/source/docs/reference/README.md @@ -0,0 +1,9 @@ +# Reference + +These documents serve as reference material for the `brush` project. + +* [Configuration file](configuration.md) +* [Experimental features](experimental.md) +* [Integration testing](integration-testing.md) +* [Minimum Supported Rust Version (MSRV) policy](msrv-policy.md) +* [Compatibility](compatibility.md) \ No newline at end of file diff --git a/local/recipes/shells/brush/source/docs/reference/compatibility.md b/local/recipes/shells/brush/source/docs/reference/compatibility.md new file mode 100644 index 0000000000..b8813f080e --- /dev/null +++ b/local/recipes/shells/brush/source/docs/reference/compatibility.md @@ -0,0 +1,222 @@ +# `bash` Compatibility Reference + +This document details `brush`'s compatibility with `bash`, including supported features, known limitations, and how to report issues. + +## Overview + +`brush` aims for high compatibility with `bash`. We validate this through **1700+ compatibility test cases** that compare behavior against `bash` as an oracle. + +**Compatibility snapshot:** Production-ready for most use cases. Your `.bashrc`, aliases, functions, and completions should "just work." + +## Fully Supported Features ✅ + +### Shell Syntax & Control Flow + +- `if`/`then`/`elif`/`else`/`fi` conditionals +- `for`, `while`, `until` loops +- Arithmetic `for` loops: `for ((i=0; i<10; i++))` +- `case`/`esac` pattern matching +- `&&`, `||` conditional execution +- Subshells `()` and command grouping `{}` +- Pipelines and pipeline negation `!` +- Coprocesses: `coproc { ... }` and `coproc NAME { ... }` + +### Expansions + +- Brace expansion: `{a,b,c}`, `{1..10}`, `{a..z}` +- Parameter expansion: `${var:-default}`, `${var:+set}`, `${var#pattern}`, `${var%pattern}`, `${var//find/replace}`, etc. +- Command substitution: `$(cmd)`, `` `cmd` `` +- Arithmetic expansion: `$((expr))` +- Process substitution: `<(cmd)`, `>(cmd)` +- Tilde expansion: `~`, `~user` +- Globbing: `*`, `?`, `[...]` +- Extended globbing: `?(pat)`, `*(pat)`, `+(pat)`, `@(pat)`, `!(pat)` +- `globstar`: `**` recursive matching + +### Builtins (50+) + +- **I/O:** `echo`, `printf`, `read`, `mapfile`/`readarray` +- **Variables:** `declare`, `local`, `export`, `unset`, `readonly`, `typeset` +- **Control:** `break`, `continue`, `return`, `exit` +- **Navigation:** `cd`, `pushd`, `popd`, `dirs`, `pwd` +- **Jobs:** `jobs`, `fg`, `bg`, `wait`, `kill` +- **Completion:** `complete`, `compgen`, `compopt` +- **History:** `history`, `fc` +- **Testing:** `test`, `[`, `[[` +- **Sourcing & introspection:** `.`, `source`, `eval`, `caller` +- **Misc:** `alias`, `unalias`, `hash`, `type`, `command`, `builtin`, `enable`, `help`, `times`, `ulimit`, `umask`, `trap`, `shopt`, `set`, `shift`, `getopts` + +### Arrays + +- Indexed arrays: `arr=(a b c)`, `${arr[0]}`, `${arr[@]}` +- Associative arrays: `declare -A map` +- Array slicing: `${arr[@]:start:length}` +- Array operations: `${#arr[@]}`, `${!arr[@]}`, `${!arr[*]}` + +### Job Control + +- Background execution: `cmd &` +- Suspend/resume: Ctrl+Z, `fg`, `bg` +- Job listing: `jobs` +- Process groups and pipelines + +### Dynamic Variables + +- `RANDOM`, `SRANDOM` +- `LINENO`, `FUNCNAME`, `BASH_SOURCE` +- `EPOCHSECONDS`, `EPOCHREALTIME` +- `SECONDS` +- `PWD`, `OLDPWD` +- `BASH_VERSINFO`, `BASH_VERSION` + +### Programmable Completion + +- Compatible with [`bash-completion`](https://github.com/scop/bash-completion) +- Git, Docker, systemctl, etc. completions work out of the box +- `complete`, `compgen`, `compopt` builtins + +### Redirection + +- Standard: `>`, `>>`, `<`, `2>&1` +- Here documents: `<&n`, `<&n`, `n>&m` +- Process substitution redirects: `>(cmd)`, `<(cmd)` +- Clobber control: `>|`, `set -o noclobber` + +## Partially Supported Features 🔷 + +### Traps + +| Status | Feature | +|--------|----------| +| ✅ | `DEBUG` trap | +| ✅ | `ERR` trap | +| ✅ | `EXIT` trap | +| 🔷 | Signal traps (`SIGINT`, `SIGTERM`, etc.) — in progress | + +### Key Bindings (`bind`) + +| Status | Feature | +|--------|---------| +| ✅ | Basic `bind` support | +| ✅ | `bind -x` for custom key-bound commands | +| 🔷 | Advanced bind features — in progress | + +### Shell Options + +| Status | Feature | +|--------|---------| +| ✅ | Common options: `errexit`, `pipefail`, `extglob`, `globstar`, `noclobber`, `nounset`, `failglob` | +| 🔷 | Less common options — in progress | + +## Not Yet Supported Features 🚧 + +These features are on our roadmap but not yet implemented: + +### `select` Statement + +The `select` builtin for creating menu-driven scripts is not yet implemented. + +```bash +# Not yet supported +select opt in "Option A" "Option B" "Quit"; do + case $opt in + "Option A") echo "A";; + "Option B") echo "B";; + "Quit") break;; + esac +done +``` + +### `wait -n` + +The `wait -n` option to wait for the next background job to complete is not implemented. + +```bash +# Not yet supported +job1 & +job2 & +wait -n # Wait for whichever finishes first +``` + +### `$!` for Background Commands + +Bash expands `$!` to the process ID of the most recent asynchronous command. +Brush currently does not reliably provide this value for background commands, +so scripts that start a job with `cmd &` and immediately use `$!` may see an +empty value. + +```bash +# Not yet fully supported +sleep 10 & +kill "$!" +``` + +### `BASH_COMMAND` Variable + +The special variable `BASH_COMMAND` that contains the currently executing command is currently only available in trap contexts. + +### `disown` and `logout` + +These job control builtins are not yet implemented. + +## Known Edge Cases + +These areas have known differences from `bash` in edge cases. Most users won't encounter these, but they're documented for completeness. + +### IFS (Input Field Separator) + +There are ~10 known edge cases where IFS word splitting behavior differs from `bash`, particularly around: + +- Non-whitespace IFS characters and empty field creation +- Mixed whitespace and non-whitespace IFS +- Leading/trailing delimiter handling + +### `printf` Format Specifiers + +Some advanced `printf` format specifiers behave differently (~8 known cases), particularly features not supported by the underlying `uucore` library. + +### Arithmetic Expressions + +- Division by zero handling may differ in `errexit` mode +- `$(( exit N ))` syntax edge case + +### Aliases + +Some complex alias expansion scenarios differ from `bash` (see GitHub issues [#57](https://github.com/reubeno/brush/issues/57), [#286](https://github.com/reubeno/brush/issues/286)). + +## Test Suite Statistics + +- **Total test cases:** 1700+ +- **Known failures:** ~125 +- **Most failures are edge cases** in IFS handling and printf + +The test suite runs on every PR and compares behavior against `bash` as an oracle. + +## Version Compatibility + +`brush` targets compatibility with **`bash` 5.3+**. Behavior may differ from older `bash` versions (3.x, 4.x) in some areas. + +## Reporting Compatibility Issues + +Found a script that works in `bash` but not in `brush`? + +1. **Check existing issues:** [GitHub Issues](https://github.com/reubeno/brush/issues) +2. **Create a minimal reproducer:** Reduce to the smallest failing script +3. **File an issue** with: + - The script or command that fails + - Expected behavior (what `bash` does) + - Actual behavior (what `brush` does) + - Your platform (Linux/macOS/etc.) + +## Tracking Progress + +- **GitHub Issues:** Track specific compatibility work +- **Test Suite:** 1700+ tests run on every PR +- **This Document:** Updated as features are implemented + +## Related Resources + +- [`bash` Reference Manual](https://www.gnu.org/software/bash/manual/) +- [POSIX Shell Specification](https://pubs.opengroup.org/onlinepubs/9699919799/) +- [`brush` Test Cases](https://github.com/reubeno/brush/tree/main/brush-shell/tests/cases) diff --git a/local/recipes/shells/brush/source/docs/reference/configuration.md b/local/recipes/shells/brush/source/docs/reference/configuration.md new file mode 100644 index 0000000000..8264955fcc --- /dev/null +++ b/local/recipes/shells/brush/source/docs/reference/configuration.md @@ -0,0 +1,118 @@ +# Configuration File + +brush supports an optional TOML configuration file that allows you to customize shell behavior without command-line arguments. + +## File Location + +`brush` looks for the configuration file at: + +- **Linux/macOS**: `${XDG_CONFIG_HOME}/brush/config.toml`* +- **Windows**: `%APPDATA%\brush\config.toml` + +> [!NOTE] +> On Linux/macOS falls back to `~/.config/brush/config.toml` if `XDG_CONFIG_HOME` is undefined. + +You can override this location with the `--config` flag: + +```bash +brush --config /path/to/custom/config.toml +``` + +To disable configuration file loading entirely, use: + +```bash +brush --no-config +``` + +## Configuration Priority + +Settings are applied in the following order (later values override earlier ones): + +1. **Defaults** - Built-in default values +2. **Configuration file** - Values from `config.toml` +3. **Command-line arguments** - Flags passed to brush + +## File Format + +The configuration file uses [TOML](https://toml.io/) format. All settings are optional; brush uses sensible defaults for any unspecified values. + +### Example Configuration + +```toml +[ui] +syntax-highlighting = true + +[experimental] +zsh-hooks = true +terminal-shell-integration = true +``` + +## Available Settings + +### `[ui]` Section + +User interface settings. + +| Setting | Type | Default | CLI flag | Description | +|-----------------------|---------|---------|-------------------------|----------------------------------------------| +| `syntax-highlighting` | boolean | see below | `--enable-highlighting` | Enable syntax highlighting in the input line | + +> The default value of `syntax-highlighting` depends on how `brush-shell` +> was built: `true` when built with the `experimental` Cargo feature, +> `false` otherwise. CLI flags take precedence over the configuration +> file. + +### `[experimental]` Section + +Experimental features that may change or be removed in future versions. +Each setting has an equivalent command-line flag; CLI flags take +precedence over the configuration file. See the +[experimental features reference](experimental.md) for details on each +feature. + +| Setting | Type | Default | CLI flag | Description | +|------------------------------|---------|---------|----------------------------------|---------------------------------------| +| `zsh-hooks` | boolean | `false` | `--enable-zsh-hooks` | Enable zsh-style preexec/precmd hooks | +| `terminal-shell-integration` | boolean | `false` | `--enable-terminal-integration` | Enable terminal shell integration | + +## JSON Schema + +A JSON Schema for the configuration file is available at [`schemas/config.schema.json`](../../schemas/config.schema.json). This can be used with editors that support schema-based validation and autocompletion for TOML files. + +### Using the Schema with VS Code + +To enable schema validation in VS Code with the [Even Better TOML](https://marketplace.visualstudio.com/items?itemName=tamasfe.even-better-toml) extension, add this to your `config.toml`: + +```toml +#:schema https://raw.githubusercontent.com/reubeno/brush/main/schemas/config.schema.json + +[ui] +syntax-highlighting = true +``` + +The `#:schema` directive tells the editor where to find the schema for validation and autocompletion. + +### Using the Schema with Other Editors + +Many editors support JSON Schema for TOML files. Consult your editor's documentation for how to associate a schema with a file. You can reference the schema via: + +- **URL**: `https://raw.githubusercontent.com/reubeno/brush/main/schemas/config.schema.json` +- **Local path**: Point to `schemas/config.schema.json` in your brush source checkout + +## Sample Configuration + +A sample configuration file is available at [`samples/config.toml`](../../samples/config.toml) in the brush repository. You can copy this file to get started: + +```bash +# Linux/macOS +mkdir -p ~/.config/brush +cp samples/config.toml ~/.config/brush/config.toml +``` + +## Forward Compatibility + +brush ignores unknown settings in the configuration file. This allows configuration files to be shared across different versions of brush without causing errors. + +## Error Handling + +If the configuration file cannot be read or parsed, brush logs an error message and continues with default settings. The shell will still start normally. diff --git a/local/recipes/shells/brush/source/docs/reference/experimental.md b/local/recipes/shells/brush/source/docs/reference/experimental.md new file mode 100644 index 0000000000..6114903410 --- /dev/null +++ b/local/recipes/shells/brush/source/docs/reference/experimental.md @@ -0,0 +1,149 @@ +# Experimental Features + +`brush` ships several features that are intentionally marked as +**experimental**. They are usable today, but their interface or behavior +may evolve based on feedback before being stabilized. This page is the +canonical index of what's currently experimental and how to opt in. + +Experimental features fall into two categories: + +1. **Build-time experiments** — additional functionality gated behind + Cargo feature flags. To get them, you build `brush-shell` with the + relevant feature(s) enabled. +2. **Run-time experiments** — features that ship in standard builds but + are off by default and enabled through configuration. + +> Names, defaults, and semantics of experimental features may change +> between releases. + +--- + +## Build-time experiments (Cargo features) + +These are flags on the `brush-shell` crate. You can enable them +individually, or pull in the whole set with the umbrella `experimental` +feature. + +```bash +# Enable everything experimental +cargo install --locked brush-shell --features experimental + +# Or pick and choose +cargo install --locked brush-shell --features experimental-bundled-coreutils +``` + +### `experimental-bundled-coreutils` + +Bundles a configurable subset of [`uutils/coreutils`](https://github.com/uutils/coreutils) +implementations directly into `brush-shell` as builtins. Useful when: + +- shipping `brush` into containers, embedded systems, or other + environments where a standalone coreutils package is inconvenient or + unavailable, +- distributing a single self-contained `brush` binary that doesn't + rely on host utilities being present. + +When enabled (via the umbrella `experimental` feature, or directly), the +full set of supported utilities is bundled. When building the +`brush-coreutils-builtins` crate directly, individual utilities can be +selected via `coreutils.` features (e.g., `coreutils.cat`, +`coreutils.ls`); the `coreutils.all` feature enables all of them. + +Bundled utilities run in-process and take precedence over external +executables of the same name on `PATH` when invoked unqualified. As with +any builtin, you can bypass the in-process implementation with +`command ` or by giving an explicit path. + +### `experimental-builtins` + +Pulls in the [`brush-experimental-builtins`](../../brush-experimental-builtins) +crate, which provides additional builtins that are too new or too +narrow-purpose to ship in the default builtin set. Currently this +includes: + +- **`save`** — serializes the current shell state to JSON on stdout. + Primarily intended for debugging and tooling. ⚠️ The serialized state + may include sensitive information (variable values, command history, + environment). + +### `experimental-load` + +Enables `serde`-based serialization support in `brush-core`, and adds a +`--load ` command-line flag that restores shell state from a JSON +file previously produced by the experimental [`save`](#experimental-builtins) +builtin (or by other tooling that emits the same format). State loaded +this way overrides non-UI command-line options. Useful for tooling +built on top of `brush-core` that wants to snapshot or transfer shell +state. + +### `experimental-parser` + +Switches on the in-development [`winnow`](https://crates.io/crates/winnow)-based +parser scaffolding in `brush-parser`, and adds an `--experimental-parser` +command-line flag that selects it at runtime. This parser is not yet the +production parser; the existing PEG parser remains the default. Enable +this only if you're contributing to or experimenting with the parser +work. + +--- + +## Run-time experiments (configuration) + +These features ship in standard builds but are off by default. Each one +can be enabled either through the optional [TOML configuration +file](configuration.md) (under the `[experimental]` section, persistent +across sessions) or through a command-line flag at startup (one-shot). + +When both are specified, command-line flags take precedence over the +configuration file. + +```toml +[experimental] +zsh-hooks = true +terminal-shell-integration = true +``` + +| Feature | TOML setting (`[experimental]`) | Command-line flag | +|---------|---------------------------------|-------------------| +| zsh-style hooks | `zsh-hooks = true` | `--enable-zsh-hooks` | +| Terminal shell integration | `terminal-shell-integration = true` | `--enable-terminal-integration` | + +### `zsh-hooks` + +Enables zsh-style `preexec` and `precmd` hook functions. When set: + +- A function named `preexec` (if defined) is invoked before each + interactively-entered command runs, with the command line as `$1`. +- A function named `precmd` (if defined) is invoked just before each + prompt is displayed. + +This is convenient for prompt frameworks, command timing, and +integrations that expect zsh-style hook conventions. Equivalent +behavior in stock bash typically requires `DEBUG`/`PROMPT_COMMAND` +plumbing. + +Enable persistently with `zsh-hooks = true` under `[experimental]` in +`config.toml`, or per-invocation with `brush --enable-zsh-hooks`. + +### `terminal-shell-integration` + +Emits standard terminal shell-integration escape sequences (semantic +prompt and command boundary marking) that modern terminal emulators — +including VS Code, iTerm2, WezTerm, and others — use to enable features +like command navigation, exit-status display, and selective output +copying. This is off by default to avoid emitting escape sequences in +terminals that don't recognize them. + +Enable persistently with `terminal-shell-integration = true` under +`[experimental]` in `config.toml`, or per-invocation with +`brush --enable-terminal-integration`. + +--- + +## Reporting feedback + +Experimental features are the place where your feedback is most valuable +— they exist precisely because we want to iterate on them before +stabilizing. If you try one and find a rough edge, missing capability, +or behavior that surprises you, please [file an +issue](https://github.com/reubeno/brush/issues). diff --git a/local/recipes/shells/brush/source/docs/reference/integration-testing.md b/local/recipes/shells/brush/source/docs/reference/integration-testing.md new file mode 100644 index 0000000000..200a2d2509 --- /dev/null +++ b/local/recipes/shells/brush/source/docs/reference/integration-testing.md @@ -0,0 +1,17 @@ +# Integration testing + +Our approach to integration testing relies heavily on using test oracles to provide the "correct" answers/expectations for test cases. In practice, we use existing alternate shell implementations as oracles. + +Test cases are defined in YAML files. The test cases defined in a given file comprise a test case set. Running the integration tests for this project executes test case sets in parallel. + +```yaml +name: "Example tests" +cases: + - name: "Basic usage" + stdin: | + echo hi +``` + +This defines a new test case set with the name "Example tests". It contains one defined test case called "Basic usage". This test case will launch the shell without any additional custom arguments (beyond a few standard ones to disable processing default profiles and rc files), write "echo hi" (with a trailing newline) to stdin of the shell, and then close that stream. The test harness will capture the shell's stdout, stderr, and exit code. After repeating these steps with the test oracle, each of these 3 data are compared. An error is flagged if any of the 3 differ. + +Test cases are run with the working directory initialized to a temporary directory. The contents of the temporary directory are inspected after the shell-under-test has exited, and compared against their counterparts in the oracle's run. This enables easy checking of files created, deleted, or mutated as side effects of running the test case. \ No newline at end of file diff --git a/local/recipes/shells/brush/source/docs/reference/msrv-policy.md b/local/recipes/shells/brush/source/docs/reference/msrv-policy.md new file mode 100644 index 0000000000..89d93827ce --- /dev/null +++ b/local/recipes/shells/brush/source/docs/reference/msrv-policy.md @@ -0,0 +1,37 @@ +# Minimum Supported Rust Version (MSRV) Policy + +## Overview + +The `brush` project maintains a conservative MSRV policy to balance two key concerns: + +1. **Binary distribution**: Users building `brush` from source should not need a bleeding-edge compiler +2. **Library usage**: Downstream projects depending on `brush` crates should not face aggressive MSRV increases + +## Policy + +### When We Update MSRV + +We **do not** update MSRV proactively. Updates only occur when: + +- A meaningful set of language features or capabilities becomes available that provides clear value to the project +- The return-on-investment justifies the potential impact on users and downstream dependencies + +### MSRV Age Requirements + +When we do update MSRV, we move to a Rust version that is **at least 4-6 months old** from the time of the update. This ensures: + +- Sufficient time for the Rust version to stabilize +- Wide availability in package managers and development environments +- Reduced friction for users building from source + +### Communication + +MSRV changes are always: + +- Explicitly documented in release notes +- Considered a notable change requiring user awareness +- Announced with clear justification for the update + +## Rationale + +This conservative approach recognizes that `brush` serves dual purposes: as a standalone binary tool and as a library for integration into other projects. Both use cases benefit from stability and predictability in compiler requirements. diff --git a/local/recipes/shells/brush/source/docs/tutorials/README.md b/local/recipes/shells/brush/source/docs/tutorials/README.md new file mode 100644 index 0000000000..f709339c1a --- /dev/null +++ b/local/recipes/shells/brush/source/docs/tutorials/README.md @@ -0,0 +1,3 @@ +# Tutorials + +_To be written_ \ No newline at end of file diff --git a/local/recipes/shells/brush/source/fuzz/.gitignore b/local/recipes/shells/brush/source/fuzz/.gitignore new file mode 100644 index 0000000000..1a45eee776 --- /dev/null +++ b/local/recipes/shells/brush/source/fuzz/.gitignore @@ -0,0 +1,4 @@ +target +corpus +artifacts +coverage diff --git a/local/recipes/shells/brush/source/fuzz/Cargo.toml b/local/recipes/shells/brush/source/fuzz/Cargo.toml new file mode 100644 index 0000000000..a7fa330087 --- /dev/null +++ b/local/recipes/shells/brush/source/fuzz/Cargo.toml @@ -0,0 +1,57 @@ +[package] +name = "brush-fuzz" +description = "Fuzz tests for brush" +publish = false +version = "0.2.2" +authors.workspace = true +categories.workspace = true +edition.workspace = true +keywords.workspace = true +license.workspace = true +readme.workspace = true +repository.workspace = true +rust-version.workspace = true + +[package.metadata] +cargo-fuzz = true + +[lints] +workspace = true + +[dependencies] +anyhow = "1.0.102" +assert_cmd = "2.2.0" +libfuzzer-sys = "0.4" +tokio = { version = "1.52.3", features = ["rt"] } + +[dependencies.brush-core] +path = "../brush-core" + +[dependencies.brush-parser] +path = "../brush-parser" +features = ["arbitrary"] + +[dependencies.brush-interactive] +path = "../brush-interactive" +features = ["highlighting"] + +[[bin]] +name = "fuzz_parse" +path = "fuzz_targets/fuzz_parse.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "fuzz_arithmetic" +path = "fuzz_targets/fuzz_arithmetic.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "fuzz_highlight" +path = "fuzz_targets/fuzz_highlight.rs" +test = false +doc = false +bench = false diff --git a/local/recipes/shells/brush/source/fuzz/fuzz_targets/fuzz_arithmetic.rs b/local/recipes/shells/brush/source/fuzz/fuzz_targets/fuzz_arithmetic.rs new file mode 100644 index 0000000000..5db56a94c2 --- /dev/null +++ b/local/recipes/shells/brush/source/fuzz/fuzz_targets/fuzz_arithmetic.rs @@ -0,0 +1,97 @@ +#![no_main] +#![allow(missing_docs)] +#![allow(clippy::unwrap_used)] + +use std::sync::LazyLock; + +use anyhow::Result; +use brush_parser::ast; +use libfuzzer_sys::fuzz_target; + +static TOKIO_RT: LazyLock = + LazyLock::new(|| tokio::runtime::Runtime::new().unwrap()); + +static SHELL_TEMPLATE: LazyLock = LazyLock::new(|| { + TOKIO_RT + .block_on( + brush_core::Shell::builder() + .profile(brush_core::ProfileLoadBehavior::Skip) + .rc(brush_core::RcLoadBehavior::Skip) + .build(), + ) + .unwrap() +}); + +fn eval_arithmetic(mut shell: brush_core::Shell, input: &ast::ArithmeticExpr) -> Result<()> { + const DEFAULT_TIMEOUT_IN_SECONDS: u64 = 15; + + // + // Turn it back into a string so we can pass it in on the command-line. + // + let input_str = input.to_string(); + + // + // Instantiate a brush shell with defaults, then try to evaluate the expression. + // + let parsed_expr = brush_parser::arithmetic::parse(input_str.as_str()).ok(); + let our_eval_result = if let Some(parsed_expr) = parsed_expr { + shell.eval_arithmetic(&parsed_expr).ok() + } else { + None + }; + + // + // Now run it under 'bash' + // + let mut oracle_cmd = std::process::Command::new("bash"); + oracle_cmd + .arg("--noprofile") + .arg("--norc") + .arg("-O") + .arg("extglob") + .arg("-t"); + + let mut oracle_cmd = assert_cmd::Command::from_std(oracle_cmd); + + oracle_cmd.timeout(std::time::Duration::from_secs(DEFAULT_TIMEOUT_IN_SECONDS)); + + let input = std::format!("echo \"$(( {input_str} ))\"\n"); + oracle_cmd.write_stdin(input.as_bytes()); + + let oracle_result = oracle_cmd.output()?; + let oracle_eval_result = if oracle_result.status.success() { + let oracle_output = std::str::from_utf8(&oracle_result.stdout)?; + oracle_output.trim().parse::().ok() + } else { + None + }; + + // + // Compare results. + // + if our_eval_result != oracle_eval_result { + Err(anyhow::anyhow!( + "Mismatched eval results: {oracle_eval_result:?} from oracle vs. {our_eval_result:?} from our test (expr: '{input_str}', oracle result: {oracle_result:?})" + )) + } else { + Ok(()) + } +} + +fuzz_target!(|input: ast::ArithmeticExpr| { + let s = input.to_string(); + let s = s.trim(); + + // For now, intentionally ignore known problematic cases without actually running them. + if s.contains("+ 0") + || s.is_empty() + || s.contains(|c: char| c.is_ascii_control() || !c.is_ascii()) + || s.contains("$[") + // old deprecated form of arithmetic expansion + { + return; + } + + let shell = SHELL_TEMPLATE.clone(); + eval_arithmetic(shell, &input).unwrap(); +}); diff --git a/local/recipes/shells/brush/source/fuzz/fuzz_targets/fuzz_highlight.rs b/local/recipes/shells/brush/source/fuzz/fuzz_targets/fuzz_highlight.rs new file mode 100644 index 0000000000..c5c46c86bc --- /dev/null +++ b/local/recipes/shells/brush/source/fuzz/fuzz_targets/fuzz_highlight.rs @@ -0,0 +1,92 @@ +#![no_main] +#![allow(missing_docs)] +#![allow(clippy::unwrap_used)] +#![allow(clippy::panic)] + +use libfuzzer_sys::fuzz_target; +use std::sync::LazyLock; + +use brush_interactive::highlighting::{Highlighted, highlight_command}; + +static TOKIO_RT: LazyLock = + LazyLock::new(|| tokio::runtime::Runtime::new().unwrap()); + +static SHELL_TEMPLATE: LazyLock = LazyLock::new(|| { + TOKIO_RT + .block_on( + brush_core::Shell::builder() + .profile(brush_core::ProfileLoadBehavior::Skip) + .rc(brush_core::RcLoadBehavior::Skip) + .build(), + ) + .unwrap() +}); + +/// Clamps `raw` into `[0, line.len()]` and down to a UTF-8 char boundary. +const fn snap_cursor(line: &str, raw: u32) -> usize { + let upper = line.len(); + if upper == 0 { + return 0; + } + let mut pos = (raw as usize) % (upper + 1); + while pos > 0 && !line.is_char_boundary(pos) { + pos -= 1; + } + pos +} + +fn assert_invariants(highlighted: &Highlighted<'_>) { + let line = highlighted.line(); + + // 1. Every span lands on UTF-8 char boundaries and the range is valid. + for span in highlighted.spans() { + assert!( + span.range.start <= span.range.end, + "span has start > end: {span:?} (line={line:?})", + ); + assert!( + span.range.end <= line.len(), + "span end exceeds line length: {span:?} (line.len()={}, line={line:?})", + line.len(), + ); + assert!( + line.is_char_boundary(span.range.start), + "span start not on char boundary: {span:?} (line={line:?})", + ); + assert!( + line.is_char_boundary(span.range.end), + "span end not on char boundary: {span:?} (line={line:?})", + ); + } + + // 2. Spans are ordered and contiguous, covering the entire input. + let mut next_expected_start = 0usize; + for span in highlighted.spans() { + assert_eq!( + span.range.start, next_expected_start, + "spans are not contiguous: gap or overlap before {span:?} \ + (expected start={next_expected_start}, line={line:?})", + ); + next_expected_start = span.range.end; + } + assert_eq!( + next_expected_start, + line.len(), + "spans do not cover entire input (covered {next_expected_start} of {}, line={line:?})", + line.len(), + ); + + // 3. Resolving the text of each span must not panic. + for (_, _) in highlighted.iter() {} +} + +// `raw_cursor` is clamped into the line and snapped to a char boundary below. +fuzz_target!(|input: (String, u32)| { + let (line, raw_cursor) = input; + let cursor = snap_cursor(&line, raw_cursor); + + let shell = SHELL_TEMPLATE.clone(); + let highlighted = highlight_command(&shell, &line, cursor); + + assert_invariants(&highlighted); +}); diff --git a/local/recipes/shells/brush/source/fuzz/fuzz_targets/fuzz_parse.rs b/local/recipes/shells/brush/source/fuzz/fuzz_targets/fuzz_parse.rs new file mode 100644 index 0000000000..b59048b5d1 --- /dev/null +++ b/local/recipes/shells/brush/source/fuzz/fuzz_targets/fuzz_parse.rs @@ -0,0 +1,82 @@ +#![no_main] +#![allow(missing_docs)] +#![allow(clippy::unwrap_used)] + +use anyhow::Result; +use libfuzzer_sys::fuzz_target; +use std::sync::LazyLock; + +static TOKIO_RT: LazyLock = + LazyLock::new(|| tokio::runtime::Runtime::new().unwrap()); + +static SHELL_TEMPLATE: LazyLock = LazyLock::new(|| { + TOKIO_RT + .block_on( + brush_core::Shell::builder() + .profile(brush_core::ProfileLoadBehavior::Skip) + .rc(brush_core::RcLoadBehavior::Skip) + .build(), + ) + .unwrap() +}); + +#[expect(clippy::unused_async)] +async fn parse_async(shell: brush_core::Shell, input: String) -> Result<()> { + const DEFAULT_TIMEOUT_IN_SECONDS: u64 = 15; + + // + // Instantiate a brush shell with defaults, then try to parse the input. + // + let our_parse_result = shell.parse_string(input.clone()); + + // + // Now run it under 'bash -n -t' as a crude way to see if it's at least valid syntax. + // + let mut oracle_cmd = std::process::Command::new("bash"); + oracle_cmd + .arg("--noprofile") + .arg("--norc") + .arg("-O") + .arg("extglob") + .arg("-n") + .arg("-t"); + + let mut oracle_cmd = assert_cmd::Command::from_std(oracle_cmd); + + oracle_cmd.timeout(std::time::Duration::from_secs(DEFAULT_TIMEOUT_IN_SECONDS)); + + let mut input = input; + input.push('\n'); + oracle_cmd.write_stdin(input.as_bytes()); + + let oracle_result = oracle_cmd.output()?; + + // + // Compare results. + // + if our_parse_result.is_ok() != oracle_result.status.success() { + Err(anyhow::anyhow!( + "Mismatched parse results: {oracle_result:?} vs {our_parse_result:?}" + )) + } else { + Ok(()) + } +} + +fuzz_target!(|input: String| { + // Ignore known problematic cases without actually running them. + if input.is_empty() + || input.contains(|c: char| c.is_ascii_control() || !c.is_ascii()) // non-ascii chars (or control sequences) + || input.contains('!') // history expansions + || (input.contains('[') && !input.contains(']')) // ??? + || input.contains("<<") // weirdness with here docs + || input.ends_with('\\') // unterminated trailing escape char? + || input.contains("|&") + // unimplemented bash-ism + { + return; + } + + let shell = SHELL_TEMPLATE.clone(); + TOKIO_RT.block_on(parse_async(shell, input)).unwrap(); +}); diff --git a/local/recipes/shells/brush/source/release-plz.toml b/local/recipes/shells/brush/source/release-plz.toml new file mode 100644 index 0000000000..2e19b44bbe --- /dev/null +++ b/local/recipes/shells/brush/source/release-plz.toml @@ -0,0 +1,35 @@ +[workspace] +# disable the changelog for all packages +# ref: https://release-plz.ieni.dev/docs/extra/single-changelog +changelog_config = "cliff.toml" +changelog_update = false +git_release_enable = false +publish = true + +[[package]] +name = "brush" +version_group = "brush-shell-group" # Version-locked to brush-shell + +[[package]] +name = "brush-shell" +version_group = "brush-shell-group" +changelog_update = true +changelog_path = "./CHANGELOG.md" +changelog_include = [ + "brush-core", + "brush-interactive-shell", + "brush-parser", +] +git_release_latest = true +git_release_draft = true +git_release_enable = true +git_release_name = "brush v{{ version }}" +git_release_body = """ +{{ changelog }} +{% if remote.contributors %} +### Contributors +{% for contributor in remote.contributors %} +* @{{ contributor.username }} +{% endfor %} +{% endif %} +""" diff --git a/local/recipes/shells/brush/source/rustfmt.toml b/local/recipes/shells/brush/source/rustfmt.toml new file mode 100644 index 0000000000..64dfb8818d --- /dev/null +++ b/local/recipes/shells/brush/source/rustfmt.toml @@ -0,0 +1,3 @@ +edition = "2024" +comment_width = 100 +wrap_comments = true diff --git a/local/recipes/shells/brush/source/samples/config.toml b/local/recipes/shells/brush/source/samples/config.toml new file mode 100644 index 0000000000..eef86215e5 --- /dev/null +++ b/local/recipes/shells/brush/source/samples/config.toml @@ -0,0 +1,22 @@ +# Sample brush configuration file +# Copy this file to ~/.config/brush/config.toml on Linux/macOS +# or %APPDATA%\brush\config.toml on Windows +# +# For schema-based validation in VS Code (with Even Better TOML extension): +#:schema https://raw.githubusercontent.com/reubeno/brush/main/schemas/config.schema.json + +# User interface settings +[ui] +# Enable syntax highlighting in the input line. +# Default: false (true when built with the 'experimental' feature) +syntax-highlighting = true + +# Experimental features (may change or be removed in future versions) +[experimental] +# Enable zsh-style preexec/precmd hooks. +# Default: false +zsh-hooks = false + +# Enable terminal shell integration (for terminals that support it). +# Default: false +terminal-shell-integration = false diff --git a/local/recipes/shells/brush/source/schemas/config.schema.json b/local/recipes/shells/brush/source/schemas/config.schema.json new file mode 100644 index 0000000000..d00cd917ba --- /dev/null +++ b/local/recipes/shells/brush/source/schemas/config.schema.json @@ -0,0 +1,54 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Config", + "description": "Root configuration structure for the brush shell.\n\nAll fields are optional to support forward compatibility and partial configuration.\nUnknown fields in the TOML file are silently ignored.", + "type": "object", + "properties": { + "experimental": { + "description": "Experimental features configuration.", + "$ref": "#/$defs/ExperimentalConfig" + }, + "ui": { + "description": "User interface configuration options.", + "$ref": "#/$defs/UiConfig" + } + }, + "$defs": { + "ExperimentalConfig": { + "description": "Experimental features configuration.\n\nThese options control unstable features that may change or be removed in future versions.", + "type": "object", + "properties": { + "terminal-shell-integration": { + "description": "Enable terminal shell integration.", + "type": [ + "boolean", + "null" + ], + "default": null + }, + "zsh-hooks": { + "description": "Enable zsh-style preexec/precmd hooks.", + "type": [ + "boolean", + "null" + ], + "default": null + } + } + }, + "UiConfig": { + "description": "User interface configuration options.", + "type": "object", + "properties": { + "syntax-highlighting": { + "description": "Enable syntax highlighting in the input line.", + "type": [ + "boolean", + "null" + ], + "default": null + } + } + } + } +} diff --git a/local/recipes/shells/brush/source/scripts/compare-benchmark-results.py b/local/recipes/shells/brush/source/scripts/compare-benchmark-results.py new file mode 100755 index 0000000000..8d065734bd --- /dev/null +++ b/local/recipes/shells/brush/source/scripts/compare-benchmark-results.py @@ -0,0 +1,95 @@ +#!/usr/bin/python3 +import argparse +import re +from dataclasses import dataclass +from typing import Dict + +parser = argparse.ArgumentParser() +parser.add_argument("-b", "--base-results", dest="base_results_file_path", type=str, help="Path to base results output file", required=True) +parser.add_argument("-t", "--test-results", dest="test_results_file_path", type=str, help="Path to test results output file", required=True) + +args = parser.parse_args() + +@dataclass +class Benchmark: + test_name: str + duration_in_ns: int + plus_or_minus_in_ns: int + +def parse_benchmarks_results(file_path: str) -> Dict[str, Benchmark]: + benchmarks = {} + + with open(file_path, "r") as file: + for line in file.readlines(): + match = re.match(r"test (.*) \.\.\. bench: +([\d,]+) ns/iter \(\+/- ([\d,]+)\)", line.strip()) + if match: + benchmark = Benchmark( + test_name=match.group(1), + duration_in_ns=int(match.group(2).replace(',', '')), + plus_or_minus_in_ns=int(match.group(3).replace(',', '')) + ) + + benchmarks[benchmark.test_name] = benchmark + + return benchmarks + +base_results = parse_benchmarks_results(args.base_results_file_path) +test_results = parse_benchmarks_results(args.test_results_file_path) + +base_test_names = set(base_results.keys()) +test_test_names = set(test_results.keys()) + +removed_from_base = base_test_names - test_test_names +added_by_test = test_test_names - base_test_names +common = base_test_names & test_test_names + +print("# Performance Benchmark Report") + +if common: + print(f"| {'Benchmark name':38} | {'Baseline (μs)':>13} | {'Test/PR (μs)':>13} | {'Delta (μs)':>13} | {'Delta %':15} |") + print(f"| {'-' * 38} | {'-' * 13} | {'-' * 13} | {'-' * 13} | {'-' * 15} |") + for name in sorted(common): + # Retrieve base data + base_duration = base_results[name].duration_in_ns / 1000.0 + base_plus_or_minus = base_results[name].plus_or_minus_in_ns / 1000.0 + base_plus_or_minus_percentage = (100.0 * base_plus_or_minus) / base_duration + + # Retrieve test data + test_duration = test_results[name].duration_in_ns / 1000.0 + test_plus_or_minus = test_results[name].plus_or_minus_in_ns / 1000.0 + test_plus_or_minus_percentage = (100.0 * test_plus_or_minus) / test_duration + + # Compute delta + delta_duration = test_duration - base_duration + delta_percentage = (100.0 * delta_duration) / base_duration + abs_delta_percentage = abs(delta_percentage) + max_plus_or_minus_percentage = max(base_plus_or_minus_percentage, test_plus_or_minus_percentage) + + # Format + delta_str = f"{delta_duration:8.2f}" + + if abs_delta_percentage > max_plus_or_minus_percentage: + if delta_percentage < 0: + delta_prefix = "🟢 " + elif delta_percentage > 0: + delta_prefix = "🟠 +" + else: + delta_prefix = "⚪ " + + delta_percentage_str = f"{delta_prefix}{delta_percentage:.2f}%" + else: + delta_percentage_str = "⚪ Unchanged" + + print(f"| `{name:36}` | `{base_duration:8.2f} μs` | `{test_duration:8.2f} μs` | `{delta_str:>8} μs` | `{delta_percentage_str:12}` |") + +if removed_from_base: + print() + print("Benchmarks removed:") + for name in removed_from_base: + print(f" - {name}") + +if added_by_test: + print() + print("Benchmarks added:") + for name in added_by_test: + print(f" - {name}") diff --git a/local/recipes/shells/brush/source/scripts/enum-lib-crates.sh b/local/recipes/shells/brush/source/scripts/enum-lib-crates.sh new file mode 100755 index 0000000000..76feefbc22 --- /dev/null +++ b/local/recipes/shells/brush/source/scripts/enum-lib-crates.sh @@ -0,0 +1,10 @@ +#!/bin/bash +set -euo pipefail + +cargo metadata --no-deps --format-version 1 | jq -r ' + .workspace_members as $members + | .packages[] + | select(.id as $id | $members | index($id)) + | select(any(.targets[]?; any(.kind[]?; . == "lib"))) + | .name +' diff --git a/local/recipes/shells/brush/source/scripts/format-api-diff-report.py b/local/recipes/shells/brush/source/scripts/format-api-diff-report.py new file mode 100755 index 0000000000..17d8288691 --- /dev/null +++ b/local/recipes/shells/brush/source/scripts/format-api-diff-report.py @@ -0,0 +1,69 @@ +#!/usr/bin/python3 +import argparse +import sys + +parser = argparse.ArgumentParser(description='Format API diff report') +parser.add_argument("-p", dest="crate_name", type=str, help="Name of the crate", required=True) +parser.add_argument("diff_path", type=str, help="Path to API diff file from cargo-public-api") + +args = parser.parse_args() + +with open(args.diff_path, "r") as diff_file: + diff_lines = diff_file.readlines() + +removed_lines = [] +added_lines = [] +changed_lines = [] + +current_section = None + +for line in diff_lines: + line = line.strip() + + if "Removed items" in line: + current_section = removed_lines + elif "Added items" in line: + current_section = added_lines + elif "Changed items" in line: + current_section = changed_lines + elif "============" in line: + continue + elif line == "(none)": + continue + elif not line: + continue + elif current_section is not None: + current_section.append(line) + +if not removed_lines and not added_lines and not changed_lines: + sys.stderr.write("note: no API changes detected\n") + sys.exit(0) + +print(f"# Public API changes for crate: {args.crate_name}") + +if removed_lines: + print() + print("## Removed items") + + print("```") + for line in removed_lines: + print(line) + print("```") + +if added_lines: + print() + print("## Added items") + + print("```") + for line in added_lines: + print(line) + print("```") + +if changed_lines: + print() + print("## Changed items") + + print("```") + for line in changed_lines: + print(line) + print("```") diff --git a/local/recipes/shells/brush/source/scripts/start-dev-container.py b/local/recipes/shells/brush/source/scripts/start-dev-container.py new file mode 100755 index 0000000000..1a505f0efe --- /dev/null +++ b/local/recipes/shells/brush/source/scripts/start-dev-container.py @@ -0,0 +1,137 @@ +#!/bin/env python3 +import argparse +import json +import os +import shlex +import shutil +import subprocess +import sys + +parser = argparse.ArgumentParser() +parser.add_argument( + "--rebuild-container-image", + help="Rebuild the devcontainer image and recreate the container before entering", + dest="rebuild_container_image", + action="store_true", +) +parser.add_argument( + "--restart-container", + help="Restart the devcontainer before entering", + dest="restart_container", + action="store_true", +) +parser.add_argument( + "--command", + help="Command to run", + dest="command", + default="bash", +) +parser.add_argument( + "--quiet", + help="Only generate minimal output", + dest="quiet", + action="store_true", +) +parser.add_argument( + "--env-file", + help="Environment file to pass into the container", + dest="env_file", + type=str, +) + +args = parser.parse_args() + +if args.rebuild_container_image: + # One implies the other. + args.restart_container = True + +this_script_dir = os.path.dirname(os.path.abspath(__file__)) +source_root = os.path.join(this_script_dir, "..") + +if not os.path.isdir(os.path.join(source_root, ".devcontainer")): + sys.stderr.write("error: couldn't find repo root\n") + sys.exit(1) + +if not shutil.which("devcontainer"): + sys.stderr.write( + "error: couldn't find devcontainer CLI; did you install it as a prerequisite?\n" + ) + sys.exit(1) + +devcontainer_cli_args = [ + "devcontainer", + "up", + "--workspace-folder", + source_root, +] + +if args.rebuild_container_image or args.restart_container: + restart_args = devcontainer_cli_args.copy() + + if args.rebuild_container_image: + restart_args.append("--build-no-cache") + if args.restart_container: + restart_args.append("--remove-existing-container") + + if args.rebuild_container_image: + sys.stderr.write("Rebuilding devcontainer image...\n") + else: + sys.stderr.write("Restarting devcontainer...\n") + + subprocess.run(restart_args, check=True, capture_output=args.quiet) + +if not args.quiet: + sys.stderr.write("Ensuring devcontainer is up...\n") + +up_result = subprocess.run(devcontainer_cli_args, check=False, capture_output=True) + +if up_result.returncode != 0: + sys.stderr.write("error: devcontainer start-up failed\n") + sys.stderr.write(up_result.stderr.decode("utf-8")) + sys.exit(1) + +result_data = json.loads(up_result.stdout.decode("utf-8")) + +if "outcome" not in result_data: + sys.stderr.write("error: couldn't determine outcome of devcontainer start-up\n") + sys.exit(1) + +if result_data["outcome"] != "success": + sys.stderr.write("error: devcontainer start-up failed\n") + sys.exit(1) + +if "containerId" not in result_data: + sys.stderr.write("error: couldn't determine container ID\n") + sys.exit(1) + +if "remoteWorkspaceFolder" not in result_data: + sys.stderr.write("error: couldn't determine remote workspace folder\n") + sys.exit(1) + +container_id = result_data["containerId"] +remote_workspace_folder = result_data["remoteWorkspaceFolder"] + +if not args.quiet: + sys.stderr.write("Launching shell in devcontainer...\n") + +exec_args = [ + "docker", + "container", + "exec", + "-it", + "--workdir", + remote_workspace_folder, +] + +if args.env_file: + exec_args += ["--env-file", args.env_file] + +exec_args.append(container_id) + +exec_args += shlex.split(args.command) + +exec_result = subprocess.run(exec_args, check=False) + +sys.stderr.write("Command exited; devcontainer will remain running.\n") + +sys.exit(exec_result.returncode) diff --git a/local/recipes/shells/brush/source/scripts/summarize-pytest-results.py b/local/recipes/shells/brush/source/scripts/summarize-pytest-results.py new file mode 100755 index 0000000000..0626f00e22 --- /dev/null +++ b/local/recipes/shells/brush/source/scripts/summarize-pytest-results.py @@ -0,0 +1,48 @@ +#!/usr/bin/python3 +import argparse +import json + +parser = argparse.ArgumentParser(description='Summarize pytest results') +parser.add_argument("-r", "--results", dest="results_file_path", type=str, required=True, help="Path to .json pytest results file") +parser.add_argument("--title", dest="title", type=str, default="Pytest results", help="Title to display") + +args = parser.parse_args() + +with open(args.results_file_path, "r") as results_file: + results = json.load(results_file) + +summary = results["summary"] + +error_count = summary.get("error") or 0 +fail_count = summary.get("failed") or 0 +pass_count = summary.get("passed") or 0 +skip_count = summary.get("skipped") or 0 +expected_fail_count = summary.get("xfailed") or 0 +unexpected_pass_count = summary.get("xpassed") or 0 + +total_count = summary.get("total") or 0 +collected_count = summary.get("collected") or 0 +deselected_count = summary.get("deselected") or 0 + +# +# Output +# + +print(f"# {args.title}") + +print(f"| Outcome | Count | Percentage |") +print(f"| ------------------ | ----------------------: | ---------: |") +print(f"| ✅ Pass | {pass_count} | {pass_count * 100 / total_count:.2f} |") + +if error_count > 0: + print(f"| ❗️ Error | {error_count} | {error_count * 100 / total_count:.2f} |") +if fail_count > 0: + print(f"| ❌ Fail | {fail_count} | {fail_count * 100 / total_count:.2f} |") +if skip_count > 0: + print(f"| ⏩ Skip | {skip_count} | {skip_count * 100 / total_count:.2f} |") +if expected_fail_count > 0: + print(f"| ❎ Expected Fail | {expected_fail_count} | {expected_fail_count * 100 / total_count:.2f} |") +if unexpected_pass_count > 0: + print(f"| ✔️ Unexpected Pass | {unexpected_pass_count} | {unexpected_pass_count * 100 / total_count:.2f} |") + +print(f"| 📊 Total | {total_count} | {total_count * 100 / total_count:.2f} |") diff --git a/local/recipes/shells/brush/source/scripts/test-across-shells.sh b/local/recipes/shells/brush/source/scripts/test-across-shells.sh new file mode 100755 index 0000000000..4c1b80c56b --- /dev/null +++ b/local/recipes/shells/brush/source/scripts/test-across-shells.sh @@ -0,0 +1,217 @@ +#!/bin/bash +set -euo pipefail + +# Script to test shell script execution across different shells with detailed tracing +# Usage: test-across-shells.sh --output-dir=DIR --oracle-shell=SHELL --test-shell=SHELL SCRIPT [ARGS...] + +# Color codes for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +usage() { + cat >&2 </ + oracle/ + stdout.txt Standard output from oracle shell + stderr.txt Standard error from oracle shell + trace.txt Execution trace (set -x output) + exit_code.txt Exit code from oracle shell + test/ + stdout.txt Standard output from test shell + stderr.txt Standard error from test shell + trace.txt Execution trace (set -x output) + exit_code.txt Exit code from test shell + +The script sets BASH_XTRACEFD=3 to redirect trace output to fd 3, enables +tracing with 'set -x', and sets PS4 to include script name and line number. +EOF + exit 1 +} + +# Parse command-line arguments +output_dir="" +oracle_shell="bash" +test_shell="" +script_path="" +declare -a script_args=() +parsing_options=true + +while [[ $# -gt 0 ]]; do + if [[ "$parsing_options" == true ]]; then + case "$1" in + --output-dir=*) + output_dir="${1#*=}" + shift + ;; + --oracle-shell=*) + oracle_shell="${1#*=}" + shift + ;; + --test-shell=*) + test_shell="${1#*=}" + shift + ;; + --help|-h) + usage + ;; + --) + # Stop parsing options, everything after this is for the script + parsing_options=false + shift + ;; + -*) + echo -e "${RED}Error: Unknown option: $1${NC}" >&2 + usage + ;; + *) + # First non-option argument is the script path + script_path="$1" + parsing_options=false + shift + ;; + esac + else + # We've stopped parsing options + if [[ -z "$script_path" ]]; then + script_path="$1" + else + script_args+=("$1") + fi + shift + fi +done + +# Validate required arguments +if [[ -z "$output_dir" ]]; then + echo -e "${RED}Error: --output-dir is required${NC}" >&2 + usage +fi + +if [[ -z "$test_shell" ]]; then + echo -e "${RED}Error: --test-shell is required${NC}" >&2 + usage +fi + +if [[ -z "$script_path" ]]; then + echo -e "${RED}Error: SCRIPT path is required${NC}" >&2 + usage +fi + +if [[ ! -f "$script_path" ]]; then + echo -e "${RED}Error: Script file not found: $script_path${NC}" >&2 + exit 1 +fi + +# Verify shells exist and are executable +for shell_name in oracle_shell test_shell; do + shell_path="${!shell_name}" + if ! command -v "$shell_path" >/dev/null 2>&1; then + echo -e "${RED}Error: Shell not found or not executable: $shell_path${NC}" >&2 + exit 1 + fi +done + +# Create output directory structure +mkdir -p "$output_dir/oracle" +mkdir -p "$output_dir/test" + +# Log the parsed configuration +echo -e "${BLUE}📋 Configuration:${NC}" +echo " Oracle shell: $oracle_shell" +echo " Test shell: $test_shell" +echo " Script: $script_path" +if [[ ${#script_args[@]} -gt 0 ]]; then + echo " Arguments: ${script_args[*]}" +else + echo " Arguments: (none)" +fi +echo " Output dir: $output_dir" +echo "" + +# Function to execute script under a given shell +execute_with_shell() { + local shell_path="$1" + local output_subdir="$2" + local stdout_file="$output_dir/$output_subdir/stdout.txt" + local stderr_file="$output_dir/$output_subdir/stderr.txt" + local trace_file="$output_dir/$output_subdir/trace.txt" + local exit_code_file="$output_dir/$output_subdir/exit_code.txt" + + # Execute the shell with: + # - BASH_XTRACEFD=3 set in the environment + # - PS4 set to include script name and line number + # - fd 3 redirected to trace file + # - -x flag to enable tracing + # - stdout to stdout file + # - stderr to stderr file + env BASH_XTRACEFD=3 PS4='+${BASH_SOURCE:-}:${LINENO:-}: ' \ + "$shell_path" -x "$script_path" "${script_args[@]}" \ + >"$stdout_file" \ + 2>"$stderr_file" \ + 3>"$trace_file" + + local exit_code=$? + echo "$exit_code" > "$exit_code_file" + return $exit_code +} + +# Execute under oracle shell +echo -e "${BLUE}🔍 Executing under oracle shell ($oracle_shell)...${NC}" +if execute_with_shell "$oracle_shell" "oracle"; then + oracle_exit=0 + echo -e "${GREEN}✅ Oracle shell completed successfully (exit code: 0)${NC}" +else + oracle_exit=$? + echo -e "${YELLOW}⚠️ Oracle shell exited with code: $oracle_exit${NC}" +fi + +# Execute under test shell +echo -e "${BLUE}🧪 Executing under test shell ($test_shell)...${NC}" +if execute_with_shell "$test_shell" "test"; then + test_exit=0 + echo -e "${GREEN}✅ Test shell completed successfully (exit code: 0)${NC}" +else + test_exit=$? + echo -e "${YELLOW}⚠️ Test shell exited with code: $test_exit${NC}" +fi + +# Report results +echo "" +echo -e "📁 Execution complete. Output written to: $output_dir" +echo " Oracle shell exit code: $oracle_exit" +echo " Test shell exit code: $test_exit" +echo "" + +if [[ $oracle_exit -eq $test_exit ]]; then + echo -e "${GREEN}✅ Exit codes match ✓${NC}" +else + echo -e "${RED}❌ Exit codes differ ✗${NC}" +fi + +# Exit with non-zero if the test results differ +if [[ $oracle_exit -ne $test_exit ]]; then + exit 1 +fi + +exit 0 diff --git a/local/recipes/shells/brush/source/scripts/test-code-coverage.sh b/local/recipes/shells/brush/source/scripts/test-code-coverage.sh new file mode 100755 index 0000000000..dfbb758849 --- /dev/null +++ b/local/recipes/shells/brush/source/scripts/test-code-coverage.sh @@ -0,0 +1,16 @@ +#!/usr/bin/bash +set -euo pipefail + +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd) +workspace_root="$(realpath "${script_dir}/..")" + +export CARGO_TARGET_DIR="${workspace_root}/target/cov" + +cd "${workspace_root}" +source <(cargo llvm-cov show-env --export-prefix) + +cargo llvm-cov clean --workspace + +cargo test -- --show-output || true + +cargo llvm-cov report --html --ignore-filename-regex "target/.*" \ No newline at end of file diff --git a/local/recipes/shells/brush/source/xtask/Cargo.toml b/local/recipes/shells/brush/source/xtask/Cargo.toml new file mode 100644 index 0000000000..ef035aa0f5 --- /dev/null +++ b/local/recipes/shells/brush/source/xtask/Cargo.toml @@ -0,0 +1,49 @@ +[package] +name = "xtask" +publish = false +version = "0.1.0" +authors.workspace = true +categories.workspace = true +edition.workspace = true +keywords.workspace = true +license.workspace = true +readme.workspace = true +repository.workspace = true +rust-version.workspace = true + +[[bin]] +name = "xtask" +path = "src/main.rs" +bench = false + +[lints] +workspace = true + +[dependencies] +anyhow = "1.0.102" +num_cpus = "1.17.0" +serde = { version = "1.0.228", features = ["derive"] } +brush-shell = { version = "^0.4.0", path = "../brush-shell", features = [ + "schema", +] } +clap = { version = "4.6.0", features = ["derive"] } +clap_complete = "4.6.4" +clap_mangen = "0.3.0" +clap-markdown = "0.1.5" +schemars = "1.2.1" +serde_json = "1.0.149" +xshell = "0.2.7" +tempfile = "3.27.0" + +[target.'cfg(unix)'.dependencies] +libc = "0.2.184" + +[build-dependencies] +# Defines the `pty` cfg (see build.rs) used to gate pty-based bash test code. +cfg_aliases = "0.2.1" + +# pty-process only supports these platforms (it requires rustix's ptsname); +# pty-based bash tests are reported as unsupported elsewhere. Keep this list in +# sync with the `pty` cfg in build.rs (which gates the code using this dependency). +[target.'cfg(any(target_os = "linux", target_os = "android", target_os = "macos", target_os = "freebsd"))'.dependencies] +pty-process = "0.5.3" diff --git a/local/recipes/shells/brush/source/xtask/build.rs b/local/recipes/shells/brush/source/xtask/build.rs new file mode 100644 index 0000000000..f7fc6d7b42 --- /dev/null +++ b/local/recipes/shells/brush/source/xtask/build.rs @@ -0,0 +1,11 @@ +//! Defines a `pty` cfg for platforms that have a working PTY backend (i.e. +//! where the `pty-process` library we use for pty-based bash tests is supported). +//! +//! Keep this predicate in sync with the `[target.'cfg(...)'.dependencies]` +//! section in `Cargo.toml` (Cargo manifests can't reference custom cfg names). + +fn main() { + cfg_aliases::cfg_aliases! { + pty: { any(target_os = "linux", target_os = "android", target_os = "macos", target_os = "freebsd") }, + } +} diff --git a/local/recipes/shells/brush/source/xtask/src/analyze.rs b/local/recipes/shells/brush/source/xtask/src/analyze.rs new file mode 100644 index 0000000000..7bd5acc3ab --- /dev/null +++ b/local/recipes/shells/brush/source/xtask/src/analyze.rs @@ -0,0 +1,208 @@ +//! Analysis commands for benchmarks and API diffing. +//! +//! This module provides tools for: +//! - Running performance benchmarks with optional output capture +//! - Comparing public API changes between branches using `cargo-public-api` + +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use clap::Parser; +use xshell::{Shell, cmd}; + +/// Run analysis and comparison tools. +#[derive(Parser)] +pub enum AnalyzeCommand { + /// Run benchmarks and output results. + Bench(BenchArgs), + /// Compare public API against a base branch. + PublicApi(PublicApiArgs), +} + +/// Arguments for benchmark analysis. +#[derive(Parser)] +pub struct BenchArgs { + /// Output file for benchmark results (bencher format). + #[clap(long, short = 'o')] + output: Option, +} + +/// Arguments for public API analysis. +#[derive(Parser)] +pub struct PublicApiArgs { + /// Base branch to compare against (e.g., 'main' or 'origin/main'). + #[clap(long, short = 'b', default_value = "origin/main")] + base: String, + + /// Output directory for API diff reports. + #[clap(long, short = 'o', default_value = "reports")] + output_dir: PathBuf, +} + +/// Run an analysis command. +pub fn run(cmd: &AnalyzeCommand, verbose: bool) -> Result<()> { + let sh = Shell::new()?; + + match cmd { + AnalyzeCommand::Bench(args) => run_bench(&sh, args, verbose), + AnalyzeCommand::PublicApi(args) => run_public_api(&sh, args, verbose), + } +} + +/// Run benchmarks using `cargo bench`. +/// +/// When an output file is specified, benchmarks are run with `--output-format bencher` +/// to produce machine-readable output suitable for CI comparison tools. +fn run_bench(sh: &Shell, args: &BenchArgs, verbose: bool) -> Result<()> { + eprintln!("Running benchmarks..."); + + if let Some(output) = &args.output { + // Run with output capture to file using tee + if verbose { + eprintln!("Running: cargo bench --workspace --benches -- --output-format bencher"); + } + let bench_output = cmd!( + sh, + "cargo bench --workspace --benches -- --output-format bencher" + ) + .read() + .context("Benchmarks failed")?; + + // Write to file + sh.write_file(output, &bench_output)?; + // Also print to stdout + println!("{bench_output}"); + } else { + // Run without file output + if verbose { + eprintln!("Running: cargo bench --workspace --benches"); + } + cmd!(sh, "cargo bench --workspace --benches") + .run() + .context("Benchmarks failed")?; + } + + eprintln!("Benchmarks completed."); + Ok(()) +} + +/// Analyze public API changes between the current branch and a base branch. +/// +/// This uses `cargo-public-api` to generate diffs for each library crate, +/// then formats them into markdown reports using a Python script. +/// Requires nightly Rust and `cargo-public-api` to be installed. +fn run_public_api(sh: &Shell, args: &PublicApiArgs, verbose: bool) -> Result<()> { + eprintln!("Analyzing public API against {}...", args.base); + + // Ensure cargo-public-api is available + if verbose { + eprintln!("Running: cargo +nightly public-api --version"); + } + cmd!(sh, "cargo +nightly public-api --version") + .run() + .context("cargo-public-api not installed. Install with: cargo install cargo-public-api")?; + + // Create output directories + let diffs_dir = args.output_dir.join("diffs"); + let reports_dir = args.output_dir.clone(); + + sh.create_dir(&diffs_dir)?; + sh.create_dir(&reports_dir)?; + + // Get list of library crates + if verbose { + eprintln!("Running: ./scripts/enum-lib-crates.sh"); + } + let crates_output = cmd!(sh, "./scripts/enum-lib-crates.sh") + .read() + .context("Failed to enumerate library crates")?; + + let crates: Vec<&str> = crates_output.lines().collect(); + + if crates.is_empty() { + eprintln!("No library crates found."); + return Ok(()); + } + + let base = &args.base; + + // Track failures to report at the end + let mut failed_crates: Vec = Vec::new(); + + for crate_name in &crates { + eprintln!("Analyzing crate: {crate_name}"); + + let diff_file = diffs_dir.join(format!("{crate_name}.txt")); + let report_file = reports_dir.join(format!("api-diff-{crate_name}.md")); + + let diff_path = diff_file.display().to_string(); + let report_path = report_file.display().to_string(); + + // Run public-api diff. + // The -sss flags are shorthand for three levels of --simplified output, + // which suppresses less relevant API details (blanket impls, auto traits, etc.) + // to focus on the most important public API changes. + // We use ignore_status() because diff returns non-zero if there are differences. + if verbose { + eprintln!("Running: cargo +nightly public-api diff -sss -p {crate_name} {base}..HEAD"); + } + let diff_result = cmd!( + sh, + "cargo +nightly public-api diff -sss -p {crate_name} {base}..HEAD" + ) + .ignore_status() + .read(); + + match diff_result { + Ok(diff_output) => { + sh.write_file(&diff_file, &diff_output)?; + + // Format the report using the Python script + if verbose { + eprintln!( + "Running: ./scripts/format-api-diff-report.py {diff_path} -p {crate_name}" + ); + } + let format_result = cmd!( + sh, + "./scripts/format-api-diff-report.py {diff_path} -p {crate_name}" + ) + .read(); + + match format_result { + Ok(report) => { + if report.trim().is_empty() { + eprintln!(" No API changes for {crate_name}"); + } else { + sh.write_file(&report_file, &report)?; + eprintln!(" Report written to {report_path}"); + } + } + Err(e) => { + eprintln!(" Error: Failed to format report for {crate_name}: {e}"); + failed_crates.push((*crate_name).to_string()); + } + } + } + Err(e) => { + eprintln!(" Error: Failed to analyze {crate_name}: {e}"); + failed_crates.push((*crate_name).to_string()); + } + } + } + + eprintln!( + "Public API analysis complete. Reports in: {}", + reports_dir.display() + ); + + // Return error if any crates failed + if !failed_crates.is_empty() { + anyhow::bail!( + "Public API analysis failed for: {}", + failed_crates.join(", ") + ); + } + + Ok(()) +} diff --git a/local/recipes/shells/brush/source/xtask/src/bash_tests.rs b/local/recipes/shells/brush/source/xtask/src/bash_tests.rs new file mode 100644 index 0000000000..7556e034d9 --- /dev/null +++ b/local/recipes/shells/brush/source/xtask/src/bash_tests.rs @@ -0,0 +1,2041 @@ +//! Runner for the upstream bash test suite. +//! +//! Parses the `run-*` scripts from the bash source tree to discover tests, +//! then executes them against a shell binary and compares output against +//! `.right` expected-output files (static mode) or a reference bash binary +//! (oracle mode). + +use std::collections::BTreeMap; +#[cfg(pty)] +use std::io::Read as _; +use std::io::Write as _; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::mpsc; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result}; +use clap::Args; +use serde::Serialize; + +use serde::Deserialize; + +use crate::test::BinaryArgs; + +// --------------------------------------------------------------------------- +// Expectations (XFAIL / XPASS tracking) +// --------------------------------------------------------------------------- + +/// Per-test expected outcome, stored in a JSON expectations file. +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ExpectedOutcome { + status: TestStatus, + #[serde(skip_serializing_if = "Option::is_none")] + reason: Option, +} + +/// The full expectations file: a sorted map of test name → expected outcome. +type Expectations = BTreeMap; + +fn load_expectations(path: &Path) -> Result { + let content = std::fs::read_to_string(path) + .with_context(|| format!("reading expectations file: {}", path.display()))?; + serde_json::from_str(&content) + .with_context(|| format!("parsing expectations file: {}", path.display())) +} + +fn save_expectations(path: &Path, expectations: &Expectations) -> Result<()> { + let json = serde_json::to_string_pretty(expectations)?; + std::fs::write(path, format!("{json}\n")) + .with_context(|| format!("writing expectations file: {}", path.display())) +} + +fn build_expectations_from_results(results: &[TestResult]) -> Expectations { + let mut expectations = Expectations::new(); + for r in results { + expectations.insert( + r.name.clone(), + ExpectedOutcome { + status: r.status.clone(), + reason: r.known_issue.clone(), + }, + ); + } + expectations +} + +/// Classify a test result against expectations. +#[derive(Debug, Clone, PartialEq, Eq)] +enum ExpectationMatch { + /// No expectations file loaded — show everything. + NoExpectations, + /// Result matches expectation (expected pass or expected fail). + Expected, + /// Test was expected to fail but now passes (XPASS — a fix!). + UnexpectedPass, + /// Test was expected to pass but now fails (regression). + UnexpectedFail, + /// Test not in expectations file (new test). + New, +} + +fn classify_result(result: &TestResult, expectations: Option<&Expectations>) -> ExpectationMatch { + let Some(expectations) = expectations else { + return ExpectationMatch::NoExpectations; + }; + let Some(expected) = expectations.get(&result.name) else { + return ExpectationMatch::New; + }; + if result.status == expected.status { + ExpectationMatch::Expected + } else if result.status == TestStatus::Passed { + ExpectationMatch::UnexpectedPass + } else { + ExpectationMatch::UnexpectedFail + } +} + +// --------------------------------------------------------------------------- +// PTY test list +// --------------------------------------------------------------------------- + +// Bash's upstream test runner (run-all) uses plain pipes — no terminal. +// Most tests work fine that way, but a handful *require* a controlling +// terminal (PTY) in order to produce correct output. Without a PTY they +// either produce almost no output, extra error lines, or subtly different +// results because isatty() returns false. +// +// We default to the upstream pipe-based execution for everything and only +// switch to PTY execution for the tests listed here. +// +// **Why not run everything in a PTY?** Some tests (notably `jobs`) use +// `fg` to move stopped background processes into the foreground of the +// controlling terminal. In a PTY where the master side isn't driving an +// interactive session, `fg` blocks until the process finishes — which +// works but makes the test very slow. Keeping the pipe-based default +// matches upstream's behavior and avoids unnecessary slowdowns. +// +// If a new bash release adds tests that need a terminal, add them here. +const PTY_TESTS: &[&str] = &[ + "exec", // produces only 1 line without a terminal + "history", // history builtins require interactive-like environment + "read", // read -e and read -p need a terminal + "test", // uses `test -t` which checks isatty() + "vredir", // {varname}> redirection tests check /dev/fd on a tty +]; + +// --------------------------------------------------------------------------- +// Slow test list +// --------------------------------------------------------------------------- + +// Some tests contain many `sleep` + `wait` calls that add up to significant +// wall-clock time. Rather than raising the global default timeout (which +// would delay detection of genuinely stuck tests), we give these tests a +// longer per-test timeout. +// +// The value is a multiplier applied to the user-specified `--timeout`. +const SLOW_TESTS: &[(&str, u32)] = &[ + ("jobs", 4), // ~64s of sleep+wait+fg across jobs.tests and jobs*.sub +]; + +// --------------------------------------------------------------------------- +// Known platform issues +// --------------------------------------------------------------------------- + +// Some tests produce different output on different platforms (e.g. Linux vs +// macOS) due to locale availability, glibc behavior, or signal numbering. +// Bash 5.3 itself exhibits the same differences — these are not shell bugs. +// +// When a test fails in static mode and *every* diff hunk is fully explained +// by the markers below, the test is promoted to PASS with an annotation. +// If any hunk contains changes not matching a known marker, the test stays +// FAIL so regressions aren't masked. + +struct KnownPlatformIssue { + test: &'static str, + markers: &'static [&'static str], + description: &'static str, +} + +const KNOWN_PLATFORM_ISSUES: &[KnownPlatformIssue] = &[ + KnownPlatformIssue { + test: "intl", + markers: &["Unicode tests"], + description: "ja_JP.SJIS locale unavailable on Linux glibc", + }, + KnownPlatformIssue { + test: "printf", + markers: &["Value too large"], + description: "glibc printf overflow behavior differs from .right", + }, + KnownPlatformIssue { + test: "exec", + markers: &["trap -- "], + description: "trap -p signal ordering is platform-dependent", + }, +]; + +// --------------------------------------------------------------------------- +// CLI args +// --------------------------------------------------------------------------- + +/// Arguments for the upstream bash test suite. +#[derive(Args, Clone)] +pub struct BashTestsArgs { + /// Path to the bash source directory (parent of tests/). + #[clap(long)] + bash_source_path: PathBuf, + + /// Comparison mode. + #[clap(long, default_value = "static")] + mode: CompareMode, + + /// Path to the bash binary for oracle mode (default: "bash"). + #[clap(long, default_value = "bash")] + bash_path: String, + + /// List discovered tests without running them. + #[clap(long)] + list: bool, + + /// Filter tests by name (substring match, or shell-style glob with *). + #[clap(long, short = 't')] + test_filter: Option, + + /// Per-test timeout in seconds. + #[clap(long, default_value_t = 30)] + timeout: u64, + + /// Number of parallel workers (default: number of CPUs). + #[clap(long, short = 'j')] + jobs: Option, + + /// Output file for JSON results. + #[clap(long, short = 'o')] + output: Option, + + /// Output file for markdown summary. + #[clap(long)] + summary_output: Option, + + /// Stop after first failure. + #[clap(long, short = 'x')] + stop_on_first: bool, + + /// Show unified diff for failures. + #[clap(long)] + show_diff: bool, + + /// Directory to write per-test actual output and diffs. + /// Creates /.actual and /.diff for each test. + #[clap(long)] + results_dir: Option, + + /// Path to the expectations JSON file for XFAIL/XPASS tracking. + #[clap(long)] + expectations: Option, + + /// Update the expectations file from actual test results (baseline). + #[clap(long)] + update_expectations: bool, + + /// Only show tests whose result differs from expectations (regressions + fixes). + #[clap(long)] + only_unexpected: bool, + + /// Run only tests from a named suite (e.g. "minimal"). Parsed from run- script. + #[clap(long)] + subset: Option, +} + +#[derive(Clone, Debug)] +enum CompareMode { + Static, + Oracle, +} + +impl std::str::FromStr for CompareMode { + type Err = String; + fn from_str(s: &str) -> std::result::Result { + match s { + "static" => Ok(Self::Static), + "oracle" => Ok(Self::Oracle), + other => Err(format!("unknown mode: {other} (expected static or oracle)")), + } + } +} + +impl std::fmt::Display for CompareMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Static => f.write_str("static"), + Self::Oracle => f.write_str("oracle"), + } + } +} + +// --------------------------------------------------------------------------- +// Parsed test entry +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone)] +enum StdinSource { + /// The test script is passed as an argument (default). + Argument, + /// The test script is redirected via stdin (`< ./file`). + File(String), + /// Stdin is `/dev/null`. + DevNull, +} + +#[derive(Debug, Clone)] +enum OutputFilter { + /// `| grep -v ''` + GrepExclude(String), + /// `| cat -v` + CatV, +} + +#[derive(Debug, Clone)] +struct TestEntry { + /// Human-readable name, derived from the `.right` file (e.g. "alias"). + name: String, + /// The `run-*` script this was parsed from (for diagnostics). + #[allow(dead_code)] + runner_script: String, + /// The test script to execute (e.g. `./alias.tests`). + test_script: String, + /// The expected-output file (e.g. `alias.right`). + right_file: String, + /// How stdin is provided to the shell. + stdin_source: StdinSource, + /// Whether stderr is redirected to stdout for capture. + capture_stderr: bool, + /// Optional output filter to apply after capture. + output_filter: Option, + /// Environment variables to unset before running. + env_unsets: Vec, + /// Whether to prepend `pwd` to PATH. + path_extend_pwd: bool, + /// Whether this test needs a controlling terminal (PTY). + needs_pty: bool, + /// Timeout multiplier for slow tests (1 = default, >1 = longer). + timeout_multiplier: u32, +} + +// --------------------------------------------------------------------------- +// Parser +// --------------------------------------------------------------------------- + +/// Parse a single `run-*` script into test entries. +fn parse_run_script(path: &Path) -> Result> { + let content = + std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?; + let script_name = path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + + // Extract TEST_NAME variable if set (used by dbg-support, dbg-support2, set-x) + let test_name_var = extract_test_name_var(&content); + + // Resolve variable references in the content before parsing + let resolved = if let Some(ref tn) = test_name_var { + content + .replace("${TEST_NAME}", tn) + .replace("$TEST_NAME", tn) + } else { + content + }; + + // Collect meaningful lines (skip comments, blank lines, echo warnings, set -f, + // diff -a checks, AFLAG assignments, TEST_NAME/TEST_FILE assignments, exit, shebang) + let lines: Vec<&str> = resolved + .lines() + .map(str::trim) + .filter(|l| { + !l.is_empty() + && !l.starts_with('#') + && !l.starts_with("echo ") + && !l.starts_with("set -f") + && !l.starts_with("( diff -a") + && !l.starts_with("exit") + && !l.starts_with("TEST_NAME=") + && !l.starts_with("TEST_FILE=") + }) + .collect(); + + // Detect global modifiers + let path_extend_pwd = resolved.contains("PATH=$PATH:`pwd`"); + let env_unsets = parse_env_unsets(&resolved); + + // Find `THIS_SH` invocation lines and diff lines + let sh_lines: Vec<&str> = lines + .iter() + .copied() + .filter(|l| l.contains("${THIS_SH}")) + .collect(); + let diff_lines: Vec<&str> = lines + .iter() + .copied() + .filter(|l| l.starts_with("diff ") && l.contains(".right")) + .collect(); + + if sh_lines.is_empty() || diff_lines.is_empty() { + anyhow::bail!("no THIS_SH invocation or .right diff found"); + } + + // Pair each `THIS_SH` line with its corresponding diff line (by order) + let mut entries = Vec::new(); + for (i, sh_line) in sh_lines.iter().enumerate() { + let diff_line = diff_lines.get(i).copied().unwrap_or(diff_lines[0]); + + let right_file = extract_right_file(diff_line) + .with_context(|| format!("extracting .right file from: {diff_line}"))?; + let name = right_file.trim_end_matches(".right").to_string(); + + let (test_script, stdin_source, capture_stderr, output_filter) = parse_sh_line(sh_line)?; + + let needs_pty = PTY_TESTS.contains(&name.as_str()); + let timeout_multiplier = SLOW_TESTS + .iter() + .find(|(n, _)| *n == name) + .map_or(1, |(_, m)| *m); + entries.push(TestEntry { + name, + runner_script: script_name.clone(), + test_script, + right_file, + stdin_source, + capture_stderr, + output_filter, + env_unsets: env_unsets.clone(), + path_extend_pwd, + needs_pty, + timeout_multiplier, + }); + } + + Ok(entries) +} + +/// Extract the value of `TEST_NAME='...'` from script content. +fn extract_test_name_var(content: &str) -> Option { + for line in content.lines() { + let trimmed = line.trim(); + if let Some(rest) = trimmed.strip_prefix("TEST_NAME=") { + // Strip quotes + let val = rest.trim_matches('\'').trim_matches('"'); + return Some(val.to_string()); + } + } + None +} + +/// Extract environment variable unsets from script content. +fn parse_env_unsets(content: &str) -> Vec { + let mut unsets = Vec::new(); + for line in content.lines() { + let trimmed = line.trim(); + if let Some(rest) = trimmed.strip_prefix("unset ") { + // Handle "unset VAR1 VAR2 2>/dev/null" or "unset VAR" + let vars_part = rest.split("2>").next().unwrap_or(rest); + for var in vars_part.split_whitespace() { + if var.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { + unsets.push(var.to_string()); + } + } + } + } + unsets +} + +/// Parse a `${THIS_SH}` invocation line to extract the test script, stdin +/// source, stderr capture mode, and output filter. +fn parse_sh_line(line: &str) -> Result<(String, StdinSource, bool, Option)> { + // Determine stdin source + let stdin_source = if line.contains("< /dev/null") { + StdinSource::DevNull + } else if let Some(idx) = line.find("< ./") { + // Extract filename: skip the "< " (2 chars) to get "./filename" + let file = line + .get(idx + 2..) + .unwrap_or("") + .split_whitespace() + .next() + .unwrap_or("") + .to_string(); + StdinSource::File(file) + } else { + StdinSource::Argument + }; + + // Determine output filter + let output_filter = if line.contains("| grep -v") { + let pattern = extract_grep_pattern(line).unwrap_or_else(|| "^expect".to_string()); + Some(OutputFilter::GrepExclude(pattern)) + } else if line.contains("| cat -v") { + Some(OutputFilter::CatV) + } else { + None + }; + + // Determine stderr capture: look for "2>&1" before any pipe + let before_pipe = line.split('|').next().unwrap_or(line); + let capture_stderr = before_pipe.contains("2>&1") || line.contains("2>&1 |"); + + // Extract test script + let test_script = extract_test_script(line)?; + + Ok((test_script, stdin_source, capture_stderr, output_filter)) +} + +/// Extract the test script path from a `${THIS_SH}` invocation line. +fn extract_test_script(line: &str) -> Result { + let after_sh = line + .split("${THIS_SH}") + .nth(1) + .context("no content after ${THIS_SH}")? + .trim(); + + // For stdin redirect lines like "${THIS_SH} < ./file > ..." + if after_sh.starts_with('<') { + let rest = after_sh.trim_start_matches('<').trim(); + let file = rest + .split(|c: char| c.is_whitespace() || c == '>') + .next() + .unwrap_or("") + .to_string(); + return Ok(file); + } + + // Normal case: first non-flag token after `${THIS_SH}` + // Strip any redirection suffix (e.g. "2>&1" or ">") by splitting at the + // first `>` character. An earlier version used trim_end_matches with a + // char array which would incorrectly strip trailing chars like '2' from + // test script names. + let script = after_sh + .split_whitespace() + .find(|t| !t.starts_with('-')) + .unwrap_or("") + .split('>') + .next() + .unwrap_or("") + .trim() + .to_string(); + + if script.is_empty() { + anyhow::bail!("could not extract test script from: {line}"); + } + Ok(script) +} + +/// Extract the `grep -v` pattern from a line containing `| grep -v ''`. +fn extract_grep_pattern(line: &str) -> Option { + let after_grep = line.split("grep -v").nth(1)?; + let rest = after_grep.trim(); + if let Some(stripped) = rest.strip_prefix('\'') { + let end = stripped.find('\'')?; + Some(stripped.get(..end)?.to_string()) + } else { + // Unquoted: take until whitespace or > or | + let pat = rest + .split(|c: char| c.is_whitespace() || c == '>' || c == '|') + .next()?; + Some(pat.to_string()) + } +} + +/// Extract the `.right` filename from a diff line. +#[allow(clippy::case_sensitive_file_extension_comparisons)] +fn extract_right_file(line: &str) -> Result { + for token in line.split_whitespace() { + if token.ends_with(".right") { + let clean = token + .trim_start_matches("${TEST_NAME}.") + .trim_start_matches("$TEST_FILE") + .to_string(); + if clean.ends_with(".right") { + return Ok(clean); + } + } + } + // Handle variable references like ${TEST_NAME}.right + if line.contains(".right") { + for token in line.split_whitespace() { + if token.contains(".right") { + return Ok(token.replace("${TEST_NAME}", "").replace("$TEST_FILE", "")); + } + } + } + anyhow::bail!("no .right file found in: {line}"); +} + +/// Scan the tests/ directory for `run-*` scripts and parse them all. +#[allow(clippy::case_sensitive_file_extension_comparisons)] +fn discover_tests(tests_dir: &Path) -> Result<(Vec, Vec)> { + let mut entries = Vec::new(); + let mut warnings = Vec::new(); + + let mut run_scripts: Vec<_> = std::fs::read_dir(tests_dir) + .with_context(|| format!("reading directory: {}", tests_dir.display()))? + .filter_map(|e| e.ok()) + .filter(|e| { + let name = e.file_name().to_string_lossy().to_string(); + name.starts_with("run-") + && name != "run-all" + && name != "run-minimal" + && !name.ends_with('~') + && !name.ends_with(".orig") + }) + .collect(); + run_scripts.sort_by_key(|e| e.file_name()); + + for entry in &run_scripts { + let path = entry.path(); + let script_name = entry.file_name().to_string_lossy().to_string(); + match parse_run_script(&path) { + Ok(parsed) => entries.extend(parsed), + Err(e) => { + warnings.push(format!( + "WARNING: Could not parse {script_name}, skipping -- {e:#}" + )); + } + } + } + + Ok((entries, warnings)) +} + +/// Parse a suite script (e.g. `run-minimal`) to extract which tests it includes. +/// Looks for `case` patterns like `run-X|run-Y|...) echo $x ; sh $x ;;`. +fn parse_suite_script(suite_path: &Path) -> Result> { + let content = std::fs::read_to_string(suite_path) + .with_context(|| format!("reading suite script: {}", suite_path.display()))?; + let mut members = Vec::new(); + for line in content.lines() { + let trimmed = line.trim(); + // Match lines that run tests: "run-X|run-Y|...) echo $x ; sh $x ;;" + if trimmed.contains("echo $x") && trimmed.contains("sh $x") { + let pattern_part = trimmed.split(')').next().unwrap_or(""); + for name in pattern_part.split('|') { + if let Some(test_name) = name.trim().strip_prefix("run-") { + members.push(test_name.to_string()); + } + } + } + } + Ok(members) +} + +// --------------------------------------------------------------------------- +// Test execution +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize)] +struct TestResult { + name: String, + status: TestStatus, + matching_lines: usize, + total_lines: usize, + duration_secs: f64, + #[serde(skip_serializing_if = "Option::is_none")] + first_diff_line: Option, + #[serde(skip_serializing_if = "Option::is_none")] + known_issue: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +enum TestStatus { + Passed, + Failed, + TimedOut, + Skipped, +} + +impl std::fmt::Display for TestStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Passed => f.write_str("PASS"), + Self::Failed => f.write_str("FAIL"), + Self::TimedOut => f.write_str("TMOUT"), + Self::Skipped => f.write_str("SKIP"), + } + } +} + +/// Create an isolated copy of the test directory for a worker thread. +/// Regular files are copied (so CWD writes don't collide), while +/// subdirectories are symlinked (they're read-only test data). +fn create_test_farm(src_dir: &Path, farm_dir: &Path) -> Result<()> { + for entry in std::fs::read_dir(src_dir) + .with_context(|| format!("reading {} for test farm", src_dir.display()))? + { + let entry = entry?; + let dest = farm_dir.join(entry.file_name()); + let ft = entry.file_type()?; + if ft.is_dir() { + // Symlink directories (e.g. misc/) — they're read-only test data. + std::os::unix::fs::symlink(entry.path(), &dest)?; + } else { + // Copy regular files so parallel writes don't collide. + std::fs::copy(entry.path(), &dest).with_context(|| { + format!("copying {} -> {}", entry.path().display(), dest.display()) + })?; + } + } + Ok(()) +} + +/// Build the shell command string that mirrors what the `run-*` script does. +fn build_test_command_string(shell_path: &Path, entry: &TestEntry) -> String { + let shell_str = shell_path.display().to_string(); + let inner_cmd = match &entry.stdin_source { + StdinSource::Argument => { + if entry.capture_stderr { + format!("{shell_str} {} 2>&1", entry.test_script) + } else { + format!("{shell_str} {}", entry.test_script) + } + } + StdinSource::File(path) => { + if entry.capture_stderr { + format!("{shell_str} < {path} 2>&1") + } else { + format!("{shell_str} < {path}") + } + } + StdinSource::DevNull => { + if entry.capture_stderr { + format!("{shell_str} {} 2>&1 < /dev/null", entry.test_script) + } else { + format!("{shell_str} {} < /dev/null", entry.test_script) + } + } + }; + + match &entry.output_filter { + Some(OutputFilter::GrepExclude(pattern)) => { + format!("{inner_cmd} | grep -v '{pattern}'") + } + Some(OutputFilter::CatV) => { + format!("{inner_cmd} | cat -v") + } + None => inner_cmd, + } +} + +/// Compute the common environment variables for a test execution. +fn build_test_env( + shell_path: &Path, + entry: &TestEntry, + tests_dir: &Path, + bash_source_dir: &Path, + tmpdir: &str, +) -> Vec<(String, String)> { + let tmpdir = tmpdir.to_string(); + let sys_path = std::env::var("PATH").unwrap_or_default(); + let new_path = if entry.path_extend_pwd { + format!("{}:{}:{sys_path}", tests_dir.display(), tests_dir.display()) + } else { + format!("{}:{sys_path}", tests_dir.display()) + }; + let tstout = PathBuf::from(&tmpdir).join(format!( + "brush-tstout-{}-{}", + std::process::id(), + entry.name + )); + + vec![ + ("THIS_SH".to_string(), shell_path.display().to_string()), + ( + "BUILD_DIR".to_string(), + bash_source_dir.display().to_string(), + ), + ("TMPDIR".to_string(), tmpdir), + ("PATH".to_string(), new_path), + ("BASH_TSTOUT".to_string(), tstout.display().to_string()), + ] +} + +/// Dispatch test execution: use PTY for terminal-dependent tests, pipes otherwise. +fn execute_test( + shell_path: &Path, + entry: &TestEntry, + work_dir: &Path, + tests_dir: &Path, + bash_source_dir: &Path, + tmpdir: &str, + timeout: Duration, +) -> Result<(String, Duration)> { + if entry.needs_pty { + execute_test_pty( + shell_path, + entry, + work_dir, + tests_dir, + bash_source_dir, + tmpdir, + timeout, + ) + } else { + execute_test_pipe( + shell_path, + entry, + work_dir, + tests_dir, + bash_source_dir, + tmpdir, + timeout, + ) + } +} + +/// Execute a test using piped stdout/stderr (the common path, matching upstream's runner). +fn execute_test_pipe( + shell_path: &Path, + entry: &TestEntry, + work_dir: &Path, + tests_dir: &Path, + bash_source_dir: &Path, + tmpdir: &str, + timeout: Duration, +) -> Result<(String, Duration)> { + let start = Instant::now(); + let full_cmd = build_test_command_string(shell_path, entry); + + let mut cmd = Command::new("sh"); + cmd.arg("-c"); + cmd.arg(&full_cmd); + cmd.current_dir(work_dir); + + // Environment setup — mirror what run-all does + let env_vars = build_test_env(shell_path, entry, tests_dir, bash_source_dir, tmpdir); + for (key, val) in &env_vars { + cmd.env(key, val); + } + cmd.env_remove("BASH_ENV"); + for var in &entry.env_unsets { + cmd.env_remove(var); + } + + cmd.stdin(Stdio::null()); + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::piped()); + + // Put the child in its own process group so we can kill the entire + // pipeline (sh + test shell + filters) on timeout. + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + // Safety: pre_exec runs the closure between fork and exec. + // setpgid(0, 0) is async-signal-safe and makes this process a + // new process group leader so we can kill the whole pipeline. + #[allow(clippy::multiple_unsafe_ops_per_block)] + unsafe { + cmd.pre_exec(|| { + libc::setpgid(0, 0); + Ok(()) + }); + } + } + + let child = cmd.spawn().context("spawning shell process")?; + let child_id = child.id(); + + // Drain stdout/stderr in a background thread to avoid pipe deadlock. + let (result_tx, result_rx) = mpsc::channel(); + std::thread::spawn(move || { + let _ = result_tx.send(child.wait_with_output()); + }); + + match result_rx.recv_timeout(timeout) { + Ok(Ok(output)) => { + let out = String::from_utf8_lossy(&output.stdout).to_string(); + Ok((out, start.elapsed())) + } + Ok(Err(e)) => Err(e.into()), + Err(mpsc::RecvTimeoutError::Timeout) => { + kill_process(child_id); + anyhow::bail!("timed out after {:.1}s", start.elapsed().as_secs_f64()); + } + Err(mpsc::RecvTimeoutError::Disconnected) => { + anyhow::bail!("child process monitor thread died unexpectedly"); + } + } +} + +/// Execute a test in a PTY for tests that need a controlling terminal. +#[cfg(not(pty))] +fn execute_test_pty( + _shell_path: &Path, + _entry: &TestEntry, + _work_dir: &Path, + _tests_dir: &Path, + _bash_source_dir: &Path, + _tmpdir: &str, + _timeout: Duration, +) -> Result<(String, Duration)> { + anyhow::bail!("pty tests not supported on this platform"); +} + +/// Execute a test in a PTY for tests that need a controlling terminal. +#[cfg(pty)] +fn execute_test_pty( + shell_path: &Path, + entry: &TestEntry, + work_dir: &Path, + tests_dir: &Path, + bash_source_dir: &Path, + tmpdir: &str, + timeout: Duration, +) -> Result<(String, Duration)> { + let start = Instant::now(); + let full_cmd = build_test_command_string(shell_path, entry); + + let (mut pty, pts) = pty_process::blocking::open().context("opening PTY pair")?; + + // Disable ECHO and OPOST for clean output (no \n→\r\n, no input echo). + configure_pty_termios(&pty); + + // Set a reasonable terminal size. + pty.resize(pty_process::Size::new(24, 80)) + .context("resizing PTY")?; + + // Build the command. pty_process::blocking::Command methods consume self, + // so we chain or reassign. + let env_vars = build_test_env(shell_path, entry, tests_dir, bash_source_dir, tmpdir); + let mut cmd = pty_process::blocking::Command::new("sh") + .arg("-c") + .arg(&full_cmd) + .current_dir(work_dir) + .env_remove("BASH_ENV"); + + for (key, val) in &env_vars { + cmd = cmd.env(key, val); + } + for var in &entry.env_unsets { + cmd = cmd.env_remove(var); + } + + // pty-process handles setsid() + TIOCSCTTY internally. + let mut child = cmd.spawn(pts).context("spawning PTY shell process")?; + let child_id = child.id(); + + // Read output from PTY master in a background thread. + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let mut output = Vec::new(); + let mut buf = [0u8; 8192]; + loop { + match pty.read(&mut buf) { + Ok(0) => break, + Ok(n) => output.extend_from_slice(&buf[..n]), + // EIO is the normal EOF signal when the PTY slave closes. + Err(e) if e.raw_os_error() == Some(libc::EIO) => break, + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {} + Err(_) => break, + } + } + // Reap the child to avoid zombies. + let _ = child.wait(); + let _ = tx.send(String::from_utf8_lossy(&output).to_string()); + }); + + match rx.recv_timeout(timeout) { + Ok(output) => Ok((output, start.elapsed())), + Err(mpsc::RecvTimeoutError::Timeout) => { + kill_process(child_id); + anyhow::bail!("timed out after {:.1}s", start.elapsed().as_secs_f64()); + } + Err(mpsc::RecvTimeoutError::Disconnected) => { + anyhow::bail!("child process monitor thread died unexpectedly"); + } + } +} + +/// Configure PTY master termios: disable ECHO (don't echo input) and +/// OPOST (don't translate \n to \r\n in output). +#[cfg(pty)] +fn configure_pty_termios(pty: &pty_process::blocking::Pty) { + use std::os::unix::io::AsRawFd; + let fd = pty.as_raw_fd(); + // Safety: zeroed is a valid initial value for libc::termios. + let mut termios = unsafe { std::mem::zeroed::() }; + // Safety: tcgetattr is a standard POSIX call on a valid PTY fd. + if unsafe { libc::tcgetattr(fd, &raw mut termios) } == 0 { + termios.c_lflag &= !libc::ECHO; + termios.c_oflag &= !libc::OPOST; + // Safety: tcsetattr is a standard POSIX call on a valid PTY fd. + unsafe { libc::tcsetattr(fd, libc::TCSANOW, &raw const termios) }; + } +} + +#[cfg(unix)] +fn kill_process(pid: u32) { + let pid_i32 = pid.cast_signed(); + // Safety: sending SIGKILL to the process group we spawned. + unsafe { libc::kill(-pid_i32, libc::SIGKILL) }; + // Safety: sending SIGKILL to the process itself as a fallback. + unsafe { libc::kill(pid_i32, libc::SIGKILL) }; +} + +#[cfg(not(unix))] +fn kill_process(_pid: u32) {} + +// --------------------------------------------------------------------------- +// Output normalization and comparison +// --------------------------------------------------------------------------- + +/// Normalize shell output for comparison. +fn normalize_output(output: &str, shell_path: &Path) -> String { + let shell_name = shell_path.file_name().unwrap_or_default().to_string_lossy(); + let shell_path_str = shell_path.display().to_string(); + + let mut result = output.to_string(); + + // Replace full path references: `/path/to/brush:` -> `bash:` + if shell_path_str != "bash" { + result = result.replace(&format!("{shell_path_str}: "), "bash: "); + result = result.replace(&format!("{shell_path_str}:"), "bash:"); + } + + // Replace shell name at line starts: `brush:` -> `bash:` + if *shell_name != *"bash" { + let suffix = if result.ends_with("\n\n") { + "\n\n" + } else if result.ends_with('\n') { + "\n" + } else { + "" + }; + let prefix_space = format!("{shell_name}: "); + let prefix_colon = format!("{shell_name}:"); + let lines: Vec = result + .lines() + .map(|line| { + if let Some(rest) = line.strip_prefix(&prefix_space) { + format!("bash: {rest}") + } else if let Some(rest) = line.strip_prefix(&prefix_colon) { + format!("bash:{rest}") + } else { + line.to_string() + } + }) + .collect(); + result = lines.join("\n"); + // `.lines()` drops trailing newlines; restore the original termination. + if !result.ends_with('\n') && !suffix.is_empty() { + result.push_str(suffix); + } else if result.ends_with('\n') && suffix == "\n\n" { + result.push('\n'); + } + } + + result +} + +/// Compare output line by line. +fn compare_outputs(actual: &str, expected: &str) -> (usize, usize, Option) { + let actual_lines: Vec<&str> = actual.lines().collect(); + let expected_lines: Vec<&str> = expected.lines().collect(); + + let total = expected_lines.len(); + let mut matching = 0; + let mut first_diff = None; + + for (i, expected_line) in expected_lines.iter().enumerate() { + if let Some(actual_line) = actual_lines.get(i) { + if actual_line == expected_line { + matching += 1; + } else if first_diff.is_none() { + first_diff = Some(i + 1); + } + } else if first_diff.is_none() { + first_diff = Some(i + 1); + } + } + + if actual_lines.len() > expected_lines.len() && first_diff.is_none() { + first_diff = Some(expected_lines.len() + 1); + } + + (matching, total, first_diff) +} + +/// Produce a unified diff between two strings. +fn unified_diff(expected: &str, actual: &str, expected_label: &str, actual_label: &str) -> String { + use std::fmt::Write; + + let mut result = String::new(); + let expected_lines: Vec<&str> = expected.lines().collect(); + let actual_lines: Vec<&str> = actual.lines().collect(); + + let _ = writeln!(result, "--- {expected_label}"); + let _ = writeln!(result, "+++ {actual_label}"); + + let max_len = expected_lines.len().max(actual_lines.len()); + let mut chunk_start: Option = None; + let mut chunk_lines: Vec = Vec::new(); + let mut chunk_removed = 0usize; + let mut chunk_added = 0usize; + + for i in 0..max_len { + let exp = expected_lines.get(i).copied(); + let act = actual_lines.get(i).copied(); + + if exp != act { + if chunk_start.is_none() { + chunk_start = Some(i); + } + if let Some(e) = exp { + chunk_lines.push(format!("-{e}")); + chunk_removed += 1; + } + if let Some(a) = act { + chunk_lines.push(format!("+{a}")); + chunk_added += 1; + } + } else if let Some(start) = chunk_start { + let _ = writeln!( + result, + "@@ -{},{chunk_removed} +{},{chunk_added} @@", + start + 1, + start + 1 + ); + for cl in &chunk_lines { + let _ = writeln!(result, "{cl}"); + } + chunk_start = None; + chunk_lines.clear(); + chunk_removed = 0; + chunk_added = 0; + } + } + + if let Some(start) = chunk_start { + let _ = writeln!( + result, + "@@ -{},{chunk_removed} +{},{chunk_added} @@", + start + 1, + start + 1 + ); + for cl in &chunk_lines { + let _ = writeln!(result, "{cl}"); + } + } + + result +} + +/// Check whether a test's diff is fully explained by known platform issues. +/// +/// Parses the unified diff into hunks (contiguous groups of `+`/`-` lines). +/// For each hunk, checks that at least one changed line contains a marker from +/// the test's known issues. If ALL hunks are explained, returns the issue +/// description; otherwise returns `None`. +fn check_known_platform_issue(test_name: &str, diff: &str) -> Option<&'static str> { + let issue = KNOWN_PLATFORM_ISSUES + .iter() + .find(|ki| ki.test == test_name)?; + + let mut in_hunk = false; + let mut hunk_explained = false; + let mut hunk_count = 0usize; + let mut explained_count = 0usize; + + for line in diff.lines() { + let is_change = line.starts_with('+') || line.starts_with('-'); + // Skip diff headers (--- / +++) + let is_header = line.starts_with("--- ") || line.starts_with("+++ "); + + if is_change && !is_header { + if !in_hunk { + in_hunk = true; + hunk_explained = false; + hunk_count += 1; + } + // Check if this changed line matches any marker. + // Skip the leading +/- prefix (always a single ASCII byte). + let content = line.get(1..).unwrap_or(""); + if !hunk_explained { + for marker in issue.markers { + if content.contains(marker) { + hunk_explained = true; + break; + } + } + } + } else if in_hunk { + // Non-change line ends the current hunk. + if hunk_explained { + explained_count += 1; + } + in_hunk = false; + } + } + + // Finalize the last hunk. + if in_hunk && hunk_explained { + explained_count += 1; + } + + if hunk_count > 0 && explained_count == hunk_count { + Some(issue.description) + } else { + None + } +} + +// --------------------------------------------------------------------------- +// Reporting +// --------------------------------------------------------------------------- + +#[derive(Serialize)] +struct JsonReport { + mode: String, + brush_path: String, + bash_source_path: String, + summary: JsonSummary, + tests: Vec, + warnings: Vec, +} + +#[derive(Serialize)] +struct JsonSummary { + total: usize, + passed: usize, + failed: usize, + timed_out: usize, + skipped: usize, + total_lines: usize, + matching_lines: usize, + line_match_pct: f64, +} + +struct ResultSummary { + passed: usize, + failed: usize, + timed_out: usize, + skipped: usize, + total_lines: usize, + matching_lines: usize, +} + +impl ResultSummary { + fn from_results(results: &[TestResult]) -> Self { + let mut s = Self { + passed: 0, + failed: 0, + timed_out: 0, + skipped: 0, + total_lines: 0, + matching_lines: 0, + }; + for r in results { + match r.status { + TestStatus::Passed => s.passed += 1, + TestStatus::Failed => s.failed += 1, + TestStatus::TimedOut => s.timed_out += 1, + TestStatus::Skipped => s.skipped += 1, + } + s.total_lines += r.total_lines; + s.matching_lines += r.matching_lines; + } + s + } + + #[allow(clippy::cast_precision_loss)] + fn line_match_pct(&self) -> f64 { + if self.total_lines > 0 { + (self.matching_lines as f64 / self.total_lines as f64) * 100.0 + } else { + 0.0 + } + } +} + +fn print_console_report( + results: &[TestResult], + show_diff: bool, + diffs: &BTreeMap, + expectations: Option<&Expectations>, + only_unexpected: bool, +) { + let s = ResultSummary::from_results(results); + let mut xpass_count = 0usize; + let mut xfail_count = 0usize; + let mut regression_count = 0usize; + + eprintln!(); + for r in results { + let classification = classify_result(r, expectations); + + // In only-unexpected mode, skip expected results. + if only_unexpected && classification == ExpectationMatch::Expected { + continue; + } + + let status_tag = match r.status { + TestStatus::Passed => " PASS ", + TestStatus::Failed => " FAIL ", + TestStatus::TimedOut => " TMOUT", + TestStatus::Skipped => " SKIP ", + }; + + let expectation_tag = match classification { + ExpectationMatch::UnexpectedPass => { + xpass_count += 1; + " [XPASS]" + } + ExpectationMatch::UnexpectedFail => { + regression_count += 1; + " [REGRESSION]" + } + ExpectationMatch::Expected + if r.status == TestStatus::Failed || r.status == TestStatus::TimedOut => + { + xfail_count += 1; + " [XFAIL]" + } + ExpectationMatch::New => " [NEW]", + _ => "", + }; + + if let Some(desc) = &r.known_issue { + eprintln!( + " {status_tag} {:<20} ({}/{} lines, {:.1}s) [known: {desc}]{expectation_tag}", + r.name, r.matching_lines, r.total_lines, r.duration_secs + ); + } else { + eprintln!( + " {status_tag} {:<20} ({}/{} lines, {:.1}s){expectation_tag}", + r.name, r.matching_lines, r.total_lines, r.duration_secs + ); + } + + if show_diff && r.status == TestStatus::Failed { + if let Some(diff) = diffs.get(&r.name) { + for line in diff.lines().take(50) { + eprintln!(" {line}"); + } + let diff_line_count = diff.lines().count(); + if diff_line_count > 50 { + eprintln!(" ... ({} more lines)", diff_line_count - 50); + } + } + } + } + + let pct = s.line_match_pct(); + eprintln!(); + eprintln!( + "Results: {} passed, {} failed, {} timed out, {} skipped ({} total)", + s.passed, + s.failed, + s.timed_out, + s.skipped, + results.len() + ); + eprintln!( + "Lines: {} / {} matched ({pct:.1}%)", + s.matching_lines, s.total_lines + ); + if expectations.is_some() { + eprintln!( + "Expectations: {xfail_count} xfail, {xpass_count} xpass (fixes), {regression_count} regressions" + ); + } +} + +fn write_json_report( + path: &Path, + results: &[TestResult], + warnings: &[String], + mode: &CompareMode, + brush_path: &Path, + bash_source_path: &Path, +) -> Result<()> { + let s = ResultSummary::from_results(results); + let pct = s.line_match_pct(); + + let report = JsonReport { + mode: mode.to_string(), + brush_path: brush_path.display().to_string(), + bash_source_path: bash_source_path.display().to_string(), + summary: JsonSummary { + total: results.len(), + passed: s.passed, + failed: s.failed, + timed_out: s.timed_out, + skipped: s.skipped, + total_lines: s.total_lines, + matching_lines: s.matching_lines, + line_match_pct: (pct * 10.0).round() / 10.0, + }, + tests: results.to_vec(), + warnings: warnings.to_vec(), + }; + + let json = serde_json::to_string_pretty(&report)?; + std::fs::write(path, json).with_context(|| format!("writing JSON report to {}", path.display())) +} + +fn write_markdown_summary( + path: &Path, + results: &[TestResult], + mode: &CompareMode, + brush_path: &Path, +) -> Result<()> { + let s = ResultSummary::from_results(results); + let pct = s.line_match_pct(); + + let mut f = std::fs::File::create(path) + .with_context(|| format!("creating markdown summary at {}", path.display()))?; + + writeln!(f, "# Bash Test Suite Results")?; + writeln!(f)?; + writeln!( + f, + "**Mode:** {mode} | **Shell:** `{}`", + brush_path.display() + )?; + writeln!(f)?; + writeln!(f, "## Summary")?; + writeln!(f)?; + writeln!(f, "| Metric | Value |")?; + writeln!(f, "|--------|-------|")?; + writeln!(f, "| Total tests | {} |", results.len())?; + writeln!(f, "| Passed | {} |", s.passed)?; + writeln!(f, "| Failed | {} |", s.failed)?; + writeln!(f, "| Timed out | {} |", s.timed_out)?; + writeln!(f, "| Skipped | {} |", s.skipped)?; + writeln!( + f, + "| Lines matched | {} / {} ({pct:.1}%) |", + s.matching_lines, s.total_lines + )?; + writeln!(f)?; + + let failures: Vec<&TestResult> = results + .iter() + .filter(|r| r.status == TestStatus::Failed || r.status == TestStatus::TimedOut) + .collect(); + + if !failures.is_empty() { + writeln!(f, "## Failures")?; + writeln!(f)?; + writeln!(f, "| Test | Status | Lines | Duration |")?; + writeln!(f, "|------|--------|-------|----------|")?; + for r in &failures { + writeln!( + f, + "| {} | {} | {}/{} | {:.1}s |", + r.name, r.status, r.matching_lines, r.total_lines, r.duration_secs + )?; + } + writeln!(f)?; + } + + let passes: Vec<&TestResult> = results + .iter() + .filter(|r| r.status == TestStatus::Passed) + .collect(); + + if !passes.is_empty() { + writeln!(f, "## Passed")?; + writeln!(f)?; + writeln!(f, "| Test | Lines | Duration |")?; + writeln!(f, "|------|-------|----------|")?; + for r in &passes { + writeln!( + f, + "| {} | {}/{} | {:.1}s |", + r.name, r.matching_lines, r.total_lines, r.duration_secs + )?; + } + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Test filter matching +// --------------------------------------------------------------------------- + +fn matches_filter(name: &str, filter: &str) -> bool { + if filter.contains('*') { + glob_match(filter, name) + } else { + name.contains(filter) + } +} + +/// Simple glob matching supporting only `*` wildcards. +fn glob_match(pattern: &str, text: &str) -> bool { + let parts: Vec<&str> = pattern.split('*').collect(); + if parts.len() == 1 { + return pattern == text; + } + + let mut pos = 0usize; + for (i, part) in parts.iter().enumerate() { + if part.is_empty() { + continue; + } + if let Some(found) = text.get(pos..).and_then(|s| s.find(part)) { + if i == 0 && found != 0 { + return false; + } + pos += found + part.len(); + } else { + return false; + } + } + + if !pattern.ends_with('*') { + return text.ends_with(parts.last().unwrap_or(&"")); + } + + true +} + +// --------------------------------------------------------------------------- +// Shared config passed to worker threads +// --------------------------------------------------------------------------- + +struct TestRunConfig { + brush_path: PathBuf, + tests_dir: PathBuf, + bash_source_dir: PathBuf, + timeout: Duration, + mode: CompareMode, + bash_path: String, + stop_on_first: bool, + verbose: bool, +} + +// --------------------------------------------------------------------------- +// Orchestrator +// --------------------------------------------------------------------------- + +#[allow(clippy::too_many_lines)] +pub fn run_bash_tests(args: &BashTestsArgs, binary_args: &BinaryArgs, verbose: bool) -> Result<()> { + let tests_dir = args.bash_source_path.join("tests"); + if !tests_dir.exists() { + anyhow::bail!( + "bash tests directory not found at: {}\n\ + Please provide the path to the bash source directory (parent of tests/)", + tests_dir.display() + ); + } + + validate_support_binaries(&tests_dir)?; + + eprint!("Parsing run-* scripts..."); + let (mut entries, warnings) = discover_tests(&tests_dir)?; + + for w in &warnings { + eprintln!(" {w}"); + } + + if let Some(filter) = &args.test_filter { + entries.retain(|e| matches_filter(&e.name, filter)); + } + + // Filter by named suite (e.g. --subset minimal reads run-minimal) + if let Some(subset) = &args.subset { + let suite_path = tests_dir.join(format!("run-{subset}")); + let suite_members = parse_suite_script(&suite_path)?; + entries.retain(|e| { + // Match by runner script name: "run-X" matches member "X" + let runner_test = e + .runner_script + .strip_prefix("run-") + .unwrap_or(&e.runner_script); + suite_members.iter().any(|m| m == runner_test) + }); + eprintln!(" Subset '{subset}': {} tests selected", entries.len()); + } + + eprintln!( + " found {} test entries{}", + entries.len(), + if warnings.is_empty() { + String::new() + } else { + format!(" ({} warnings)", warnings.len()) + } + ); + + if args.list { + list_tests(&entries); + return Ok(()); + } + + let brush_path = binary_args.find_brush_binary()?; + let timeout = Duration::from_secs(args.timeout); + let num_workers = args.jobs.unwrap_or_else(num_cpus::get); + + eprintln!( + "Running {} bash tests against {}...", + entries.len(), + brush_path.display() + ); + eprintln!( + "Shell: {} | Timeout: {}s | Workers: {num_workers} | Mode: {}", + brush_path.display(), + args.timeout, + args.mode + ); + + let config = TestRunConfig { + brush_path, + tests_dir, + bash_source_dir: args.bash_source_path.clone(), + timeout, + mode: args.mode.clone(), + bash_path: args.bash_path.clone(), + stop_on_first: args.stop_on_first, + verbose, + }; + + let raw_results = run_tests_parallel(&entries, &config, num_workers)?; + + let results: Vec = raw_results.iter().map(|(r, _, _)| r.clone()).collect(); + let diffs: BTreeMap = raw_results + .iter() + .filter_map(|(r, d, _)| d.as_ref().map(|diff| (r.name.clone(), diff.clone()))) + .collect(); + let actuals: BTreeMap = raw_results + .into_iter() + .filter_map(|(r, _, a)| a.map(|actual| (r.name, actual))) + .collect(); + + // Load or update expectations. + let expectations = if let Some(exp_path) = &args.expectations { + if args.update_expectations { + let exp = build_expectations_from_results(&results); + save_expectations(exp_path, &exp)?; + eprintln!("Expectations updated: {}", exp_path.display()); + Some(exp) + } else if exp_path.exists() { + Some(load_expectations(exp_path)?) + } else { + eprintln!( + "Expectations file not found: {} (use --update-expectations to create)", + exp_path.display() + ); + None + } + } else { + None + }; + + print_console_report( + &results, + args.show_diff, + &diffs, + expectations.as_ref(), + args.only_unexpected, + ); + write_reports(args, &results, &warnings, &config, &diffs, &actuals)?; + + // In expectations mode, only fail on regressions (unexpected failures). + let any_failure = if expectations.is_some() { + results + .iter() + .any(|r| classify_result(r, expectations.as_ref()) == ExpectationMatch::UnexpectedFail) + } else { + results + .iter() + .any(|r| r.status == TestStatus::Failed || r.status == TestStatus::TimedOut) + }; + if any_failure { + anyhow::bail!("Some bash tests failed"); + } + + Ok(()) +} + +fn validate_support_binaries(tests_dir: &Path) -> Result<()> { + for bin in &["recho", "zecho", "printenv"] { + let bin_path = tests_dir.join(bin); + if !bin_path.exists() { + anyhow::bail!( + "Required support binary not found: {}\n\ + Please build the bash source tree first (run `make` in the bash source directory)", + bin_path.display() + ); + } + } + Ok(()) +} + +fn list_tests(entries: &[TestEntry]) { + for entry in entries { + eprintln!( + " {:<24} script={:<20} right={:<24} stdin={:?} filter={:?}", + entry.name, + entry.test_script, + entry.right_file, + match &entry.stdin_source { + StdinSource::Argument => "arg".to_string(), + StdinSource::File(f) => format!("file({f})"), + StdinSource::DevNull => "devnull".to_string(), + }, + entry.output_filter.as_ref().map(|f| match f { + OutputFilter::GrepExclude(p) => format!("grep-v({p})"), + OutputFilter::CatV => "cat-v".to_string(), + }), + ); + } +} + +fn write_reports( + args: &BashTestsArgs, + results: &[TestResult], + warnings: &[String], + config: &TestRunConfig, + diffs: &BTreeMap, + actuals: &BTreeMap, +) -> Result<()> { + if let Some(json_path) = &args.output { + write_json_report( + json_path, + results, + warnings, + &args.mode, + &config.brush_path, + &args.bash_source_path, + )?; + eprintln!("JSON report written to: {}", json_path.display()); + } + if let Some(md_path) = &args.summary_output { + write_markdown_summary(md_path, results, &args.mode, &config.brush_path)?; + eprintln!("Markdown summary written to: {}", md_path.display()); + } + if let Some(results_dir) = &args.results_dir { + write_results_dir(results_dir, diffs, actuals)?; + eprintln!("Per-test results written to: {}", results_dir.display()); + } + Ok(()) +} + +fn write_results_dir( + dir: &Path, + diffs: &BTreeMap, + actuals: &BTreeMap, +) -> Result<()> { + std::fs::create_dir_all(dir) + .with_context(|| format!("creating results directory: {}", dir.display()))?; + for (name, actual) in actuals { + let path = dir.join(format!("{name}.actual")); + std::fs::write(&path, actual).with_context(|| format!("writing {}", path.display()))?; + } + for (name, diff) in diffs { + let path = dir.join(format!("{name}.diff")); + std::fs::write(&path, diff).with_context(|| format!("writing {}", path.display()))?; + } + Ok(()) +} + +/// (`TestResult`, optional diff, optional actual output). +type TestOutput = (TestResult, Option, Option); + +fn run_tests_parallel( + entries: &[TestEntry], + config: &TestRunConfig, + num_workers: usize, +) -> Result> { + use std::sync::{Arc, Mutex}; + + // Each entry: (index, TestResult, diff, actual_output) + type ResultVec = Vec<(usize, TestResult, Option, Option)>; + let results: Arc> = Arc::new(Mutex::new(Vec::new())); + let stop_flag = Arc::new(std::sync::atomic::AtomicBool::new(false)); + + // We must collect to own the data before sharing across threads. + #[allow(clippy::needless_collect)] + let work_items: Vec<(usize, TestEntry)> = entries.iter().cloned().enumerate().collect(); + let work = Arc::new(Mutex::new(work_items.into_iter())); + + let mut handles = Vec::new(); + let actual_workers = num_workers.min(entries.len()).max(1); + + for _ in 0..actual_workers { + let work = Arc::clone(&work); + let results = Arc::clone(&results); + let stop_flag = Arc::clone(&stop_flag); + let brush_path = config.brush_path.clone(); + let tests_dir = config.tests_dir.clone(); + let bash_source_dir = config.bash_source_dir.clone(); + let mode = config.mode.clone(); + let bash_path = config.bash_path.clone(); + let timeout = config.timeout; + let stop_on_first = config.stop_on_first; + let verbose = config.verbose; + + let handle = std::thread::spawn(move || { + // Create a per-worker copy of the test directory so parallel + // tests don't collide on CWD writes or shared TMPDIR files. + let farm_tmp = match tempfile::TempDir::new() { + Ok(t) => t, + Err(e) => { + eprintln!("WARNING: failed to create test farm: {e}"); + return; + } + }; + let work_dir = farm_tmp.path().join("tests"); + if let Err(e) = std::fs::create_dir(&work_dir) + .map_err(anyhow::Error::from) + .and_then(|()| create_test_farm(&tests_dir, &work_dir)) + { + eprintln!("WARNING: failed to populate test farm: {e}"); + return; + } + // Per-worker TMPDIR prevents collisions on files like + // $TMPDIR/sh (posixexp) or $TMPDIR/newhistory (history). + let worker_tmpdir = farm_tmp.path().join("tmp"); + if let Err(e) = std::fs::create_dir(&worker_tmpdir) { + eprintln!("WARNING: failed to create worker tmpdir: {e}"); + return; + } + + loop { + if stop_flag.load(std::sync::atomic::Ordering::Relaxed) { + break; + } + + let item = match work.lock() { + Ok(mut guard) => guard.next(), + Err(_) => break, + }; + + let Some((idx, entry)) = item else { + break; + }; + + let tmpdir_str = worker_tmpdir.display().to_string(); + let (result, diff, actual) = run_single_test( + &entry, + &brush_path, + &work_dir, + &tests_dir, + &bash_source_dir, + &tmpdir_str, + timeout, + &mode, + &bash_path, + verbose, + ); + + if stop_on_first + && (result.status == TestStatus::Failed + || result.status == TestStatus::TimedOut) + { + stop_flag.store(true, std::sync::atomic::Ordering::Relaxed); + } + + if let Ok(mut guard) = results.lock() { + guard.push((idx, result, diff, actual)); + } + } + }); + + handles.push(handle); + } + + for handle in handles { + handle + .join() + .map_err(|_| anyhow::anyhow!("worker thread panicked"))?; + } + + let mut final_results = Arc::try_unwrap(results) + .map_err(|_| anyhow::anyhow!("failed to unwrap results"))? + .into_inner() + .map_err(|e| anyhow::anyhow!("mutex poisoned: {e}"))?; + final_results.sort_by_key(|(idx, _, _, _)| *idx); + + Ok(final_results + .into_iter() + .map(|(_, result, diff, actual)| (result, diff, actual)) + .collect()) +} + +#[allow(clippy::too_many_arguments, clippy::too_many_lines)] +fn run_single_test( + entry: &TestEntry, + brush_path: &Path, + work_dir: &Path, + tests_dir: &Path, + bash_source_dir: &Path, + tmpdir: &str, + timeout: Duration, + mode: &CompareMode, + bash_path_str: &str, + verbose: bool, +) -> (TestResult, Option, Option) { + if verbose { + eprintln!(" Running: {} ({})", entry.name, entry.test_script); + } + + let effective_timeout = timeout * entry.timeout_multiplier; + let brush_result = execute_test( + brush_path, + entry, + work_dir, + tests_dir, + bash_source_dir, + tmpdir, + effective_timeout, + ); + + let (brush_output, duration) = match brush_result { + Ok((output, dur)) => (output, dur), + Err(e) => { + let err_msg = format!("{e:#}"); + let is_timeout = err_msg.contains("timed out"); + // Read the .right file to report expected line count even on error. + let total_lines = std::fs::read(tests_dir.join(&entry.right_file)) + .map_or(0, |b| String::from_utf8_lossy(&b).lines().count()); + let duration_secs = if is_timeout { + timeout.as_secs_f64() + } else { + 0.0 + }; + return ( + TestResult { + name: entry.name.clone(), + status: if is_timeout { + TestStatus::TimedOut + } else { + TestStatus::Failed + }, + matching_lines: 0, + total_lines, + duration_secs: (duration_secs * 10.0).round() / 10.0, + first_diff_line: None, + known_issue: None, + }, + Some(format!("Error: {err_msg}")), + None, + ); + } + }; + + let duration_secs = duration.as_secs_f64(); + + let (expected, expected_label) = match mode { + CompareMode::Static => { + let right_path = tests_dir.join(&entry.right_file); + // Use lossy conversion: some .right files contain non-UTF-8 data + // (e.g. nquote4.right is ISO-8859, glob.right is binary). + match std::fs::read(&right_path) { + Ok(bytes) => { + let content = String::from_utf8_lossy(&bytes).to_string(); + (content, entry.right_file.clone()) + } + Err(e) => { + return ( + TestResult { + name: entry.name.clone(), + status: TestStatus::Skipped, + matching_lines: 0, + total_lines: 0, + duration_secs, + first_diff_line: None, + known_issue: None, + }, + Some(format!("Could not read {}: {e}", entry.right_file)), + Some(brush_output), + ); + } + } + } + CompareMode::Oracle => { + let bash_path = Path::new(bash_path_str); + match execute_test( + bash_path, + entry, + work_dir, + tests_dir, + bash_source_dir, + tmpdir, + effective_timeout, + ) { + Ok((output, _)) => { + let normalized = normalize_output(&output, bash_path); + (normalized, "bash output".to_string()) + } + Err(e) => { + return ( + TestResult { + name: entry.name.clone(), + status: TestStatus::Skipped, + matching_lines: 0, + total_lines: 0, + duration_secs, + first_diff_line: None, + known_issue: None, + }, + Some(format!("Oracle execution failed: {e:#}")), + Some(brush_output), + ); + } + } + } + }; + + let normalized_brush = normalize_output(&brush_output, brush_path); + let (matching, total, first_diff) = compare_outputs(&normalized_brush, &expected); + + let status = + if matching == total && normalized_brush.lines().count() == expected.lines().count() { + TestStatus::Passed + } else { + TestStatus::Failed + }; + + let diff = if status == TestStatus::Failed { + Some(unified_diff( + &expected, + &normalized_brush, + &expected_label, + "brush output", + )) + } else { + None + }; + + // Promote to PASS if the diff is fully explained by a known platform issue. + let (status, known_issue) = if status == TestStatus::Failed { + if let Some(desc) = diff + .as_ref() + .and_then(|d| check_known_platform_issue(&entry.name, d)) + { + (TestStatus::Passed, Some(desc.to_string())) + } else { + (status, None) + } + } else { + (status, None) + }; + + ( + TestResult { + name: entry.name.clone(), + status, + matching_lines: matching, + total_lines: total, + duration_secs: (duration_secs * 10.0).round() / 10.0, + first_diff_line: first_diff, + known_issue, + }, + diff, + Some(normalized_brush), + ) +} diff --git a/local/recipes/shells/brush/source/xtask/src/check.rs b/local/recipes/shells/brush/source/xtask/src/check.rs new file mode 100644 index 0000000000..0c74fe75f0 --- /dev/null +++ b/local/recipes/shells/brush/source/xtask/src/check.rs @@ -0,0 +1,217 @@ +//! Check commands for code quality validation. +//! +//! This module provides various code quality checks that can be run individually +//! or as part of a CI workflow. Each check wraps an external tool and provides +//! consistent error handling and verbose output. +//! +//! Some checks require additional tools to be installed: +//! - `cargo-deny`: Security/license auditing (`cargo install cargo-deny`) +//! - `cargo-udeps`: Unused dependency detection (`cargo install cargo-udeps`, requires nightly) +//! - `cargo-public-api`: Public API analysis (`cargo install cargo-public-api`, requires nightly) +//! - `typos`: Spelling checker (`cargo install typos-cli`) +//! - `zizmor`: GitHub workflow security scanner (`pip install zizmor`) +//! - `lychee`: Link checker (`cargo install lychee`) + +use anyhow::{Context, Result}; +use clap::Parser; +use xshell::{Shell, cmd}; + +/// Run code quality checks. +#[derive(Parser)] +pub enum CheckCommand { + /// Check that the code compiles. + Build, + /// Check dependencies for security vulnerabilities and license compliance. + Deps, + /// Check code formatting. + Fmt, + /// Check for broken links in documentation. + Links, + /// Run clippy lints. + Lint, + /// Analyze public API for breaking changes (requires nightly). + PublicApi, + /// Check that generated schemas are up-to-date. + Schemas, + /// Check for spelling errors. + Spelling, + /// Check for unused dependencies (requires nightly). + UnusedDeps, + /// Check GitHub workflow files for security issues. + Workflows, +} + +/// Run a check command. +pub fn run(cmd: &CheckCommand, verbose: bool) -> Result<()> { + let sh = Shell::new()?; + + match cmd { + CheckCommand::Fmt => check_fmt(&sh, verbose), + CheckCommand::Lint => check_lint(&sh, verbose), + CheckCommand::Deps => check_deps(&sh, verbose), + CheckCommand::UnusedDeps => check_unused_deps(&sh, verbose), + CheckCommand::Build => check_build(&sh, verbose), + CheckCommand::Schemas => check_schemas(&sh, verbose), + CheckCommand::PublicApi => check_public_api(&sh, verbose), + CheckCommand::Spelling => check_spelling(&sh, verbose), + CheckCommand::Workflows => check_workflows(&sh, verbose), + CheckCommand::Links => check_links(&sh, verbose), + } +} + +fn check_fmt(sh: &Shell, verbose: bool) -> Result<()> { + eprintln!("Checking code formatting..."); + if verbose { + eprintln!("Running: cargo fmt --check --all"); + } + cmd!(sh, "cargo fmt --check --all") + .run() + .context("Format check failed")?; + eprintln!("Format check passed."); + Ok(()) +} + +fn check_lint(sh: &Shell, verbose: bool) -> Result<()> { + eprintln!("Running clippy..."); + let mut args = vec!["clippy", "--workspace", "--all-features", "--all-targets"]; + if verbose { + args.push("--verbose"); + eprintln!("Running: cargo {}", args.join(" ")); + } + cmd!(sh, "cargo {args...}") + .run() + .context("Clippy check failed")?; + eprintln!("Clippy check passed."); + Ok(()) +} + +fn check_deps(sh: &Shell, verbose: bool) -> Result<()> { + eprintln!("Checking dependencies..."); + if verbose { + eprintln!("Running: cargo deny --all-features check all"); + } + cmd!(sh, "cargo deny --all-features check all") + .run() + .context("Dependency check failed")?; + eprintln!("Dependency check passed."); + Ok(()) +} + +fn check_unused_deps(sh: &Shell, verbose: bool) -> Result<()> { + eprintln!("Checking for unused dependencies (requires nightly)..."); + if verbose { + eprintln!("Running: cargo +nightly udeps --workspace --all-targets --all-features"); + } + cmd!( + sh, + "cargo +nightly udeps --workspace --all-targets --all-features" + ) + .run() + .context("Unused dependency check failed")?; + eprintln!("Unused dependency check passed."); + Ok(()) +} + +fn check_build(sh: &Shell, verbose: bool) -> Result<()> { + eprintln!("Checking that code compiles..."); + let mut args = vec!["check", "--all-features", "--all-targets", "--workspace"]; + if verbose { + args.push("--verbose"); + eprintln!("Running: cargo {}", args.join(" ")); + } + cmd!(sh, "cargo {args...}") + .run() + .context("Build check failed")?; + eprintln!("Build check passed."); + Ok(()) +} + +fn check_schemas(sh: &Shell, verbose: bool) -> Result<()> { + eprintln!("Checking generated schemas..."); + + // Regenerate schemas to a temporary state to compare against committed versions. + if verbose { + eprintln!( + "Running: cargo run --package xtask -- gen schema config --out schemas/config.schema.json" + ); + } + cmd!( + sh, + "cargo run --package xtask -- gen schema config --out schemas/config.schema.json" + ) + .run() + .context("Failed to regenerate schemas")?; + + // Check for drift by capturing the diff output. + // We don't use --exit-code here because we want to capture and display the + // actual differences to help the user understand what changed. + if verbose { + eprintln!("Running: git diff schemas/"); + } + let diff_output = cmd!(sh, "git diff schemas/") + .read() + .context("Failed to run git diff on schemas directory")?; + + if !diff_output.is_empty() { + // Show the user exactly what changed so they can understand the drift. + eprintln!("\nSchema drift detected. The following changes were found:\n"); + eprintln!("{diff_output}"); + anyhow::bail!( + "Generated schemas are out of date. Please run 'cargo xtask gen schema config --out schemas/config.schema.json' and commit the changes." + ); + } + + eprintln!("Schema check passed."); + Ok(()) +} + +fn check_public_api(sh: &Shell, verbose: bool) -> Result<()> { + eprintln!("Analyzing public API (requires nightly and cargo-public-api)..."); + + // This is typically only useful for PRs comparing against main + if verbose { + eprintln!("Running: cargo +nightly public-api --version"); + } + cmd!(sh, "cargo +nightly public-api --version") + .run() + .context("cargo-public-api not installed. Install with: cargo install cargo-public-api")?; + + eprintln!("Public API analysis complete. For PR diffs, compare against main branch."); + Ok(()) +} + +fn check_spelling(sh: &Shell, verbose: bool) -> Result<()> { + eprintln!("Checking spelling..."); + if verbose { + eprintln!("Running: typos"); + } + cmd!(sh, "typos") + .run() + .context("Spelling check failed. Install typos with: cargo install typos-cli")?; + eprintln!("Spelling check passed."); + Ok(()) +} + +fn check_workflows(sh: &Shell, verbose: bool) -> Result<()> { + eprintln!("Checking GitHub workflows for security issues..."); + if verbose { + eprintln!("Running: zizmor .github/workflows/"); + } + cmd!(sh, "zizmor .github/workflows/") + .run() + .context("Workflow check failed. Install zizmor with: pip install zizmor")?; + eprintln!("Workflow check passed."); + Ok(()) +} + +fn check_links(sh: &Shell, verbose: bool) -> Result<()> { + eprintln!("Checking for broken links..."); + if verbose { + eprintln!("Running: lychee --offline docs/"); + } + cmd!(sh, "lychee --offline docs/") + .run() + .context("Link check failed. Install lychee with: cargo install lychee")?; + eprintln!("Link check passed."); + Ok(()) +} diff --git a/local/recipes/shells/brush/source/xtask/src/ci.rs b/local/recipes/shells/brush/source/xtask/src/ci.rs new file mode 100644 index 0000000000..e5b1752996 --- /dev/null +++ b/local/recipes/shells/brush/source/xtask/src/ci.rs @@ -0,0 +1,201 @@ +//! CI workflow commands that aggregate multiple checks and tests. +//! +//! This module provides composite workflows that run multiple checks in sequence: +//! +//! ## Quick workflow (`cargo xtask ci quick`) +//! +//! Fast inner-loop checks (~7s warm cache) for rapid iteration: +//! 1. **Format check** - Fast, catches formatting issues early +//! 2. **Build check** - Ensures code compiles with all features +//! 3. **Lint check** - Clippy warnings that should be addressed +//! 4. **Unit tests** - Fast tests excluding integration test binaries +//! +//! ## Pre-commit workflow (`cargo xtask ci pre-commit`) +//! +//! Comprehensive validation (~45s warm cache) before committing: +//! 1. All quick workflow checks +//! 2. **Dependency check** - Security vulnerabilities and license compliance +//! 3. **Schema check** - Verifies generated schemas are up-to-date +//! 4. **Integration tests** - Full workspace tests including compat tests +//! +//! The ordering is intentional: fast checks run first to provide quick feedback, +//! with slower comprehensive tests running last. + +use anyhow::Result; +use clap::Parser; + +use crate::check::{self, CheckCommand}; +use crate::test::{ + self, BinaryArgs, IntegrationTestArgs, TestCommand, TestSubcommand, UnitTestArgs, +}; + +/// Type alias for a named step in a CI workflow. +type Step<'a> = (&'a str, Box Result<()> + 'a>); + +/// Run CI workflows. +#[derive(Parser)] +pub enum CiCommand { + /// Run quick inner-loop checks: fmt, build, lint, unit tests (~7s warm). + /// + /// Use this for rapid iteration during development. + Quick(QuickArgs), + + /// Run full pre-commit workflow: quick + deps, schemas, integration tests (~45s warm). + /// + /// This runs all essential checks that should pass before every commit. + /// Does not include: bench, links, public-api, spelling, unused-deps, workflows. + PreCommit(PreCommitArgs), +} + +/// Arguments for quick workflow. +#[derive(Parser)] +pub struct QuickArgs { + /// Continue running checks even if one fails. + #[clap(short = 'k', long)] + continue_on_error: bool, +} + +/// Arguments for pre-commit workflow. +#[derive(Parser)] +pub struct PreCommitArgs { + /// Continue running checks even if one fails. + #[clap(short = 'k', long)] + continue_on_error: bool, +} + +/// Run a CI workflow command. +pub fn run(cmd: &CiCommand, verbose: bool) -> Result<()> { + match cmd { + CiCommand::Quick(args) => run_quick(args, verbose), + CiCommand::PreCommit(args) => run_pre_commit(args, verbose), + } +} + +/// Create a `TestCommand` for unit tests. +fn make_unit_test_command() -> TestCommand { + TestCommand { + binary_args: BinaryArgs { + brush_path: None, + profile: crate::common::BuildProfile::Debug, + debug: false, + release: false, + }, + subcommand: TestSubcommand::Unit(UnitTestArgs::default()), + } +} + +/// Create a `TestCommand` for integration tests. +fn make_integration_test_command() -> TestCommand { + TestCommand { + binary_args: BinaryArgs { + brush_path: None, + profile: crate::common::BuildProfile::Debug, + debug: false, + release: false, + }, + subcommand: TestSubcommand::Integration(IntegrationTestArgs::default()), + } +} + +/// Run quick inner-loop checks (~7s warm cache). +fn run_quick(args: &QuickArgs, verbose: bool) -> Result<()> { + eprintln!("Running quick checks...\n"); + + let steps: Vec> = vec![ + ( + "Format check", + Box::new(|| check::run(&CheckCommand::Fmt, verbose)), + ), + ( + "Build check", + Box::new(|| check::run(&CheckCommand::Build, verbose)), + ), + ( + "Lint check", + Box::new(|| check::run(&CheckCommand::Lint, verbose)), + ), + ( + "Unit tests", + Box::new(|| test::run(&make_unit_test_command(), verbose)), + ), + ]; + + run_steps(&steps, args.continue_on_error, "Quick checks") +} + +fn run_pre_commit(args: &PreCommitArgs, verbose: bool) -> Result<()> { + eprintln!("Running pre-commit checks...\n"); + + let steps: Vec> = vec![ + // Quick checks first + ( + "Format check", + Box::new(|| check::run(&CheckCommand::Fmt, verbose)), + ), + ( + "Build check", + Box::new(|| check::run(&CheckCommand::Build, verbose)), + ), + ( + "Lint check", + Box::new(|| check::run(&CheckCommand::Lint, verbose)), + ), + ( + "Unit tests", + Box::new(|| test::run(&make_unit_test_command(), verbose)), + ), + // Additional pre-commit checks + ( + "Dependency check", + Box::new(|| check::run(&CheckCommand::Deps, verbose)), + ), + ( + "Schema check", + Box::new(|| check::run(&CheckCommand::Schemas, verbose)), + ), + ( + "Integration tests", + Box::new(|| test::run(&make_integration_test_command(), verbose)), + ), + ]; + + run_steps(&steps, args.continue_on_error, "Pre-commit checks") +} + +/// Run a series of steps, optionally continuing on error. +fn run_steps(steps: &[Step<'_>], continue_on_error: bool, workflow_name: &str) -> Result<()> { + let mut failures: Vec<&str> = Vec::new(); + + for (name, step) in steps { + eprintln!("\n{}", "=".repeat(60)); + eprintln!("Running: {name}"); + eprintln!("{}\n", "=".repeat(60)); + + if let Err(e) = step() { + eprintln!("\n❌ {name} failed: {e}"); + if continue_on_error { + failures.push(name); + } else { + return Err(e); + } + } else { + eprintln!("\n✅ {name} passed"); + } + } + + if !failures.is_empty() { + eprintln!("\n{}", "=".repeat(60)); + eprintln!("{workflow_name} completed with failures:"); + for name in &failures { + eprintln!(" ❌ {name}"); + } + eprintln!("{}", "=".repeat(60)); + anyhow::bail!("{} check(s) failed", failures.len()); + } + + eprintln!("\n{}", "=".repeat(60)); + eprintln!("✅ All {workflow_name} passed!"); + eprintln!("{}", "=".repeat(60)); + + Ok(()) +} diff --git a/local/recipes/shells/brush/source/xtask/src/common.rs b/local/recipes/shells/brush/source/xtask/src/common.rs new file mode 100644 index 0000000000..9a90f31eb9 --- /dev/null +++ b/local/recipes/shells/brush/source/xtask/src/common.rs @@ -0,0 +1,107 @@ +//! Common utilities shared across xtask commands. +//! +//! This module provides shared functionality for: +//! - Build profile selection (debug vs release) +//! - Workspace root discovery +//! - Brush binary location for test commands + +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use clap::ValueEnum; + +/// Build profile for selecting which binary to use. +#[derive(Clone, Copy, Debug, Default, ValueEnum, PartialEq, Eq)] +pub enum BuildProfile { + /// Debug build (target/debug/). + #[default] + Debug, + /// Release build (target/release/). + Release, +} + +impl BuildProfile { + /// Returns the target subdirectory name for this profile. + #[must_use] + pub const fn target_dir_name(self) -> &'static str { + match self { + Self::Debug => "debug", + Self::Release => "release", + } + } +} + +/// Find the workspace root directory. +/// +/// This walks up from the xtask crate directory to find the workspace root +/// (the directory containing the top-level Cargo.toml with [workspace]). +pub fn find_workspace_root() -> Result { + // Start from the xtask crate directory + let xtask_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + + // The workspace root is the parent of xtask/ + let workspace_root = xtask_dir + .parent() + .context("Failed to find workspace root (parent of xtask)")?; + + Ok(workspace_root.to_path_buf()) +} + +/// Find the brush binary path for the given build profile. +/// +/// If `override_path` is provided, it is used directly (after validation). +/// Otherwise, the binary is located in the workspace's target directory +/// based on the specified profile. +pub fn find_brush_binary( + override_path: Option<&PathBuf>, + profile: BuildProfile, +) -> Result { + let binary_path = if let Some(path) = override_path { + path.clone() + } else { + let workspace_root = find_workspace_root()?; + let binary_name = if cfg!(windows) { "brush.exe" } else { "brush" }; + workspace_root + .join("target") + .join(profile.target_dir_name()) + .join(binary_name) + }; + + // Canonicalize to get absolute path and verify existence + let canonical_path = binary_path.canonicalize().with_context(|| { + format!( + "Brush binary not found at: {} (profile: {:?}). Did you run `cargo build{}`?", + binary_path.display(), + profile, + if profile == BuildProfile::Release { + " --release" + } else { + "" + } + ) + })?; + + Ok(canonical_path) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_profile_dir_names() { + assert_eq!(BuildProfile::Debug.target_dir_name(), "debug"); + assert_eq!(BuildProfile::Release.target_dir_name(), "release"); + } + + #[test] + fn test_find_workspace_root() { + let root = find_workspace_root(); + assert!(root.is_ok(), "Should find workspace root"); + let root = root.unwrap(); + // The workspace root should contain Cargo.toml + assert!(root.join("Cargo.toml").exists()); + // And it should contain the xtask directory + assert!(root.join("xtask").exists()); + } +} diff --git a/local/recipes/shells/brush/source/xtask/src/generate.rs b/local/recipes/shells/brush/source/xtask/src/generate.rs new file mode 100644 index 0000000000..dd4ca33c62 --- /dev/null +++ b/local/recipes/shells/brush/source/xtask/src/generate.rs @@ -0,0 +1,281 @@ +//! Generation commands for documentation, completions, and schemas. +//! +//! This module provides commands for generating various artifacts: +//! - **Documentation**: Man pages and markdown help text from clap definitions +//! - **Completions**: Shell completion scripts for bash, zsh, fish, etc. +//! - **Schemas**: JSON schemas for configuration files +//! - **Distribution archives**: Reproducible documentation bundles with checksums + +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use clap::{CommandFactory, Parser}; +use xshell::{Shell, cmd}; + +/// Generate various artifacts. +#[derive(Parser)] +pub enum GenCommand { + /// Generate completion scripts. + #[clap(subcommand)] + Completion(CompletionCommand), + /// Generate documentation. + #[clap(subcommand)] + Docs(DocsCommand), + /// Generate JSON schemas. + #[clap(subcommand)] + Schema(SchemaCommand), +} + +/// Documentation generation commands. +#[derive(Parser)] +pub enum DocsCommand { + /// Generate man content. + Man(GenerateManArgs), + /// Generate help content in markdown format. + Markdown(GenerateMarkdownArgs), + /// Generate a reproducible documentation distribution archive with checksums. + Dist(GenerateDistArgs), +} + +/// Completion script generation commands. +#[derive(Parser)] +pub enum CompletionCommand { + /// Generate completion script for `bash`. + Bash, + /// Generate completion script for `elvish`. + Elvish, + /// Generate completion script for `fish`. + Fish, + /// Generate completion script for `PowerShell`. + PowerShell, + /// Generate completion script for `zsh`. + Zsh, +} + +/// Arguments for man page generation. +#[derive(Parser)] +pub struct GenerateManArgs { + /// Output directory. + #[clap(long = "output-dir", short = 'o')] + output_dir: PathBuf, +} + +/// Arguments for markdown documentation generation. +#[derive(Parser)] +pub struct GenerateMarkdownArgs { + /// Output file path. + #[clap(long = "out", short = 'o')] + output_path: PathBuf, +} + +/// Arguments for documentation distribution generation. +#[derive(Parser)] +pub struct GenerateDistArgs { + /// Output file path for the distribution archive (defaults to brush-docs.tar.gz). + #[clap(long = "out", short = 'o', default_value = "brush-docs.tar.gz")] + output_path: PathBuf, + + /// Generate SHA-256 checksum file alongside the distribution archive. + #[clap(long, default_value_t = true)] + sha256: bool, + + /// Generate SHA-512 checksum file alongside the distribution archive. + #[clap(long, default_value_t = true)] + sha512: bool, +} + +/// Schema generation commands. +#[derive(Parser)] +pub enum SchemaCommand { + /// Generate JSON schema for the configuration file. + Config(GenerateSchemaArgs), +} + +/// Arguments for schema generation. +#[derive(Parser)] +pub struct GenerateSchemaArgs { + /// Output file path. + #[clap(long = "out", short = 'o')] + output_path: PathBuf, +} + +/// Run a generation command. +pub fn run(cmd: &GenCommand, verbose: bool) -> Result<()> { + match cmd { + GenCommand::Docs(docs_cmd) => match docs_cmd { + DocsCommand::Man(args) => gen_man(args, verbose), + DocsCommand::Markdown(args) => gen_markdown_docs(args, verbose), + DocsCommand::Dist(args) => gen_docs_dist(args, verbose), + }, + GenCommand::Completion(completion_cmd) => { + let shell = match completion_cmd { + CompletionCommand::Bash => clap_complete::Shell::Bash, + CompletionCommand::Elvish => clap_complete::Shell::Elvish, + CompletionCommand::Fish => clap_complete::Shell::Fish, + CompletionCommand::PowerShell => clap_complete::Shell::PowerShell, + CompletionCommand::Zsh => clap_complete::Shell::Zsh, + }; + gen_completion_script(shell, verbose); + Ok(()) + } + GenCommand::Schema(schema_cmd) => match schema_cmd { + SchemaCommand::Config(args) => gen_config_schema(args, verbose), + }, + } +} + +fn gen_man(args: &GenerateManArgs, verbose: bool) -> Result<()> { + if verbose { + eprintln!("Generating man pages to: {}", args.output_dir.display()); + } + + // Create the output dir if it doesn't exist. If it already does, we proceed + // onward and hope for the best. + if !args.output_dir.exists() { + std::fs::create_dir_all(&args.output_dir)?; + } + + // Generate! + let cmd = brush_shell::args::CommandLineArgs::command(); + clap_mangen::generate_to(cmd, &args.output_dir)?; + + Ok(()) +} + +fn gen_markdown_docs(args: &GenerateMarkdownArgs, verbose: bool) -> Result<()> { + if verbose { + eprintln!( + "Generating markdown docs to: {}", + args.output_path.display() + ); + } + + let options = clap_markdown::MarkdownOptions::new() + .show_footer(false) + .show_table_of_contents(true); + + // Generate! + let markdown = + clap_markdown::help_markdown_custom::(&options); + std::fs::write(&args.output_path, markdown)?; + + Ok(()) +} + +/// Generate a shell completion script to stdout. +/// +/// The completion script is written directly to stdout so it can be piped +/// to a file or sourced directly by the shell. +fn gen_completion_script(shell: clap_complete::Shell, verbose: bool) { + if verbose { + eprintln!("Generating {shell} completion script..."); + } + let mut cmd = brush_shell::args::CommandLineArgs::command(); + clap_complete::generate(shell, &mut cmd, "brush", &mut std::io::stdout()); +} + +fn gen_config_schema(args: &GenerateSchemaArgs, verbose: bool) -> Result<()> { + if verbose { + eprintln!( + "Generating config schema to: {}", + args.output_path.display() + ); + } + + // Generate JSON schema for the configuration file. + let schema = schemars::schema_for!(brush_shell::config::Config); + let json = serde_json::to_string_pretty(&schema)?; + std::fs::write(&args.output_path, format!("{json}\n"))?; + + Ok(()) +} + +fn gen_docs_dist(args: &GenerateDistArgs, verbose: bool) -> Result<()> { + let sh = Shell::new()?; + + // Create a temporary directory for staging the documentation + let temp_dir = tempfile::tempdir().context("Failed to create temporary directory")?; + let staging_dir = temp_dir.path(); + let md_dir = staging_dir.join("md"); + let man_dir = staging_dir.join("man"); + + std::fs::create_dir_all(&md_dir)?; + std::fs::create_dir_all(&man_dir)?; + + if verbose { + eprintln!("Staging documentation in: {}", staging_dir.display()); + } + + // Generate markdown documentation + let md_args = GenerateMarkdownArgs { + output_path: md_dir.join("brush.md"), + }; + gen_markdown_docs(&md_args, verbose)?; + + // Generate man pages + let man_args = GenerateManArgs { + output_dir: man_dir, + }; + gen_man(&man_args, verbose)?; + + // Get absolute path for output + let output_path = if args.output_path.is_absolute() { + args.output_path.clone() + } else { + std::env::current_dir()?.join(&args.output_path) + }; + + if verbose { + eprintln!( + "Creating reproducible distribution archive: {}", + output_path.display() + ); + } + + // Create reproducible distribution archive using tar with options for reproducibility: + // - --sort=name: Sort files by name for consistent ordering + // - --mtime: Set modification time to epoch for reproducibility + // - --owner=0 --group=0: Remove user/group ownership info + // - --numeric-owner: Use numeric IDs + // - --pax-option: Remove atime/ctime from PAX headers + let output_path_str = output_path.display().to_string(); + + // Change to staging directory and create archive + let dir_guard = sh.push_dir(staging_dir); + + cmd!( + sh, + "tar --sort=name --mtime=1970-01-01T00:00:00Z --owner=0 --group=0 --numeric-owner --pax-option=exthdr.name=%d/PaxHeaders/%f,delete=atime,delete=ctime -czf {output_path_str} ." + ) + .run() + .context("Failed to create distribution archive")?; + + eprintln!("Created: {}", output_path.display()); + + // Generate checksums + drop(dir_guard); + + if args.sha256 { + let checksum_path = format!("{}.sha256", output_path.display()); + let checksum = cmd!(sh, "sha256sum {output_path_str}") + .read() + .context("Failed to generate SHA-256 checksum")?; + std::fs::write(&checksum_path, format!("{checksum}\n"))?; + if verbose { + eprintln!("Created: {checksum_path}"); + } + } + + if args.sha512 { + let checksum_path = format!("{}.sha512", output_path.display()); + let checksum = cmd!(sh, "sha512sum {output_path_str}") + .read() + .context("Failed to generate SHA-512 checksum")?; + std::fs::write(&checksum_path, format!("{checksum}\n"))?; + if verbose { + eprintln!("Created: {checksum_path}"); + } + } + + Ok(()) +} diff --git a/local/recipes/shells/brush/source/xtask/src/main.rs b/local/recipes/shells/brush/source/xtask/src/main.rs new file mode 100644 index 0000000000..dcc489d411 --- /dev/null +++ b/local/recipes/shells/brush/source/xtask/src/main.rs @@ -0,0 +1,62 @@ +//! xtask-style command-line tool for building this project. + +mod analyze; +#[cfg(unix)] +mod bash_tests; +mod check; +mod ci; +mod common; +mod generate; +mod test; + +use anyhow::Result; +use clap::Parser; + +/// Global options shared across all commands. +#[derive(Parser, Debug, Clone, Copy)] +pub struct GlobalArgs { + /// Enable verbose output. + #[clap(long, short = 'v', global = true)] + pub verbose: bool, +} + +#[derive(Parser)] +#[clap(name = "xtask", about = "Build automation tasks for brush")] +struct CommandLineArgs { + #[clap(flatten)] + global: GlobalArgs, + + #[clap(subcommand)] + command: Command, +} + +#[derive(Parser)] +enum Command { + /// Run analysis tasks (benchmarks, public API diffing). + #[clap(subcommand)] + Analyze(analyze::AnalyzeCommand), + /// Run code quality checks. + #[clap(subcommand)] + Check(check::CheckCommand), + /// Run CI workflows. + #[clap(subcommand)] + Ci(ci::CiCommand), + /// Generate documentation, completions, and schemas. + #[clap(subcommand)] + Gen(generate::GenCommand), + /// Run tests. + Test(Box), +} + +fn main() -> Result<()> { + let args = CommandLineArgs::parse(); + let verbose = args.global.verbose; + + match &args.command { + Command::Analyze(cmd) => analyze::run(cmd, verbose), + Command::Gen(cmd) => generate::run(cmd, verbose), + Command::Check(cmd) => check::run(cmd, verbose), + Command::Test(cmd) => test::run(cmd, verbose), + Command::Ci(cmd) => ci::run(cmd, verbose), + } +} diff --git a/local/recipes/shells/brush/source/xtask/src/test.rs b/local/recipes/shells/brush/source/xtask/src/test.rs new file mode 100644 index 0000000000..1a85a4f9dd --- /dev/null +++ b/local/recipes/shells/brush/source/xtask/src/test.rs @@ -0,0 +1,755 @@ +//! Test commands for running various test suites. +//! +//! This module provides commands for running different types of tests: +//! +//! - **Unit tests**: Fast tests that don't execute the brush binary (excludes integration test +//! binaries like brush-compat-tests, brush-interactive-tests, brush-completion-tests) +//! - **Integration tests**: All workspace tests including unit tests and integration tests that +//! execute the brush binary +//! - **External suites**: Third-party test suites like bash-completion +//! +//! Both unit and integration tests support optional coverage collection via +//! `cargo-llvm-cov`. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use clap::{Args, Parser, Subcommand}; +use xshell::{Shell, cmd}; + +use crate::common::{BuildProfile, find_brush_binary, find_workspace_root}; + +/// Integration test binaries that are excluded from unit tests. +/// These tests execute the brush binary and are slower. +const INTEGRATION_TEST_BINARIES: &[&str] = &[ + "brush-compat-tests", + "brush-interactive-tests", + "brush-completion-tests", +]; + +#[cfg(windows)] +const TEST_BINARIES_DISABLED_ON_WINDOWS: &[&str] = &["brush-compat-tests"]; + +/// Shared arguments for test commands that need a brush binary. +#[derive(Args, Debug, Clone)] +pub struct BinaryArgs { + /// Path to the brush binary to test. If not specified, uses the binary + /// from the workspace's target directory based on --profile/--debug/--release. + #[clap(long, global = true)] + pub brush_path: Option, + + /// Build profile to use when auto-detecting the brush binary. + #[clap(long, short = 'p', value_enum, default_value_t = BuildProfile::Debug, global = true)] + pub profile: BuildProfile, + + /// Use debug build profile (shorthand for --profile=debug). + #[clap(long, conflicts_with_all = ["profile", "release"], global = true)] + pub debug: bool, + + /// Use release build profile (shorthand for --profile=release). + #[clap(long, conflicts_with_all = ["profile", "debug"], global = true)] + pub release: bool, +} + +impl BinaryArgs { + /// Resolve the effective build profile, considering --debug/--release shorthands. + #[must_use] + pub const fn effective_profile(&self) -> BuildProfile { + if self.debug { + BuildProfile::Debug + } else if self.release { + BuildProfile::Release + } else { + self.profile + } + } + + /// Find the brush binary using these arguments. + pub fn find_brush_binary(&self) -> Result { + find_brush_binary(self.brush_path.as_ref(), self.effective_profile()) + } +} + +/// Run tests. +#[derive(Parser)] +pub struct TestCommand { + /// Shared binary arguments. + #[clap(flatten)] + pub binary_args: BinaryArgs, + + /// Test subcommand. + #[clap(subcommand)] + pub subcommand: TestSubcommand, +} + +/// Test subcommands. +#[derive(Subcommand, Clone)] +pub enum TestSubcommand { + /// Run unit tests (fast tests that don't execute the brush binary). + /// + /// Excludes integration test binaries: brush-compat-tests, brush-interactive-tests, + /// brush-completion-tests. + Unit(UnitTestArgs), + + /// Run all workspace tests (unit + integration tests). + /// + /// This includes all tests: unit tests plus integration tests that execute + /// the brush binary (compat tests, interactive tests, completion tests). + Integration(IntegrationTestArgs), + + /// Run external test suites. + #[clap(subcommand)] + External(ExternalTestCommand), +} + +/// Arguments for unit tests. +#[derive(Args, Clone, Default)] +pub struct UnitTestArgs { + /// Coverage options. + #[clap(flatten)] + pub coverage: CoverageArgs, +} + +/// Arguments for integration tests. +#[derive(Args, Clone, Default)] +pub struct IntegrationTestArgs { + /// Coverage options. + #[clap(flatten)] + pub coverage: CoverageArgs, + + /// Copy the nextest `JUnit` XML results to this path after the test run. + /// The copy is performed even if tests fail, so CI can always upload results. + #[clap(long)] + pub results_output: Option, + + /// Build and test against a wasm32-wasip2 target under a WASI runtime + /// (wasmtime by default). Builds brush for wasm32-wasip2 with minimal + /// features, then runs a subset of integration tests (excluding compat, + /// interactive, and completion suites) under the WASI launcher. + #[clap(long)] + pub wasi: bool, + + /// Launcher command for the WASI runtime (only used with --wasi). + /// The first token is resolved against `PATH`; subsequent tokens are + /// passed as leading arguments before the brush binary path. + /// Defaults to `wasmtime run --dir=.::/ --allow-precompiled --`. + #[clap(long)] + pub wasi_launcher: Option, + + /// Skip the WASI wasm build step and assume brush.wasm is already + /// present at the expected path (only used with --wasi). + #[clap(long)] + pub skip_wasi_build: bool, +} + +/// Arguments for coverage collection. +#[derive(Args, Clone, Default)] +pub struct CoverageArgs { + /// Collect code coverage during test run. + #[clap(long)] + pub coverage: bool, + + /// Output file for coverage report (Cobertura XML format). + /// Only used when --coverage is specified. + #[clap(long, short = 'o', default_value = "codecov.xml")] + pub coverage_output: PathBuf, +} + +/// External test suite commands. +#[derive(Subcommand, Clone)] +pub enum ExternalTestCommand { + /// Run the bash-completion test suite against brush. + BashCompletion(BashCompletionArgs), + /// Run the upstream bash test suite against brush. + #[cfg(unix)] + BashTests(crate::bash_tests::BashTestsArgs), +} + +/// Arguments for bash-completion test suite. +#[derive(Args, Clone)] +pub struct BashCompletionArgs { + /// Path to the bash-completion repository checkout. + #[clap(long)] + bash_completion_path: PathBuf, + + /// List available tests without running them. + #[clap(long)] + list: bool, + + /// Filter tests by name pattern (passed to pytest -k). + /// Supports pytest expression syntax, e.g., `"test_alias"`, `"test_alias and test_1"`. + #[clap(long, short = 't')] + test_filter: Option, + + /// Run only specific test file(s). Can be specified multiple times. + /// Example: `-f test_alias.py -f test_bash.py` + #[clap(long, short = 'f')] + file: Vec, + + /// Stop on first test failure. + #[clap(long, short = 'x')] + stop_on_first: bool, + + /// Output file for JSON test results. + #[clap(long, short = 'o')] + output: Option, + + /// Output file for markdown summary report. + #[clap(long)] + summary_output: Option, + + /// Path to the summarize-pytest-results.py script (for generating summary). + /// Defaults to ./scripts/summarize-pytest-results.py relative to workspace root. + #[clap(long)] + summary_script: Option, + + /// Number of parallel test workers (requires pytest-xdist). + /// Use -j 1 to disable parallel execution. + #[clap(long, short = 'j', default_value_t = 128)] + jobs: u32, +} + +/// Run a test command. +pub fn run(cmd: &TestCommand, verbose: bool) -> Result<()> { + let sh = Shell::new()?; + + match &cmd.subcommand { + TestSubcommand::Unit(args) => run_unit_tests(&sh, &cmd.binary_args, args, verbose), + TestSubcommand::Integration(args) => { + run_integration_tests(&sh, &cmd.binary_args, args, verbose) + } + TestSubcommand::External(ext_cmd) => run_external(ext_cmd, &cmd.binary_args, &sh, verbose), + } +} + +fn run_external( + cmd: &ExternalTestCommand, + binary_args: &BinaryArgs, + sh: &Shell, + verbose: bool, +) -> Result<()> { + match cmd { + ExternalTestCommand::BashCompletion(args) => { + run_bash_completion_tests(sh, args, binary_args, verbose) + } + #[cfg(unix)] + ExternalTestCommand::BashTests(args) => { + crate::bash_tests::run_bash_tests(args, binary_args, verbose) + } + } +} + +/// Run unit tests (excludes integration test binaries). +/// +/// Unit tests are fast tests that don't execute the brush binary. +pub fn run_unit_tests( + sh: &Shell, + binary_args: &BinaryArgs, + args: &UnitTestArgs, + verbose: bool, +) -> Result<()> { + let profile = binary_args.effective_profile(); + eprintln!("Running unit tests ({profile:?} profile)..."); + + // Build the filter expression to exclude integration test binaries + let exclusions: Vec = INTEGRATION_TEST_BINARIES + .iter() + .map(|name| format!("not binary({name})")) + .collect(); + let filter_expr = exclusions.join(" and "); + + if args.coverage.coverage { + run_tests_with_coverage( + sh, + profile, + Some(&filter_expr), + &args.coverage.coverage_output, + verbose, + ) + } else { + run_nextest(sh, profile, Some(&filter_expr), verbose)?; + eprintln!("Unit tests passed."); + Ok(()) + } +} + +/// Run all workspace tests (unit + integration). +/// +/// This runs all tests in the workspace, including integration tests +/// that execute the brush binary. With `--wasi`, builds brush for +/// wasm32-wasip2 and runs the integration tests under a WASI runtime. +pub fn run_integration_tests( + sh: &Shell, + binary_args: &BinaryArgs, + args: &IntegrationTestArgs, + verbose: bool, +) -> Result<()> { + let profile = binary_args.effective_profile(); + + if args.wasi { + if args.coverage.coverage { + eprintln!("Warning: --coverage is not supported with --wasi and will be ignored."); + } + return run_integration_tests_wasi(sh, profile, args, verbose); + } + + eprintln!("Running integration tests ({profile:?} profile)..."); + + #[cfg(windows)] + let exclusions: Vec = TEST_BINARIES_DISABLED_ON_WINDOWS + .iter() + .map(|name| format!("not binary({name})")) + .collect(); + #[cfg(windows)] + let filter_expr = exclusions.join(" and "); + #[cfg(windows)] + let filter = Some(filter_expr.as_str()); + + #[cfg(not(windows))] + let filter = None; + + let test_result = if args.coverage.coverage { + run_tests_with_coverage(sh, profile, filter, &args.coverage.coverage_output, verbose) + } else { + run_nextest(sh, profile, filter, verbose).map(|()| { + eprintln!("Integration tests passed."); + }) + }; + + // Copy nextest results if requested (even on test failure, so CI can upload them). + if let Some(ref output) = args.results_output { + copy_nextest_results(output)?; + } + + test_result +} + +/// Run the brush integration tests against a wasm32-wasip2 build of brush, +/// executed under a WASI runtime. Builds the wasm module first unless +/// `--skip-wasi-build` is given, then runs the integration tests via nextest +/// with the appropriate environment variables populated for the test harness. +fn run_integration_tests_wasi( + sh: &Shell, + profile: BuildProfile, + args: &IntegrationTestArgs, + verbose: bool, +) -> Result<()> { + let is_release = profile == BuildProfile::Release; + + // Build the wasm module unless the caller opts out. + if !args.skip_wasi_build { + eprintln!("Building brush for wasm32-wasip2..."); + let mut build_args = vec![ + "build", + "--target", + "wasm32-wasip2", + "-p", + "brush-shell", + "--bin", + "brush", + "--no-default-features", + "--features", + "minimal", + ]; + if is_release { + build_args.push("--release"); + } + if verbose { + eprintln!("Running: cargo {}", build_args.join(" ")); + } + cmd!(sh, "cargo {build_args...}") + .run() + .context("failed to build brush for wasm32-wasip2")?; + } + + // Locate the wasm module that was (or should have been) produced. + let workspace_root = find_workspace_root()?; + let profile_dir = if is_release { "release" } else { "debug" }; + let wasm_path = workspace_root + .join("target/wasm32-wasip2") + .join(profile_dir) + .join("brush.wasm"); + let wasm_path = wasm_path.canonicalize().with_context(|| { + format!( + "brush.wasm not found at {} — did the build step succeed?", + wasm_path.display() + ) + })?; + + // The `--dir=.::/` flag maps the host root filesystem into the WASI + // sandbox. This is intentionally permissive for testing — tests create + // temp dirs and need access to fixtures across the filesystem. This is + // NOT a recommended default for production use of brush under WASI. + // Pre-compile the wasm module to avoid JIT compilation overhead during + // parallel test execution. Without this, multiple concurrent wasmtime + // processes each try to JIT-compile brush.wasm simultaneously, which + // can exceed test timeouts on CI. + eprintln!("Pre-compiling brush.wasm..."); + let cwasm_path = wasm_path.with_extension("cwasm"); + { + let wasm_arg = wasm_path.display().to_string(); + let cwasm_arg = cwasm_path.display().to_string(); + cmd!(sh, "wasmtime compile {wasm_arg} -o {cwasm_arg}") + .run() + .context("failed to pre-compile brush.wasm with wasmtime")?; + } + + // The default launcher includes --allow-precompiled so wasmtime accepts + // the AOT-compiled .cwasm module without re-compilation. + let launcher = args + .wasi_launcher + .as_deref() + .unwrap_or("wasmtime run --dir=.::/ --allow-precompiled --"); + + eprintln!("Running brush integration tests under WASI..."); + eprintln!(" wasm: {}", cwasm_path.display()); + eprintln!(" launcher: {launcher}"); + + let brush_path_str = cwasm_path.display().to_string(); + let _brush_path = sh.push_env("BRUSH_PATH", &brush_path_str); + let _brush_launcher = sh.push_env("BRUSH_LAUNCHER", launcher); + let _brush_platform_tags = sh.push_env("BRUSH_PLATFORM_TAGS", "wasi wasm"); + + // Only run the brush integration tests; compat tests require a native binary. + let filter = "binary(brush-integration-tests)"; + let test_result = run_nextest(sh, profile, Some(filter), verbose); + + // Copy nextest results if requested (even on test failure, so CI can upload them). + if let Some(ref output) = args.results_output { + copy_nextest_results(output)?; + } + + test_result +} + +/// Run cargo nextest with optional filter expression. +fn run_nextest( + sh: &Shell, + profile: BuildProfile, + filter_expr: Option<&str>, + verbose: bool, +) -> Result<()> { + let mut args = vec!["nextest", "run", "--workspace", "--no-fail-fast"]; + + if profile == BuildProfile::Release { + args.push("--release"); + } + + // Add filter expression if provided + let filter_value = filter_expr.map(str::to_string); + if let Some(ref value) = filter_value { + args.push("-E"); + args.push(value); + } + + if verbose { + eprintln!("Running: cargo {}", args.join(" ")); + } + + cmd!(sh, "cargo {args...}").run().context("Tests failed")?; + Ok(()) +} + +/// Copy the nextest `JUnit` XML results to the given output path. +fn copy_nextest_results(output: &Path) -> Result<()> { + let workspace_root = find_workspace_root()?; + let source = workspace_root.join("target/nextest/default/test-results.xml"); + std::fs::copy(&source, output).with_context(|| { + format!( + "Failed to copy nextest results from {} to {}", + source.display(), + output.display() + ) + })?; + eprintln!("Nextest results copied to: {}", output.display()); + Ok(()) +} + +/// Run tests with code coverage collection using `cargo-llvm-cov`. +/// +/// The coverage workflow: +/// 1. Source environment variables from `cargo llvm-cov show-env` +/// 2. Clean previous coverage data +/// 3. Run tests (continuing even if tests fail to still generate report) +/// 4. Generate Cobertura XML report for CI integration +/// +/// Requires `cargo-llvm-cov` to be installed: `cargo install cargo-llvm-cov` +fn run_tests_with_coverage( + sh: &Shell, + profile: BuildProfile, + filter_expr: Option<&str>, + output: &Path, + verbose: bool, +) -> Result<()> { + let output_path = output.display().to_string(); + + eprintln!("Running tests with coverage ({profile:?} profile)..."); + eprintln!("Coverage output: {output_path}"); + + // Set up llvm-cov environment + eprintln!("Setting up llvm-cov environment..."); + if verbose { + eprintln!("Running: cargo llvm-cov show-env --export-prefix"); + } + let env_output = cmd!(sh, "cargo llvm-cov show-env --export-prefix") + .read() + .context("Failed to get llvm-cov environment. Is cargo-llvm-cov installed?")?; + + // Parse and set environment variables from llvm-cov output + env_output + .lines() + .filter_map(|line| line.strip_prefix("export ")) + .filter_map(|rest| rest.split_once('=')) + .for_each(|(k, v)| sh.set_var(k, v.trim_matches(['"', '\'']))); + + // Clean previous coverage data + if verbose { + eprintln!("Running: cargo llvm-cov clean --workspace"); + } + cmd!(sh, "cargo llvm-cov clean --workspace") + .run() + .context("Failed to clean coverage data")?; + + // Build cargo nextest args + let mut test_args = vec!["nextest", "run", "--workspace", "--no-fail-fast"]; + if profile == BuildProfile::Release { + test_args.push("--release"); + } + + // Add filter expression if provided + let filter_value = filter_expr.map(str::to_string); + if let Some(ref value) = filter_value { + test_args.push("-E"); + test_args.push(value); + } + + if verbose { + eprintln!("Running: cargo {}", test_args.join(" ")); + } + + // Run tests - let output pass through naturally, but continue on failure to generate coverage + // report + let test_result = cmd!(sh, "cargo {test_args...}").run(); + let test_failed = test_result.is_err(); + + if test_failed { + eprintln!("Tests failed, but continuing to generate coverage report..."); + } + + // Generate coverage report (always attempt this) + eprintln!("Generating coverage report..."); + if verbose { + eprintln!("Running: cargo llvm-cov report --cobertura --output-path {output_path}"); + } + cmd!( + sh, + "cargo llvm-cov report --cobertura --output-path {output_path}" + ) + .run() + .context("Failed to generate coverage report")?; + + eprintln!("Coverage report written to: {output_path}"); + + // Now propagate test failure if tests failed + if test_failed { + anyhow::bail!("Tests failed (coverage report was still generated)"); + } + + eprintln!("Tests with coverage completed successfully."); + Ok(()) +} + +/// List available bash-completion tests without running them. +fn list_bash_completion_tests(sh: &Shell, args: &BashCompletionArgs, verbose: bool) -> Result<()> { + eprintln!("Collecting bash-completion tests..."); + + // Determine test targets - specific files or all tests + let test_targets: Vec = if args.file.is_empty() { + vec!["./t".to_string()] + } else { + args.file + .iter() + .map(|f| { + if f.starts_with("./t/") || f.starts_with("t/") { + f.clone() + } else { + format!("./t/{f}") + } + }) + .collect() + }; + + let mut pytest_args = vec!["--collect-only".to_string(), "-q".to_string()]; + + // Add test filter if specified + if let Some(filter) = &args.test_filter { + pytest_args.push("-k".to_string()); + pytest_args.push(filter.clone()); + } + + // Add test targets + pytest_args.extend(test_targets); + + if verbose { + eprintln!("Running: pytest {}", pytest_args.join(" ")); + } + + // Run pytest --collect-only and display results + cmd!(sh, "pytest").args(&pytest_args).run()?; + + Ok(()) +} + +/// Run the bash-completion project's test suite against brush. +/// +/// This runs pytest on the bash-completion test suite with brush as the shell, +/// configured via the `BASH_COMPLETION_TEST_BASH` environment variable. +/// Results are output as JSON and optionally summarized to markdown. +/// +/// Requires: +/// - A checkout of the bash-completion repository +/// - Python with pytest, pytest-xdist, and pytest-json-report installed +fn run_bash_completion_tests( + sh: &Shell, + args: &BashCompletionArgs, + binary_args: &BinaryArgs, + verbose: bool, +) -> Result<()> { + // Find the brush binary (use explicit path or auto-detect from target dir) + let brush_path = binary_args.find_brush_binary()?; + + let test_dir = args.bash_completion_path.join("test"); + if !test_dir.exists() { + anyhow::bail!( + "bash-completion test directory not found at: {}", + test_dir.display() + ); + } + + // Build the pytest command + let dir_guard = sh.push_dir(&test_dir); + + // Set environment variable for the test suite + let brush_path_str = brush_path.display().to_string(); + let _env = sh.push_env( + "BASH_COMPLETION_TEST_BASH", + format!("{brush_path_str} --noprofile --no-config --input-backend=basic"), + ); + + // Handle --list mode: just collect and display tests + if args.list { + return list_bash_completion_tests(sh, args, verbose); + } + + eprintln!("Running bash-completion test suite..."); + eprintln!("Using brush binary: {}", brush_path.display()); + + // Determine test targets - specific files or all tests + let test_targets: Vec = if args.file.is_empty() { + vec!["./t".to_string()] + } else { + args.file + .iter() + .map(|f| { + if f.starts_with("./t/") || f.starts_with("t/") { + f.clone() + } else { + format!("./t/{f}") + } + }) + .collect() + }; + + // Build pytest args + let mut pytest_args: Vec = Vec::new(); + + // Add parallel execution flag if jobs > 1 (requires pytest-xdist) + if args.jobs > 1 { + pytest_args.push("-n".to_string()); + pytest_args.push(args.jobs.to_string()); + } + + // Add JSON report if output is requested (requires pytest-json-report) + let json_output = args.output.as_ref().map(|p| p.display().to_string()); + if let Some(ref output) = json_output { + pytest_args.push("--json-report".to_string()); + pytest_args.push(format!("--json-report-file={output}")); + } + + // Add optional flags + if verbose { + pytest_args.push("-v".to_string()); + } + if args.stop_on_first { + pytest_args.push("-x".to_string()); + } + if let Some(filter) = &args.test_filter { + pytest_args.push("-k".to_string()); + pytest_args.push(filter.clone()); + } + + // Add test targets at the end + pytest_args.extend(test_targets); + + if verbose { + eprintln!("Running: pytest {}", pytest_args.join(" ")); + } + + // Run pytest - pass stdout/stderr through directly, capture whether it failed. + let pytest_failed = cmd!(sh, "pytest").args(&pytest_args).run().is_err(); + + if pytest_failed { + eprintln!("Some tests failed, but continuing to generate reports..."); + } + + // Generate summary report if requested (requires JSON output) + if let (Some(summary_path), Some(output)) = (&args.summary_output, &json_output) { + // Get workspace root for script path resolution + let workspace_root = find_workspace_root()?; + + let summary_path_str = summary_path.display().to_string(); + + // Determine the script path - use provided path or default to workspace root + let script_path = args + .summary_script + .clone() + .unwrap_or_else(|| workspace_root.join("scripts/summarize-pytest-results.py")); + + let script_path_str = script_path.display().to_string(); + + // Go back to original directory for the script (if we were in test dir) + drop(dir_guard); + + let title = "Test Summary: bash-completion test suite"; + if verbose { + eprintln!("Running: python3 {script_path_str} -r {output} --title \"{title}\""); + } + let summary_result = cmd!(sh, "python3 {script_path_str}") + .args(["-r", output, "--title", title]) + .read(); + + match summary_result { + Ok(summary) => { + sh.write_file(summary_path, &summary)?; + eprintln!("Summary report written to: {summary_path_str}"); + } + Err(e) => { + eprintln!("Warning: Failed to generate summary report: {e}"); + } + } + } else if args.summary_output.is_some() && json_output.is_none() { + eprintln!("Warning: --summary-output requires --output for JSON results"); + } + + eprintln!("bash-completion test suite completed."); + if let Some(ref output) = json_output { + eprintln!("Results written to: {output}"); + } + + // Propagate test failure after reports are generated + if pytest_failed { + anyhow::bail!("bash-completion tests failed (reports were still generated)"); + } + + Ok(()) +} diff --git a/local/recipes/shells/brush/source/zizmor.yml b/local/recipes/shells/brush/source/zizmor.yml new file mode 100644 index 0000000000..99d82a3b39 --- /dev/null +++ b/local/recipes/shells/brush/source/zizmor.yml @@ -0,0 +1,4 @@ +rules: + unpinned-uses: + config: + policies: