+
+
+
+`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
+```
+
+
+
+
+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