Merge v3-t85: one Cargo workspace (host, vfs, userland, vendored brush), idiomatic flake (nix-gitignore, one crane artifact), no git in derivations (T85)

This commit is contained in:
2026-11-12 01:33:06 +00:00
866 changed files with 141009 additions and 14091 deletions
+7
View File
@@ -0,0 +1,7 @@
# WASI tools share the startup object built by userland/cwd-init/build.nu.
[env]
SAUSAGE_CWD_INIT = { value = "userland/cwd-init/init.o", relative = true }
[target.wasm32-wasip2]
linker = "userland/cwd-init/link.nu"
rustflags = ["-C", "link-arg=sausage-cwd-init"]
+2 -1
View File
@@ -3,8 +3,9 @@ target/
/world
/result
# Lock files of test crates resolve against the workspace lock.
# The root lock covers the tools. Isolated test guests keep disposable locks.
**/tests/**/Cargo.lock
/userland/**/Cargo.lock
# Cargo homes that the devshell creates where it is entered.
.cargo-home/
+7 -4
View File
@@ -36,7 +36,8 @@ This branch (`v3`) rebuilds Sausage. The plan and its decisions (D1, D2, …) ar
`host/src/exec.rs` is the contract between the harness and the process table.
- `vfs/`: `sausage-vfs`, the virtual filesystem for WASI guests and for host code. It knows nothing
about Sausage. Host code reads and writes the world only through it.
- `userland/`: the default CLIs of a world, a separate workspace built for `wasm32-wasip2`.
- `userland/`: the default CLIs of a world, built for `wasm32-wasip2` in the root Cargo workspace.
The patched Brush crates live in `userland/brush/source/` and share the root lock file.
- `spikes/`: accepted prototypes. Read them for evidence, copy from them, do not build on them.
## Programs in the world
@@ -48,9 +49,11 @@ This branch (`v3`) rebuilds Sausage. The plan and its decisions (D1, D2, …) ar
## Checks
```sh
nix develop -c cargo test --workspace # host and vfs
nix develop -c cargo clippy --workspace --all-targets -- -D warnings
nix develop -c cargo build --manifest-path userland/Cargo.toml --release
nix develop -c cargo test -p sausage -p sausage-vfs
nix develop -c cargo clippy -p sausage -p sausage-vfs --all-targets -- -D warnings
nix develop -c nu userland/bundle.nu
# For one tool, select its package and WASI target explicitly:
nix develop -c cargo build -p git --target wasm32-wasip2 --release
```
`nix flake check` checks the packaged host, its bundle, and sandbox-compatible tests.
Generated
+6702 -108
View File
File diff suppressed because it is too large Load Diff
+158 -7
View File
@@ -1,16 +1,167 @@
[workspace]
resolver = "3"
members = ["host", "vfs"]
members = [
"host",
"vfs",
"userland/git",
"userland/coreutils",
"userland/procps",
"userland/cwd-init/probe",
"userland/ripgrep",
"userland/diffutils",
"userland/jaq",
"userland/sed",
"userland/grep",
"userland/fd",
"userland/spawn",
"userland/findutils",
"userland/exec-tools",
"userland/ssh-client",
"userland/ssh",
"userland/curl",
"userland/init",
"userland/box",
"userland/archive-tools",
"userland/fs-tools",
"userland/undo",
"userland/cargo",
"userland/web",
"userland/brush/source/brush",
"userland/brush/source/brush-shell",
"userland/brush/source/brush-parser",
"userland/brush/source/brush-core",
"userland/brush/source/brush-clap-adapter",
"userland/brush/source/brush-winnow-args-adapter",
"userland/brush/source/brush-builtins",
"userland/brush/source/brush-coreutils-builtins",
"userland/brush/source/brush-experimental-builtins",
"userland/brush/source/brush-interactive",
"userland/brush/source/brush-test-harness",
"userland/brush/source/brush-bundled-tests",
"userland/brush/source/fuzz",
"userland/brush/source/xtask",
]
default-members = ["host"]
# Reference spikes and the separate wasm32-wasip2 userland workspace.
exclude = ["spikes", "userland"]
exclude = ["spikes"]
[workspace.package]
authors = ["reuben olinsky"]
categories = [
"command-line-utilities",
"development-tools",
]
edition = "2024"
keywords = [
"cli",
"shell",
"sh",
"bash",
"script",
]
license = "MIT"
readme = "userland/brush/source/README.md"
repository = "https://github.com/reubeno/brush"
rust-version = "1.88.0"
[workspace.lints.rust]
unnameable_types = "deny"
unsafe_op_in_unsafe_fn = "deny"
unused_attributes = "deny"
unused_lifetimes = "deny"
unused_macro_rules = "deny"
[workspace.lints.rust.warnings]
level = "deny"
[workspace.lints.rust.future_incompatible]
level = "deny"
[workspace.lints.rust.missing_docs]
level = "deny"
[workspace.lints.rust.nonstandard_style]
level = "deny"
[workspace.lints.rust.rust_2018_idioms]
level = "deny"
priority = -1
[workspace.lints.rust.unknown_lints]
level = "allow"
priority = -100
[workspace.lints.rustdoc.all]
level = "deny"
priority = -1
[workspace.lints.clippy]
expect_used = "deny"
format_push_string = "deny"
multiple_unsafe_ops_per_block = "deny"
panic = "deny"
panic_in_result_fn = "deny"
string_lit_chars_any = "deny"
string_slice = "deny"
tests_outside_test_module = "deny"
todo = "deny"
unwrap_in_result = "deny"
unwrap_used = "deny"
undocumented_unsafe_blocks = "deny"
bool_to_int_with_if = "allow"
cognitive_complexity = "allow"
collapsible_else_if = "allow"
collapsible_if = "allow"
if_not_else = "allow"
if_same_then_else = "allow"
match_same_arms = "allow"
missing_errors_doc = "allow"
must_use_candidate = "allow"
multiple_crate_versions = "allow"
option_if_let_else = "allow"
redundant_closure_for_method_calls = "allow"
redundant_else = "allow"
redundant_pub_crate = "allow"
result_large_err = "allow"
similar_names = "allow"
struct_excessive_bools = "allow"
[workspace.lints.clippy.all]
level = "deny"
priority = -1
[workspace.lints.clippy.pedantic]
level = "deny"
priority = -1
[workspace.lints.clippy.cargo]
level = "deny"
priority = -1
[workspace.lints.clippy.nursery]
level = "deny"
priority = -1
[workspace.lints.clippy.perf]
level = "deny"
priority = -1
# Line tables only: backtraces keep file and line, binaries stay small (each test
# binary links V8, Wasmtime and deno_core). Set `debug = true` when you need a debugger.
[profile.dev]
debug = "line-tables-only"
# Wasmtime compiles components with Cranelift. Optimize dependencies in dev
# builds, so that tests and debug runs start guests quickly.
[profile.dev.package."*"]
opt-level = 2
[patch.crates-io.gix-tempfile]
path = "userland/git/vendor/gix-tempfile"
[patch.crates-io.gix-pack]
path = "userland/git/vendor/gix-pack"
[patch.crates-io.gix-index]
path = "userland/git/vendor/gix-index"
[patch.crates-io.gix-odb]
path = "userland/git/vendor/gix-odb"
[patch.crates-io.filetime]
path = "userland/git/vendor/filetime"
+2 -2
View File
@@ -33,7 +33,7 @@ The `/api/` mount forwards to `host.sausage`, including NDJSON streams. The host
- `host/`: the Rust host, process table, and durable harness. Its checked-in JavaScript bundles need no npm during a Nix build.
- `vfs/`: the shared virtual filesystem and WASI adapters.
- `userland/`: the default tools, in a separate `wasm32-wasip2` workspace. `bundle.nu` builds and assembles them.
- `userland/`: the default tools and patched Brush source, in the root Cargo workspace. `bundle.nu` builds them for `wasm32-wasip2`.
- `spikes/`: reference prototypes, not production dependencies.
The design and decisions live in `~/research/sausage-v3/PLAN.md`.
@@ -42,7 +42,7 @@ The design and decisions live in `~/research/sausage-v3/PLAN.md`.
```sh
nix develop
cargo test --workspace
cargo test -p sausage -p sausage-vfs
nu userland/bundle.nu
nix flake check
```
+49 -110
View File
@@ -1,6 +1,5 @@
{
description = "Sausage: a world of WASI programs and durable agents";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
flake-utils.url = "github:numtide/flake-utils";
@@ -12,51 +11,28 @@
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { self, nixpkgs, flake-utils, rust-overlay, crane, kache, ... }:
outputs = { nixpkgs, flake-utils, rust-overlay, crane, kache, ... }:
flake-utils.lib.eachSystem flake-utils.lib.allSystems (system:
let
pkgs = import nixpkgs {
inherit system;
overlays = [ rust-overlay.overlays.default ];
};
pkgs = import nixpkgs { inherit system; overlays = [ rust-overlay.overlays.default ]; };
rust = pkgs.rust-bin.stable.latest.default.override {
targets = [ "wasm32-wasip2" ];
extensions = [ "rust-src" ];
};
craneLib = (crane.mkLib pkgs).overrideToolchain rust;
src = pkgs.nix-gitignore.gitignoreSource [] ./.;
mold = pkgs.lib.optionals pkgs.stdenv.hostPlatform.isLinux [ pkgs.mold ];
hostTarget = pkgs.lib.toUpper (builtins.replaceStrings [ "-" ] [ "_" ]
pkgs.stdenv.hostPlatform.rust.rustcTarget);
hostTarget = pkgs.lib.toUpper (builtins.replaceStrings [ "-" ] [ "_" ] pkgs.stdenv.hostPlatform.rust.rustcTarget);
moldVar = "CARGO_TARGET_${hostTarget}_RUSTFLAGS";
moldEnv = pkgs.lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux {
${moldVar} = "-C link-arg=-fuse-ld=mold";
};
moldEnv = pkgs.lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux { ${moldVar} = "-C link-arg=-fuse-ld=mold"; };
builderTools = [ kache.packages.${system}.default pkgs.bubblewrap pkgs.nushell pkgs.wasm-tools pkgs.bash pkgs.coreutils ];
runtimePath = pkgs.lib.makeBinPath ([ rust pkgs.stdenv.cc ] ++ builderTools ++ mold);
# A WASI clang with wasi-libc, so that build scripts (the `cc` crate)
# can compile C for wasm32-wasip2. Its headers are WASI 0.1: C code
# that needs sockets does not build.
wasiCc = pkgs.pkgsCross.wasi32.stdenv.cc;
wasiCcEnv = {
CC_wasm32_wasip2 = "${wasiCc}/bin/${wasiCc.targetPrefix}cc";
AR_wasm32_wasip2 = "${wasiCc}/bin/${wasiCc.targetPrefix}ar";
};
craneLib = (crane.mkLib pkgs).overrideToolchain rust;
# Preserve the embedded JavaScript and WIT alongside Cargo's sources.
hostSrc = pkgs.lib.cleanSourceWith {
src = ./.;
filter = path: type:
let relative = pkgs.lib.removePrefix (toString ./. + "/") path;
in !((pkgs.lib.hasPrefix "userland/" relative && !(pkgs.lib.hasPrefix "userland/cwd-init" relative))
|| pkgs.lib.hasPrefix "spikes/" relative)
&& (craneLib.filterCargoSources path type
|| pkgs.lib.hasSuffix ".wit" path || pkgs.lib.hasSuffix ".witx" path
|| pkgs.lib.hasSuffix ".js" path || pkgs.lib.hasSuffix ".html" path
|| pkgs.lib.hasSuffix ".wasm" path || pkgs.lib.hasSuffix ".nu" path
|| pkgs.lib.hasSuffix ".c" path || pkgs.lib.hasSuffix ".patch" path);
};
v8Archive = pkgs.fetchurl {
# deno_core enables rusty_v8's simdutf feature.
url = "https://github.com/denoland/rusty_v8/releases/download/v150.4.0/librusty_v8_simdutf_release_${pkgs.stdenv.hostPlatform.rust.rustcTarget}.a.gz";
hash = {
x86_64-linux = "sha256-9IdiyhDR8fxgWkQcWuQw7Izh6egPFNePvELLh4wwtHY=";
@@ -65,73 +41,53 @@
aarch64-darwin = "sha256-Wu/9jVoMG3msHXCvg9WxkJllX9nGRaeU3EPxAfd5g4w=";
}.${system};
};
# rusty_v8's documented archive hook prevents downloads inside the Nix sandbox.
v8Env = { RUSTY_V8_ARCHIVE = v8Archive; };
cwdInit = pkgs.runCommand "sausage-cwd-init" { nativeBuildInputs = [ rust pkgs.nushell pkgs.wasm-tools ]; } ''
cp -r ${./userland/cwd-init} cwd-init
chmod -R u+w cwd-init
nu cwd-init/build.nu
mkdir $out
cp cwd-init/init.o $out/
'';
linker = pkgs.writeScript "sausage-wasi-link.nu" ("#!${pkgs.nushell}/bin/nu\n" + builtins.readFile ./userland/cwd-init/link.nu);
members = (builtins.fromTOML (builtins.readFile ./Cargo.toml)).workspace.members;
commands = builtins.filter (p: pkgs.lib.hasPrefix "userland/" p && !(pkgs.lib.hasPrefix "userland/brush/" p)) members;
commandArgs = pkgs.lib.concatMapStringsSep " " (p: "-p " + (builtins.fromTOML (builtins.readFile (./. + "/${p}/Cargo.toml"))).package.name) commands;
common = {
pname = "sausage";
version = "0.3.0";
src = hostSrc;
inherit src;
strictDeps = true;
RUSTY_V8_ARCHIVE = v8Archive;
buildInputs = [ pkgs.openssl ];
nativeBuildInputs = [ pkgs.pkg-config pkgs.cmake pkgs.python3 pkgs.perl ] ++ mold;
} // moldEnv;
nativeBuildInputs = [ pkgs.pkg-config pkgs.cmake pkgs.python3 pkgs.perl pkgs.nushell pkgs.wasm-tools pkgs.go ] ++ mold;
SAUSAGE_CWD_INIT = "${cwdInit}/init.o";
CARGO_TARGET_WASM32_WASIP2_LINKER = linker;
CARGO_TARGET_WASM32_WASIP2_RUSTFLAGS = "-C link-arg=sausage-cwd-init";
GOTOOLCHAIN = "local";
} // v8Env // wasiCcEnv // moldEnv;
cargoArtifacts = craneLib.buildDepsOnly (common // {
# Registry gix crates need the real APIs of the local WASI patches.
extraDummyScript = ''
chmod -R u+w $out/userland/git/vendor
cp -r ${./userland/git/vendor}/. $out/userland/git/vendor/
'';
buildPhaseCargoCommand = ''
cargoWithProfile build --locked -p sausage -p sausage-vfs
cargoWithProfile build --locked --target wasm32-wasip2 ${commandArgs}
cargoWithProfile build --locked --target wasm32-wasip2 -p brush-shell --no-default-features --features minimal,tokio/time
'';
doCheck = false;
});
brushSource = pkgs.fetchFromGitHub {
owner = "reubeno";
repo = "brush";
rev = "943553433ae04f5b3bd55d2cf207e39eb9ef2af7";
hash = "sha256-a1eMkqx4nBqJ8R+h+rxtU35pAungcEnXoxwZnb1poH8=";
};
# bundle.nu archives these sources, not the sandbox's development patches.
userlandSrc = pkgs.lib.cleanSource ./.;
revision = self.rev or self.dirtyRev or "unknown";
userlandCommon = {
userland = craneLib.mkCargoDerivation (common // {
inherit cargoArtifacts;
pname = "sausage-userland";
version = "0.3.0";
src = userlandSrc;
strictDeps = true;
cargoVendorDir = craneLib.vendorMultipleCargoDeps {
cargoLockList = [ ./userland/Cargo.lock ./userland/brush/Cargo.lock ];
};
nativeBuildInputs = [ pkgs.nushell pkgs.wasm-tools pkgs.git pkgs.pkg-config pkgs.go ] ++ mold;
GOTOOLCHAIN = "local";
GIT_AUTHOR_NAME = "Sausage";
GIT_AUTHOR_EMAIL = "sausage@localhost";
GIT_COMMITTER_NAME = "Sausage";
GIT_COMMITTER_EMAIL = "sausage@localhost";
GIT_AUTHOR_DATE = "2000-01-01T00:00:00+0000";
GIT_COMMITTER_DATE = "2000-01-01T00:00:00+0000";
postPatch = ''
git init --quiet --initial-branch=main
git add host vfs userland agents skills extensions Cargo.toml Cargo.lock flake.nix flake.lock README.md AGENTS.md .gitignore
git commit --no-gpg-sign --quiet -m 'Bundle source ${revision}'
cp -r ${brushSource} userland/brush/source
chmod -R u+w userland/brush/source
# The fixed-output source fetch replaces the development checkout's Git check.
substituteInPlace userland/brush/build.nu --replace-fail \
${pkgs.lib.escapeShellArg "if (^git -C $source rev-parse HEAD | str trim) != $revision { error make {msg: 'unexpected brush revision'} }"} \
'# Revision verified by the fixed-output Nix fetch.'
patchShebangs userland/cwd-init/link.nu
'';
preBuild = ''
export CARGO_TARGET_DIR="$PWD/target"
export GOCACHE="$TMPDIR/go-cache"
'';
buildPhaseCargoCommand = ''
nu userland/bundle.nu
# bundle.nu sees the reconstructed repository's HEAD. Restore the flake revision.
git -C target/userland/bundle/src/sausage commit --amend --no-gpg-sign --quiet \
-m 'Bundle source ${revision}'
'';
buildPhaseCargoCommand = "nu userland/bundle.nu";
doCheck = false;
} // wasiCcEnv // moldEnv;
# Keep the real local patch crates: registry dependencies use their full APIs.
userlandArtifacts = craneLib.buildDepsOnly (userlandCommon // {
dummySrc = userlandSrc;
});
userland = craneLib.mkCargoDerivation (userlandCommon // {
cargoArtifacts = userlandArtifacts;
installPhaseCommand = ''
mkdir -p $out
cp -a target/userland/bundle/. $out/
@@ -141,17 +97,15 @@
test -f $out/etc/systemd/system/multi-user.target.wants/web.service
'';
doInstallCargoArtifacts = false;
# The upstream browser fixture links to an optional, unbuilt artifact.
dontCheckForBrokenSymlinks = true;
});
sausageUnwrapped = craneLib.buildPackage (common // {
inherit cargoArtifacts;
# Deno loads extension TypeScript from the vendored sources at runtime.
doNotRemoveReferencesToVendorDir = true;
cargoExtraArgs = "--locked -p sausage";
doCheck = false;
});
# A separate wrapper, so that crane's toolchain reference removal cannot rewrite its PATH.
# Keep the wrapper separate from crane's removal of toolchain references.
sausage = pkgs.symlinkJoin {
name = "sausage-${sausageUnwrapped.version}";
paths = [ sausageUnwrapped ];
@@ -168,32 +122,22 @@
in {
packages.default = sausage;
checks.package = sausage;
checks.tests = craneLib.mkCargoDerivation (common // wasiCcEnv // {
checks.tests = craneLib.mkCargoDerivation (common // {
inherit cargoArtifacts;
pnameSuffix = "-test";
nativeBuildInputs = common.nativeBuildInputs ++ [ pkgs.git ];
doCheck = true;
buildPhaseCargoCommand = "";
# Keep the same sandbox-compatible modules in the shared host test executable.
checkPhaseCargoCommand = ''
cargoWithProfile test --locked --workspace --lib
cargoWithProfile test --locked -p sausage --test it -- \
harness:: p1:: durable:: cred:: cred_ --skip undo_harness::
cargoWithProfile test --locked -p sausage-vfs \
--test layers --test regressions --test streams --test wasi
cargoWithProfile test --locked -p sausage -p sausage-vfs --lib
cargoWithProfile test --locked -p sausage --test it -- harness:: p1:: durable:: cred:: cred_ --skip undo_harness::
cargoWithProfile test --locked -p sausage-vfs --test layers --test regressions --test streams --test wasi
'';
preCheck = ''
export SAUSAGE_USERLAND="${userland}"
export SAUSAGE_CACHE="$TMPDIR/sausage-cache"
export CARGO_TARGET_DIR="$PWD/target"
export RUST_BACKTRACE=1
# Flake sources omit Git history. Map the recorded revision to independently
# staged source trees for P1's directory-hash comparison.
cp -r ${userlandSrc}/agents ${userlandSrc}/skills ${userlandSrc}/extensions .
git init --quiet
git add agents skills extensions
git -c user.name=Sausage -c user.email=sausage@localhost \
commit --no-gpg-sign --quiet -m 'Flake source trees'
git update-ref refs/replace/${revision} HEAD
mkdir -p target/userland
ln -s ${userland} target/bundle
ln -s ${userland} target/userland/bundle
@@ -203,23 +147,18 @@
checks.shell = pkgs.runCommand "sausage-shell" { } ''
export SAUSAGE_CACHE="$TMPDIR/sausage-cache"
${sausage}/bin/sausage sh "$TMPDIR/world" -c \
'ls /bin | wc -l; echo hi | tr a-z A-Z; git --version; rg --version' > output
'set -e; echo hi | tr a-z A-Z; git --version; rg --version; awk "BEGIN {print 42}"; echo payload > /payload; tar -cf /test.tar /payload; tar -tf /test.tar; qjs -e "console.log(42)"; box --help; cargo --help; undo list' > output
cat output
grep -qx HI output
grep -q 'git version' output
grep -q ripgrep output
${sausage}/bin/sausage sh "$TMPDIR/world" -c \
'set -e; awk "BEGIN {print 42}"; echo payload > /payload; tar -cf /test.tar /payload; tar -tf /test.tar; qjs -e "console.log(42)"; box --help; cargo --help; undo list' > tools
cat tools
grep -qx 42 tools
grep -q payload tools
grep -qx 42 output
grep -q payload output
test -d "$TMPDIR/world/world/src/sausage/extensions"
test -d "$TMPDIR/world/world/src/sausage/agents"
test -d "$TMPDIR/world/world/src/sausage/skills"
touch $out
'';
apps.default = { type = "app"; program = "${sausage}/bin/sausage"; };
devShells.default = pkgs.mkShell (wasiCcEnv // moldEnv // {
devShells.default = pkgs.mkShell (v8Env // wasiCcEnv // moldEnv // {
packages = [ rust pkgs.wasmtime pkgs.openssl pkgs.pkg-config ] ++ builderTools ++ mold;
});
});
+2 -2
View File
@@ -1,4 +1,4 @@
//! Shared fixture copies and builds. A mutex protects cwd-init and the brush checkout.
//! Shared fixture copies and builds. A mutex protects cwd-init and the shell build.
use std::{
hash::{Hash, Hasher},
os::unix::{ffi::OsStrExt, fs::PermissionsExt},
@@ -165,7 +165,7 @@ pub fn build_git() -> &'static Path {
"sausage-archive-tools",
"--manifest-path"
])
.arg(userland.join("Cargo.toml"))
.arg(userland.join("../Cargo.toml"))
.args(["--target", "wasm32-wasip2", "--target-dir"])
.arg(&target)
.args(["-j", "2", "--locked"])
+1 -1
View File
@@ -53,7 +53,7 @@ async fn commits_and_restart() -> anyhow::Result<()> {
"sausage-archive-tools",
"--manifest-path"
])
.arg(userland.join("Cargo.toml"))
.arg(userland.join("../Cargo.toml"))
.args(["--target", "wasm32-wasip2", "--target-dir"])
.arg(target)
.args(["-j", "2", "--locked"])
+1 -1
View File
@@ -30,7 +30,7 @@ fn prepare(dir: &Path) -> anyhow::Result<()> {
"sausage-archive-tools",
"--manifest-path"
])
.arg(userland.join("Cargo.toml"))
.arg(source.join("Cargo.toml"))
.args(["--target", "wasm32-wasip2", "--target-dir"])
.arg(target)
.args(["-j", "2", "--locked"])
+1 -1
View File
@@ -63,7 +63,7 @@ fn sigterm_cancels_tool_and_resumes() -> anyhow::Result<()> {
"sleep",
"--manifest-path",
])
.arg(source.join("userland/Cargo.toml"))
.arg(source.join("Cargo.toml"))
.args(["--target", "wasm32-wasip2", "--target-dir"])
.arg(target)
.args(["-j", "2"])
+1 -1
View File
@@ -15,7 +15,7 @@ fn guests() -> &'static Path {
static BUILT: OnceLock<PathBuf> = OnceLock::new();
BUILT.get_or_init(|| {
let target = PathBuf::from(std::env::var("CARGO_TARGET_DIR").unwrap());
let manifest = Path::new(env!("CARGO_MANIFEST_DIR")).join("../userland/Cargo.toml");
let manifest = Path::new(env!("CARGO_MANIFEST_DIR")).join("../Cargo.toml");
assert!(
std::process::Command::new("cargo")
.args([
+13 -4
View File
@@ -4,6 +4,9 @@ use sausage_vfs::Open;
use std::path::PathBuf;
fn bundle() -> anyhow::Result<PathBuf> {
if let Some(bundle) = std::env::var_os("SAUSAGE_USERLAND") {
return Ok(PathBuf::from(bundle));
}
let repo = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..");
let bundle = crate::common::target_dir().join("bundle");
let _guard = crate::common::BUILD.lock().unwrap();
@@ -59,10 +62,16 @@ async fn bundle_copy_and_real_shell() -> anyhow::Result<()> {
let message = git(&snapshot, &["log", "-1", "--format=%s"])?;
assert_eq!(message, "Initial world source");
for path in ["config", "skills", "extensions"] {
assert_eq!(
git(&snapshot, &["rev-parse", &format!("HEAD:{path}")])?,
git(&repo, &["rev-parse", &format!("HEAD:{path}")])?,
"bundled {path} must match the source commit"
let difference = std::process::Command::new("git")
.args(["diff", "--no-index", "--exit-code", "--"])
.arg(repo.join(path))
.arg(snapshot.join(path))
.output()?;
assert!(
difference.status.success(),
"bundled {path} must match the source files: {}{}",
String::from_utf8_lossy(&difference.stdout),
String::from_utf8_lossy(&difference.stderr),
);
}
use std::os::unix::fs::MetadataExt;
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env nu
# Verify the packaged host's toolchain and namespace builder outside the Nix sandbox.
def main [host: path = 'result/bin/sausage'] {
let host = ($host | path expand)
let temporary = (^mktemp -d -t sausage-packaged.XXXXXX | str trim)
let world = ($temporary | path join world)
print $'Fresh world: ($world)'
let initialized = (^$host init $world | complete)
print --no-newline $initialized.stdout
print --stderr --no-newline $initialized.stderr
if $initialized.exit_code != 0 { error make {msg: 'packaged host init failed'} }
let script = r#'set -e
mkdir -p /src/projects
cd /src/projects
cargo new packaged-probe --vcs none
printf 'fn main() { println!("packaged builder OK"); }\n' > packaged-probe/src/main.rs
cd packaged-probe
cargo build --release
./target/wasm32-wasip2/release/packaged-probe.wasm
'#
let result = (^$host sh $world -c $script | complete)
print --no-newline $result.stdout
print --stderr --no-newline $result.stderr
if $result.exit_code != 0 { error make {msg: 'packaged builder failed'} }
if not ($result.stdout | lines | any {|line| $line == 'packaged builder OK' }) {
error make {msg: 'compiled program did not produce its expected output'}
}
rm --recursive --force $temporary
print 'Packaged builder check passed.'
}
-9
View File
@@ -1,9 +0,0 @@
[build]
target = "wasm32-wasip2"
[env]
SAUSAGE_CWD_INIT = { value = "cwd-init/init.o", relative = true }
[target.wasm32-wasip2]
linker = "cwd-init/link.nu"
rustflags = ["-C", "link-arg=sausage-cwd-init"]
-7040
View File
File diff suppressed because it is too large Load Diff
-15
View File
@@ -1,15 +0,0 @@
# The default CLIs of a world, built for wasm32-wasip2. Its own workspace, outside the host's.
[workspace]
resolver = "3"
members = ["git", "coreutils", "procps", "cwd-init/probe", "ripgrep", "diffutils", "jaq", "sed", "grep", "fd", "spawn", "findutils", "exec-tools", "ssh-client", "ssh", "curl", "init", "box", "archive-tools", "fs-tools", "undo", "cargo", "web"]
# WASI compatibility patches: git/vendor/PATCHES.md.
[patch.crates-io]
gix-tempfile = { path = "git/vendor/gix-tempfile" }
gix-pack = { path = "git/vendor/gix-pack" }
gix-index = { path = "git/vendor/gix-index" }
gix-odb = { path = "git/vendor/gix-odb" }
filetime = { path = "git/vendor/filetime" }
[profile.release]
opt-level = 3
-1
View File
@@ -1 +0,0 @@
/source/
-6739
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
# The world shell
`source/` contains Brush at upstream revision `943553433ae04f5b3bd55d2cf207e39eb9ef2af7` from <https://github.com/reubeno/brush>.
The upstream MIT license and notices remain in that directory.
All Brush crates belong to the repository's root Cargo workspace.
The root `Cargo.lock` covers their dependencies.
The checked-in Sausage changes replace the WASI process launcher and path lookup.
They use the process interface in `brush-core/sausage-process/`.
The process launcher uses a timer so that host tasks can supply filesystem and stream data.
The shell also records its pinned source revision without a Git command during compilation.
The root manifest supplies the upstream workspace metadata and lint settings.
Build the shell from the repository root:
```sh
nu userland/cwd-init/build.nu
nu userland/brush/build.nu
```
`bundle.nu` invokes the same build and installs the result as `sh` and `bash`.
The build does not fetch, copy, or patch source files.
+4 -38
View File
@@ -1,44 +1,10 @@
#!/usr/bin/env nu
# Build the pinned brush source with only its WASI launcher seam replaced.
def copy-if-changed [source: path, destination: path] {
if not ($destination | path exists) or (open --raw $source) != (open --raw $destination) {
cp $source $destination
}
}
# Build the checked-in shell from the shared workspace for WASI.
def main [] {
let here = $env.FILE_PWD
let source = ($here | path join source)
let revision = '943553433ae04f5b3bd55d2cf207e39eb9ef2af7'
if not ($source | path exists) {
^git clone --no-checkout https://github.com/reubeno/brush $source
if $env.LAST_EXIT_CODE != 0 { error make {msg: 'brush clone failed'} }
^git -C $source checkout $revision
if $env.LAST_EXIT_CODE != 0 { error make {msg: 'brush checkout failed'} }
}
if (^git -C $source rev-parse HEAD | str trim) != $revision { error make {msg: 'unexpected brush revision'} }
let core = ($source | path join brush-core)
copy-if-changed ($here | path join process.rs) ($core | path join src sys wasm process.rs)
copy-if-changed ($here | path join lookup.rs) ($core | path join src sys wasm sausage-lookup.rs)
let fs = ($core | path join src sys wasm fs.rs)
let fs_text = (open --raw $fs)
if not ($fs_text | str contains 'include!("sausage-lookup.rs")') {
$fs_text + "\ninclude!(\"sausage-lookup.rs\");\n" | save --force $fs
}
mkdir ($core | path join sausage-process)
let wit = ($here | path join .. .. host wit | path expand)
copy-if-changed ($wit | path join process.wit) ($core | path join sausage-process process.wit)
mkdir ($core | path join sausage-process deps)
copy-if-changed ($wit | path join deps io.wit) ($core | path join sausage-process deps io.wit)
let manifest = ($core | path join Cargo.toml)
let text = (open --raw $manifest)
let marker = "[target.'cfg(target_family = \"wasm\")'.dependencies]"
if not ($text | str contains 'wit-bindgen =') {
$text | str replace $marker ($marker + "\nwit-bindgen = \"0.46\"") | save -f $manifest
}
copy-if-changed ($here | path join Cargo.lock) ($source | path join Cargo.lock)
$env.SAUSAGE_CWD_INIT = ($here | path join .. cwd-init init.o | path expand)
$env.CARGO_TARGET_WASM32_WASIP2_LINKER = ($here | path join .. cwd-init link.nu | path expand)
$env.SAUSAGE_CWD_INIT = ($env.SAUSAGE_CWD_INIT? | default ($here | path join .. cwd-init init.o | path expand))
$env.CARGO_TARGET_WASM32_WASIP2_LINKER = ($env.CARGO_TARGET_WASM32_WASIP2_LINKER? | default ($here | path join .. cwd-init link.nu | path expand))
$env.CARGO_TARGET_WASM32_WASIP2_RUSTFLAGS = '-C link-arg=sausage-cwd-init'
^cargo build --manifest-path ($source | path join Cargo.toml) -p brush-shell --release --features minimal,tokio/time --no-default-features --target wasm32-wasip2 --locked
^cargo build --manifest-path ($here | path join .. .. Cargo.toml) -p brush-shell --release --features minimal,tokio/time --no-default-features --target wasm32-wasip2 --locked
if $env.LAST_EXIT_CODE != 0 { error make {msg: 'brush build failed'} }
}
+10
View File
@@ -0,0 +1,10 @@
[alias]
xtask = "run --package xtask --"
[target.wasm32-unknown-unknown]
# Select a getrandom backend that will work for this target
rustflags = ["--cfg", 'getrandom_backend="wasm_js"']
[target.wasm32-wasip3]
# Uninteresting flag to ensure this overrides the flags in [build]
rustflags = ["--cfg", "x"]
+9
View File
@@ -0,0 +1,9 @@
# Commits not to include in changelog
a8ba7982d9ca3746cd6f6e3c26783e9d1d765466
448e60c51cb721bd6991c00bc39e926c7a1ffdac
6db7ffa0d3ccfbf1e757d5a4eb096fd51d65cc19
5400c8267db15ed56b5758a6fc35667678a5c856
87ad64f9ae2044d1aa70effd50244491a765c33e
6567ede8f0a9be622176dc5e840e5b93344aeb15
@@ -0,0 +1,21 @@
id: platform-cfg-outside-sys
valid:
- "#[cfg(test)] fn f() {}"
- '#[cfg(feature = "unix")] fn f() {}'
- "// #[cfg(unix)]"
- "fn f() -> &'static str { std::env::consts::OS }"
invalid:
- "#![cfg(windows)]"
- "#[cfg(unix)] fn f() {}"
- '#[cfg(all(test, target_os = "linux"))] fn f() {}'
- '#[cfg(not(target_family = "wasm"))] fn f() {}'
- '#[cfg(target_pointer_width = "64")] fn f() {}'
- "#[cfg_attr(unix, derive(Debug))] struct S;"
- "struct S { #[cfg(windows)] x: u8 }"
- "fn f(v: u8) { match v { #[cfg(unix)] 0 => {} _ => {} } }"
- "fn f() { #[cfg(windows)] let x = 1; }"
- 'fn f() -> bool { cfg!(target_os = "macos") }'
- "fn f() { cfg_select! { windows => {} _ => {} } }"
- "cfg_if::cfg_if! { if #[cfg(unix)] { fn f() {} } }"
- "macro_rules! m { () => { #[cfg(unix)] fn f() {} } }"
- "fn f() { vec![#[cfg(unix)] 1]; }"
@@ -0,0 +1,40 @@
# Platform-specific code lives in brush_core::sys, so the rest of brush-core
# stays platform-neutral. Outside sys, platform code can only exist behind a
# target cfg -- otherwise some cross-compiled CI target fails to build -- so
# flagging those cfgs is enough to keep it out.
#
# rustc's target cfgs are `unix`, `windows`, and the `target_*` keys
# (target_os, target_family, target_arch, ...); every other platform is a value
# of one of those keys. Runtime checks such as `std::env::consts::OS` aren't
# cfgs and aren't covered.
id: platform-cfg-outside-sys
language: rust
severity: error
message: Platform-specific code belongs in brush_core::sys.
note: Move it behind an API in brush_core::sys.
files: ["brush-core/src/**/*.rs"]
ignores: ["brush-core/src/sys.rs", "brush-core/src/sys/**"]
utils:
platform-predicate:
kind: token_tree
has:
kind: identifier
regex: ^(unix|windows|target_[a-z_]+)$
stopBy: end
rule:
any:
# #[cfg(...)], #![cfg(...)], #[cfg_attr(...)]
- kind: attribute
all:
- has: { kind: identifier, regex: ^cfg(_attr)?$ }
- has: { matches: platform-predicate }
# cfg!(...), cfg_select! { ... }
- kind: macro_invocation
all:
- has: { field: macro, regex: (^|::)(cfg|cfg_select)$ }
- has: { matches: platform-predicate }
# #[cfg(...)] inside a macro body (cfg_if!, macro_rules!, ...), where it's
# unparsed tokens rather than an attribute
- kind: token_tree
regex: ^\[\s*cfg(_attr)?\s*\(
has: { matches: platform-predicate }
@@ -0,0 +1,8 @@
[profile.default]
retries = 3
status-level = "slow"
slow-timeout = { period = "5s", terminate-after = 3, grace-period = "2s" }
fail-fast = false
[profile.default.junit]
path = "test-results.xml"
@@ -0,0 +1,55 @@
FROM ubuntu:latest
ARG USERNAME=devel
ARG USER_UID=1000
ARG USER_GID=$USER_UID
# Install basic utilities and prerequisites.
# bash-completion - for completion testing
# bsdmainutils - provides hexdump, used by integration tests
# neovim - for convenience and modern editing
RUN apt-get update -y && \
apt-get install -y \
bash \
bash-completion \
bsdmainutils \
build-essential \
curl \
git \
iputils-ping \
language-pack-en \
neovim \
sed \
sudo \
wget
# Install gh cli
# Reference: https://github.com/cli/cli/blob/trunk/docs/install_linux.md
RUN mkdir -p -m 755 /etc/apt/keyrings && \
wget -qO- https://cli.github.com/packages/githubcli-archive-keyring.gpg | tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null && \
chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg && \
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null && \
apt-get update -y && \
apt-get install -y gh
# Add a non-root user that we'll do our best to use for development.
RUN userdel ubuntu && \
groupadd --gid $USER_GID $USERNAME && \
useradd --uid $USER_UID --gid $USER_GID -m $USERNAME && \
echo "devel ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers
# Switch to user.
USER devel
# Set up path to include rust components.
ENV PATH="${PATH}:/home/devel/.cargo/bin"
# Copy scripts to temp dir.
WORKDIR /tmp
# Install rust toolchain and cargo tools.
COPY install-rust-tools.sh .
RUN ./install-rust-tools.sh
# Switch back to home dir for normal usage.
WORKDIR /home/devel
@@ -0,0 +1,18 @@
{
"build": {
"dockerfile": "./Dockerfile",
"context": "."
},
"customizations": {
"vscode": {
"extensions": [
"rust-lang.rust-analyzer",
"tamasfe.even-better-toml"
]
}
},
"features": {},
"postCreateCommand": "rustup component add llvm-tools-preview",
"remoteUser": "devel",
"updateRemoteUserUID": true
}
+21
View File
@@ -0,0 +1,21 @@
#!/bin/bash
set -euo pipefail
# Install rustup and needed components
# Reference: https://rustup.rs/
curl https://sh.rustup.rs -sSf | sh -s -- -y
rustup component add llvm-tools-preview
# Install cargo binstall
# Reference: https://github.com/cargo-bins/cargo-binstall
curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash
# Install cargo tools using binstall
cargo binstall --no-confirm cargo-audit
# cargo binstall --no-confirm cargo-flamegraph
cargo binstall --no-confirm cargo-llvm-cov
cargo binstall --no-confirm cargo-nextest
# prek runs the linters pinned in .pre-commit-config.yaml; `cargo xtask ci full`
# needs it for the pre-commit hooks step.
cargo binstall --no-confirm prek
@@ -0,0 +1,86 @@
name: Compatibility Bug Report
description: Report a case where brush behaves differently from bash
labels: ["bug", "area: compat"]
body:
- type: markdown
attributes:
value: |
Thanks for helping improve brush's compatibility!
This template is for reporting cases where brush produces different behavior than bash for the same input. Before submitting, please [search existing issues](https://github.com/reubeno/brush/issues) to check if this has already been reported.
- type: input
id: brush-version
attributes:
label: brush version
description: The version of brush you're using. You can find this with `brush --version`.
placeholder: "e.g., brush 0.2.9"
validations:
required: true
- type: input
id: bash-version
attributes:
label: bash version (for comparison)
description: The version of bash you compared against, if applicable. You can find this with `bash --version`.
placeholder: "e.g., GNU bash, version 5.2.21(1)-release"
validations:
required: false
- type: dropdown
id: platform
attributes:
label: Platform / OS
options:
- Linux
- macOS
- WSL
- Windows
- Other
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Steps to reproduce
description: >
Provide the shell commands, script, or other usage details that
reproduced the issue. If you can narrow it down to a minimal
reproducing example, that's very helpful but not required.
placeholder: |
# Example:
brush -c 'some command'
validations:
required: true
- type: textarea
id: actual-behavior
attributes:
label: Actual behavior (brush)
description: What did brush do in this case?
placeholder: Describe or paste the brush output...
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: Expected behavior
description: >
What did you expect to happen? If relevant, what does bash or another
comparable shell do in this case?
placeholder: Describe or paste the expected output or behavior
validations:
required: true
- type: textarea
id: additional-context
attributes:
label: Additional context
description: >
Is there anything else you think is important to share? You might consider
including additional environment specifics (e.g., relevant .bashrc config),
workarounds, related issues, any additional debugging you've performed, etc.
validations:
required: false
@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Questions & Discussion
url: https://github.com/reubeno/brush/discussions
about: Ask questions, share ideas, or discuss brush in GitHub Discussions.
@@ -0,0 +1,52 @@
name: Feature Request
description: Suggest a new feature or enhancement for brush
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to suggest an improvement to brush! We welcome and appreciate all input and feedback. While we can't commit to prioritizing or implementing all such requests, we'd rather you make the suggestion than not.
Our only requests: (1) please ensure you follow our project's [Code of Conduct](https://github.com/reubeno/brush#coc-ov-file), and (2) before submitting, please [search existing issues](https://github.com/reubeno/brush/issues) to check if a similar request already exists. If it does, feel free to add a comment to the existing issue instead.
- type: textarea
id: description
attributes:
label: Feature description
description: A clear and concise description of the feature or enhancement you'd like.
placeholder: Describe the feature...
validations:
required: true
- type: textarea
id: use-case
attributes:
label: Use case / motivation
description: |
Why is this feature important to you? What problem does it solve? How would you use it?
Providing context helps us understand the value and prioritize accordingly.
placeholder: Explain why this feature would be useful...
validations:
required: true
- type: textarea
id: proposed-solution
attributes:
label: Proposed solution
description: >
If you have ideas on how this could be implemented or how it should work,
share them here. Note that this is purely optional.
placeholder: Describe how you think this could work...
validations:
required: false
- type: textarea
id: additional-context
attributes:
label: Additional context
description: >
Any other context, references, or screenshots that would help us understand your request.
For example, links to relevant documentation, other shells that implement this, etc.
validations:
required: false
+19
View File
@@ -0,0 +1,19 @@
name: Other Issue
description: "Report an issue that doesn't fit the other templates"
labels: ["state: triage"]
body:
- type: markdown
attributes:
value: |
If your issue is a **feature request**, please use the [Feature Request](https://github.com/reubeno/brush/issues/new?template=feature-request.yml) template instead. If it's about **brush behaving differently from bash** or another compatibility issue, please use the [Compatibility Bug Report](https://github.com/reubeno/brush/issues/new?template=compatibility-bug.yml) template instead.
For general questions or discussion, consider using [GitHub Discussions](https://github.com/reubeno/brush/discussions).
- type: textarea
id: description
attributes:
label: Description
description: Please describe the issue in detail.
placeholder: Describe the issue...
validations:
required: true
+405
View File
@@ -0,0 +1,405 @@
# GitHub Copilot Coding Agent Instructions for brush
## Project Overview
**brush** (Bourne Rusty Shell) is a POSIX- and bash-compatible shell implemented in Rust. It's a multi-crate workspace (~60K lines of Rust code) targeting Linux, macOS, and WSL, with experimental Windows and WASM support. The project emphasizes compatibility testing against bash as an oracle.
**Key Stats:** Rust 2024 edition, 5 main crates, 1500+ compatibility test cases, published to crates.io. The workspace MSRV is the `rust-version` under `[workspace.package]` in the root `Cargo.toml`; the application-tier crates (brush, brush-shell, brush-interactive, brush-fuzz) declare a higher one in their own manifests. See `docs/reference/msrv-policy.md`.
## Critical: Read AGENTS.md First
**BEFORE making any changes, read `/AGENTS.md`.** It contains detailed architecture patterns, testing workflows, and development guidelines specific to this project. The information below supplements (not replaces) AGENTS.md.
Before investigating or implementing, also read the canonical
[independent-implementation and provenance policy](../CONTRIBUTING.md#independent-implementation-and-provenance)
required by `AGENTS.md`. Apply it to delegated agents and their tools.
## Code Review Checklist
When reviewing PRs, verify:
- [ ] **Documentation**: All exported APIs have rustdoc comments (missing docs = CI failure)
- [ ] **Forbidden patterns**: No `panic`, `unwrap_in_result`, `expect_used`, or `todo` (all denied by clippy)
- [ ] **Error handling**: Uses `thiserror` for crate errors; `anyhow` only in tests
- [ ] **Logging**: Uses `tracing::debug!(target: trace_categories::CATEGORY, "msg")` pattern
- [ ] **Testing**: Compatibility fixes include YAML test cases in `brush-shell/tests/cases/`
- [ ] **Testing**: Builtin changes have tests in `brush-shell/tests/cases/builtin/`
- [ ] **Testing**: Unit tests expected for new public APIs (when feasible) (see AGENTS.md section 2)
- [ ] **Platform code**: Platform-specific code is in `brush-core/src/sys/` modules
- [ ] **Breaking changes**: Public API changes are clearly highlighted and documented
- [ ] **Builder pattern**: Configuration uses builder pattern (see `Shell::builder()`)
- [ ] **Code quality**: Passes `cargo fmt --check` and `cargo clippy` without warnings
- [ ] **Commit format**: Follows [Conventional Commits](https://www.conventionalcommits.org/) (feat:, fix:, docs:, test:)
- [ ] **Dependencies**: No unnecessary cloning (use references when possible)
- [ ] **Cross-platform**: Uses appropriate `cfg(unix)`, `cfg(windows)`, `cfg(target_family = "wasm")`
## Workspace Structure
```
brush/
├── brush-shell/ # CLI application & main entry point
├── brush-interactive/ # Interactive shell (readline, completion)
├── brush-core/ # Core shell runtime & builtins
├── brush-builtins/ # Shell builtin implementations
├── brush-parser/ # AST generation & parsing
├── xtask/ # Build automation tasks
└── docs/ # Diátaxis-structured documentation
```
**Dependency flow:** brush-shell → brush-interactive → brush-core → brush-parser
↘ brush-builtins ↗
## Build & Validation Commands
### Terminal Command Execution
When running commands that may take more than a few seconds (cargo build, cargo check, cargo test, cargo clippy, cargo xtask, etc.), **run them in background mode and poll for results** rather than blocking. This prevents commands from being cancelled due to timeouts. Use `isBackground: true` with `run_in_terminal`, then use `get_terminal_output` to check results.
### Using xtask (Recommended)
The project provides a `cargo xtask` command that centralizes common development tasks. This is the recommended approach for running checks and tests.
#### Quick Development Cycle
```bash
# Run quick inner-loop checks (~7s warm): fmt, build, lint, unit tests
cargo xtask ci quick
# Run the full workflow (~60s warm): quick + pre-commit hooks, schemas, integration tests
cargo xtask ci full
# Run with --continue-on-error to see all failures at once
cargo xtask ci full -k
# Add -v for verbose output showing exact commands being run
cargo xtask -v ci full
```
`cargo xtask ci quick` needs only the Rust toolchain. `cargo xtask ci full` also
needs `prek` on `PATH` (see CONTRIBUTING.md for install commands). If `prek` is
unavailable in your environment, run `cargo xtask ci full --no-hooks`; CI
still runs the hooks.
#### Individual Checks
```bash
# Format check
cargo xtask check fmt
# Lint check (clippy)
cargo xtask check lint
# Pre-commit hooks (file hygiene, typos, zizmor, lychee, cargo-deny)
cargo xtask check hooks
# One hook, by id or alias
cargo xtask check hooks deps
# Build check
cargo xtask check build
# Schema check (regenerates and diffs)
cargo xtask check schemas
```
#### Running Tests
```bash
# Run unit tests (fast tests excluding integration binaries)
cargo xtask test unit
# Run integration tests (all workspace tests including compat tests)
cargo xtask test integration
# Run tests with coverage
cargo xtask test integration --coverage --coverage-output codecov.xml
```
### Manual Approach (Alternate)
For finer-grained control or when xtask isn't available:
#### Quick Development Cycle (Use These Frequently)
```bash
# Fast syntax/type checking (< 5 seconds)
cargo check --workspace
# Package-specific checking (even faster)
cargo check --package brush-core
# Format code (ALWAYS run before committing)
cargo fmt --all
# Lint code (ALWAYS run before committing)
cargo clippy --workspace --all-features --all-targets
# Run package-specific tests (fast iteration)
cargo test --package brush-parser
cargo test --package brush-core
```
### Comprehensive Testing Workflow
Follow this **exact order** for efficient testing:
1. **Inner loop** (during development):
```bash
cargo check --package <changed-package>
cargo test --package <changed-package>
```
2. **Compatibility tests** (critical for shell behavior):
```bash
cargo test --test brush-compat-tests
# Run specific test case:
cargo test --test brush-compat-tests -- 'builtin/echo'
```
3. **Full workspace tests** (before considering work complete):
```bash
cargo test --workspace
```
**Test timing:** Package tests: 3-20 seconds. Compat tests: ~18 seconds build + test time. Full workspace: several minutes.
### Pre-Commit Validation (Before Every Commit)
**Recommended:** Run the xtask quick workflow:
```bash
cargo xtask ci quick
```
**Manual approach:** Run these before every commit:
```bash
cargo fmt --check --all
cargo clippy --workspace --all-features --all-targets
```
### Pre-PR Validation (Before Opening Pull Request)
**Recommended:** Run the full workflow, which includes the full test suite:
```bash
cargo xtask ci full
```
**Manual approach:** In addition to the full workflow, also run:
```bash
cargo test --workspace
```
### Pre-Finish Quality Gates (Run Before Completing Task)
**Recommended:** Run the xtask full workflow, which covers all essential checks:
```bash
cargo xtask ci full
```
**Manual approach:**
```bash
cargo test --test brush-compat-tests
cargo clippy --workspace --all-features --all-targets
cargo fmt --check --all
cargo test --workspace
cargo xtask check hooks # typos, zizmor, lychee, cargo-deny, file hygiene (run LAST, not frequently)
```
**Timing note:** `cargo xtask check hooks` takes a few seconds once its hook environments are cached; the first run builds cargo-deny from source. Only run as a final validation step, and skip it with `cargo xtask ci full --no-hooks` if `prek` is unavailable.
### Build Variants
```bash
# Standard debug build
cargo build
# Release build (takes ~2+ minutes, avoid during iteration)
cargo build --release
# Check all targets and features
cargo check --all-features --all-targets
```
## Testing Philosophy
**Test-driven approach:** When fixing bugs or adding features, write test cases in `brush-shell/tests/cases/*.yaml` BEFORE implementation. Use these to validate your changes.
**Integration test structure:** Tests are YAML-based, run shell commands, compare stdout/stderr/exit codes against bash oracle. See `docs/reference/integration-testing.md` and AGENTS.md section 2 for detailed testing strategy.
**Test categories:**
- Unit tests: In-file with `#[cfg(test)]`
- Integration tests: `brush-shell/tests/` directory
- Compatibility tests: YAML cases in `brush-shell/tests/cases/`
- Benchmarks: `brush-shell/benches/` and crate-level `benches/`
## Common Pitfalls & Solutions
### ❌ Don't Do This
- Run full test suite on every change (too slow)
- Skip `cargo fmt` and `cargo clippy` before committing
- Run `cargo xtask check hooks` during development iteration (it is a pre-PR check)
- Clone values unnecessarily (use references)
- Add breaking changes to public APIs without highlighting them
- Forget to add compat test cases for compatibility fixes
### ✅ Do This
- Target specific packages/tests during development
- Run fmt/clippy before every commit
- Follow builder pattern for configuration (see `Shell::builder()`)
- Keep platform-specific code in `brush-core/src/sys/`
- Document all exported APIs with rustdoc
- Use `tracing::debug!(target: trace_categories::CATEGORY, "msg")` for logging
- Add test cases to `brush-shell/tests/cases/` for compatibility changes
## Error Handling & Logging
```rust
// Use thiserror for crate-specific errors
use thiserror::Error;
#[derive(Error, Debug)]
pub enum MyError { ... }
// Use anyhow ONLY in tests
#[cfg(test)]
use anyhow::Result;
// Logging with trace categories
use crate::trace_categories;
tracing::debug!(target: trace_categories::COMMANDS, "executing: {}", cmd);
```
**Available trace categories:** COMMANDS, COMPLETION, EXPANSION, FUNCTIONS, INPUT, JOBS, PARSE, PATTERN, UNIMPLEMENTED
## Linting Configuration
The project uses **extremely strict** linting (workspace-level in `Cargo.toml`):
- All Rust warnings denied
- All clippy warnings denied (pedantic, cargo, nursery, perf)
- `expect_used`, `panic`, `todo`, `unwrap_in_result` are **forbidden**
- Missing docs on exported items are errors
**Your code MUST pass `cargo clippy` without warnings.**
## Cross-Platform Considerations
- Primary targets: Linux (x86_64, aarch64), macOS (aarch64)
- Secondary: Windows (x86_64), WASM (wasm32-unknown-unknown, wasm32-wasip3)
- Platform-specific code goes in `brush-core/src/sys/` modules
- Use `cfg(unix)`, `cfg(windows)`, `cfg(target_family = "wasm")` appropriately
- See `.cargo/config.toml` for target-specific configurations
## CI Pipeline (What Will Run on Your PR)
GitHub Actions runs these checks (from `.github/workflows/`):
1. **Build** on multiple platforms (x86_64/aarch64 Linux, macOS, Windows, WASM)
2. **Tests** on Linux x86_64, Linux aarch64, macOS
3. **Static checks** (format, clippy) on stable + the workspace MSRV; the MSRV leg excludes the application-tier crates
4. **Pre-commit hooks** (`cargo xtask check hooks`: file hygiene, typos, zizmor, lychee, cargo-deny), in their own workflow
5. **Compatibility tests** with bash as oracle
6. **Code coverage** reports (70% overall threshold, no 5% negative delta)
7. **External test suites** (bash-completion test suite)
8. **OS compatibility** (Arch, Debian, Fedora, NixOS, openSUSE)
9. **Benchmarks** (performance regression detection on PRs)
10. **Public API analysis** (breaking change detection)
**All of these must pass for PR to merge.**
## Making Changes
### Editing Core Shell Behavior
1. Check `brush-core/src/shell.rs` for `Shell` struct
2. Use `Shell::builder()` for construction
3. Update `brush-shell/src/main.rs` if CLI changes needed
### Adding/Modifying Builtins
1. Edit files in `brush-builtins/src/`
2. Register in `brush-builtins/src/factory.rs`
3. Add test cases in `brush-shell/tests/cases/builtin/`
### Parser Changes
1. Modify `brush-parser/src/`
2. Update AST definitions
3. Test with `cargo test --package brush-parser`
### Breaking Changes Policy
- Avoid breaking public APIs (all crate exports are public)
- If unavoidable, highlight clearly and document thoroughly
- New optional fields on public structs are OK if struct implements `Default`
- See AGENTS.md section 3 for complete breaking change policy
## Performance & Benchmarking
```bash
# Run benchmarks (using xtask)
cargo xtask analyze bench
# Run benchmarks with output file
cargo xtask analyze bench --output benchmarks.txt
# Run benchmarks (manual)
cargo bench --workspace --benches
# Collect flamegraphs (10 second profiling)
cargo bench --workspace --benches -- --profile-time 10
# Output: target/criterion/<benchmark_name>/profile/*.svg
```
**Note:** Performance regression testing runs automatically on PRs. Don't worry about it unless working on performance-specific features.
## Documentation Standards
**Rustdoc:** REQUIRED for all exported types, functions, traits, modules. Missing docs = CI failure.
**Examples:** Only needed for major feature additions.
**Style:** Follow Rust documentation best practices.
## Commit Messages
Use [Conventional Commits](https://www.conventionalcommits.org/):
```
feat: add support for X
fix: correct behavior of Y
docs: update Z documentation
test: add test cases for W
```
## AI-Assisted Contributions
If using AI assistance significantly, add to PR description or commit message:
```
Assisted-by: GitHub Copilot
```
## When Something Fails
1. **Test failures:** Focus on affected area first, check if new tests are needed
2. **Format/clippy failures:** Fix immediately before proceeding
3. **Compat test failures:** Indicates shell behavior change, may need test updates
4. **Build failures:** Check dependencies, verify the toolchain satisfies the `rust-version` of the crate being built
5. **Timeout issues:** Build from scratch can take 2+ minutes
## Quick Reference
| Command | When | Time |
|---------|------|------|
| `cargo xtask ci quick` | Rapid iteration (inner loop) | ~7s |
| `cargo xtask ci full` | Before opening a PR (comprehensive) | ~60s |
| `cargo xtask ci full -k` | See all failures at once | ~60s |
| `cargo check` | Constantly during dev | ~3-5s |
| `cargo test --package X` | After each change | 3-20s |
| `cargo xtask test unit` | Fast unit tests | ~4.5s |
| `cargo xtask test integration` | All workspace tests | ~36s |
| `cargo xtask check fmt` | Before every commit | <1s |
| `cargo xtask check lint` | Before commit | ~5-10s |
| `cargo xtask check hooks` | Final validation only | ~1-5s once cached |
## Trust These Instructions
Only search the codebase if information here or in AGENTS.md is incomplete, contradictory, or proven incorrect. These instructions are validated against the actual working repository.
+62
View File
@@ -0,0 +1,62 @@
version: 2
updates:
- package-ecosystem: "github-actions"
cooldown:
default-days: 7
directory: "/"
schedule:
interval: "weekly"
groups:
github-actions:
patterns:
- "*"
- package-ecosystem: "cargo"
cooldown:
default-days: 7
directory: "/"
schedule:
interval: "weekly"
groups:
cargo:
patterns:
- "*"
# .pre-commit-config.yaml is the single place typos, zizmor, lychee, and the
# file-hygiene hooks are pinned, so this is what keeps those linters current.
- package-ecosystem: "pre-commit"
cooldown:
default-days: 7
directory: "/"
schedule:
interval: "weekly"
groups:
pre-commit:
patterns:
- "*"
# rust-toolchain.toml pins an exact toolchain so rustfmt and clippy behave the
# same everywhere; this keeps that pin at the latest stable release.
- package-ecosystem: "rust-toolchain"
cooldown:
default-days: 7
directory: "/"
schedule:
interval: "weekly"
# Base images of the e2e adapters (e2e/*/Dockerfile). The applications they test are pinned
# by ARGs with checksums, which Dependabot cannot update.
- package-ecosystem: "docker"
cooldown:
default-days: 7
directories:
- "/e2e/*"
schedule:
interval: "weekly"
ignore:
# Upstream's rolling test image, deliberately unpinned (see e2e/bash-completion/Dockerfile).
- dependency-name: "ghcr.io/scop/bash-completion/test"
groups:
e2e-docker:
patterns:
- "*"
+310
View File
@@ -0,0 +1,310 @@
#
# Based on https://github.com/release-plz/release-plz/blob/a5043c478d46d051c00e4fbc85036ac22510f07e/.github/workflows/cd.yml
#
# N.B. scripts/install/install.sh depends on details of this workflow: its path (attestations are
# verified against it), the `brush-shell-v*` release tags, the `brush-<target>.tar.gz` and
# `brush-<target>.sha256` asset names, the `brush` binary at the archive root, and the glibc
# floor of the `*-linux-gnu` builds (2.34, set by the ubuntu-22.04 runners; the script picks the
# gnu build only on glibc 2.34+ and the musl build otherwise). Changing any of these breaks the
# install script for new releases; update it (and its tests) to match.
#
# Pushes to `main` also publish canary builds to ghcr.io/reubeno/brush-canary (see the
# publish-canary job), which `install.sh --canary` installs from.
#
name: CD # Continuous Deployment
run-name: CD${{ github.event_name == 'release' && ' (release)' || (github.event_name == 'push' && github.repository == 'reubeno/brush') && ' (canary)' || ' (dry run)' }}
on:
release:
types: [published]
# Manual triggers don't actually publish but dry-run the builds.
workflow_dispatch: null
# Run on PR in dry-run mode to make sure this workflow is still generally
# working.
pull_request:
branches: ["main"]
# Run on pushes into `main` to publish canary builds.
push:
branches: ["main"]
# Only allow one run of the workflow per branch / PR at a time.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
CARGO_INCREMENTAL: 0
CARGO_NET_GIT_FETCH_WITH_CLI: true
CARGO_NET_RETRY: 10
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
RUSTFLAGS: -D warnings
RUSTUP_MAX_RETRIES: 10
defaults:
run:
shell: bash
permissions: {}
jobs:
upload-docs:
name: "Generate and upload documentation"
permissions:
contents: write
id-token: write
attestations: write
if: github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request' || github.event_name == 'push' || (github.event_name == 'release' && github.repository_owner == 'reubeno' && startsWith(github.event.release.tag_name, 'brush-shell-v'))
runs-on: ubuntu-26.04
timeout-minutes: 30
steps:
- name: "Checkout repository"
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: "Install Rust toolchain"
run: rustup toolchain install --profile minimal
- name: "Generate documentation distribution"
run: cargo xtask gen docs dist --out brush-docs.tar.gz
- name: "Upload documentation to release"
if: github.event_name == 'release'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_RELEASE_TAG: ${{ github.event.release.tag_name }}
run: |
gh release upload ${GITHUB_RELEASE_TAG} \
brush-docs.tar.gz \
brush-docs.tar.gz.sha256 \
brush-docs.tar.gz.sha512
- name: "Generate artifact attestation"
if: github.event_name == 'release'
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-path: "brush-docs.tar.gz*"
- name: "Upload documentation artifact (dry-run)"
if: github.event_name != 'release'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: brush-docs
path: |
brush-docs.tar.gz
brush-docs.tar.gz.sha256
brush-docs.tar.gz.sha512
upload-assets:
name: ${{ matrix.target }}
permissions:
contents: write
id-token: write
attestations: write
if: github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request' || github.event_name == 'push' || (github.event_name == 'release' && github.repository_owner == 'reubeno' && startsWith(github.event.release.tag_name, 'brush-shell-v'))
runs-on: ${{ matrix.os }}
strategy:
# Run all jobs to completion regardless of errors.
# This is useful because sometimes we fail to compile for a certain target.
fail-fast: false
matrix:
include:
- target: aarch64-pc-windows-msvc
os: windows-2025
force_dry_run: false
- target: x86_64-unknown-linux-gnu
os: ubuntu-22.04
force_dry_run: false
- target: x86_64-apple-darwin
os: macos-26-intel
force_dry_run: false
- target: x86_64-pc-windows-msvc
os: windows-2025
force_dry_run: false
- target: x86_64-unknown-linux-musl
os: ubuntu-26.04
force_dry_run: false
- target: aarch64-unknown-linux-gnu
os: ubuntu-22.04
force_dry_run: false
- target: aarch64-unknown-linux-musl
os: ubuntu-26.04
force_dry_run: false
- target: aarch64-apple-darwin
os: macos-26
force_dry_run: false
timeout-minutes: 60
steps:
- name: "Checkout repository"
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: "Install Rust toolchain"
uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2.0.0
with:
cache: false
# The cross ARM64 musl linker wrapper emits shell traces as linker warnings.
# See https://github.com/cross-rs/cross/issues/1618.
build-warnings: ${{ matrix.target == 'aarch64-unknown-linux-musl' && 'warn' || 'deny' }}
- name: "Setup cross-compiling toolchain"
if: startsWith(matrix.os, 'ubuntu') && !contains(matrix.target, '-musl')
uses: taiki-e/setup-cross-toolchain-action@12b7ad4acfa95a1476779d6c06699b96ec1691f8 # v1.42.0
with:
target: ${{ matrix.target }}
- name: "Install musl cross tools"
if: contains(matrix.target, '-musl')
uses: taiki-e/install-action@68a389c788b2cd04016ce8c80ee23b80d2ab3d68 # v2.88.1
with:
tool: cross
- name: "Workaround: clean cache"
run: cargo clean
- name: "Install cargo-about"
run: cargo install cargo-about --locked --features cli
- name: "Generate license notices"
working-directory: brush-shell
run: cargo about generate -o ../THIRD_PARTY_LICENSES.html about.hbs
- name: "Update build flags"
if: endsWith(matrix.target, 'windows-msvc')
run: echo "RUSTFLAGS=${RUSTFLAGS} -C target-feature=+crt-static" >> "${GITHUB_ENV}"
- name: "Build and upload binaries to release"
uses: taiki-e/upload-rust-binary-action@f0d45ae91ee7b8ee928de7a9d04d893a08bcbec6 # v1.30.2
id: upload-release
env:
# Canary builds of `main` say so in `brush --version`.
BRUSH_VERSION_SUFFIX: ${{ github.event_name == 'push' && '+canary' || '' }}
# cross only forwards environment variables it's told about into its build container.
CROSS_BUILD_ENV_PASSTHROUGH: BRUSH_VERSION_SUFFIX
with:
dry-run: ${{ github.event_name != 'release' || matrix.force_dry_run }}
bin: brush
locked: true
target: ${{ matrix.target }}
tar: unix
zip: windows
checksum: sha256,sha512
token: ${{ secrets.GITHUB_TOKEN }}
include: "LICENSE,THIRD_PARTY_LICENSES.html"
- name: "Generate artifact attestation"
# Canary builds (pushes to `main`) are attested too, for `install.sh --canary` to verify.
if: (github.event_name == 'release' && !matrix.force_dry_run) || (github.event_name == 'push' && github.repository == 'reubeno/brush')
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-path: "${{ steps.upload-release.outputs.archive }}.*"
- name: "Upload artifacts (dry-run)"
if: github.event_name != 'release' || matrix.force_dry_run
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: brush-${{ matrix.target }}
path: "${{ steps.upload-release.outputs.archive }}.*"
# Publishes builds of `main` to ghcr.io/reubeno/brush-canary, where `install.sh --canary` finds
# them. Each target's archive is pushed as a single-file OCI artifact tagged `<target>-<commit>`
# and then, once every target's archive is in place, `<target>` (always the newest build).
#
# N.B. install.sh depends on the package name, the tags, and each manifest having the archive
# as its only layer. It also shows users the build's revision and created annotations.
publish-canary:
name: "Publish canary builds"
needs: upload-assets
permissions:
packages: write
if: github.event_name == 'push' && github.repository == 'reubeno/brush'
runs-on: ubuntu-26.04
timeout-minutes: 15
steps:
- name: "Download build artifacts"
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
# brush-<target>, but not brush-docs.
pattern: brush-*-*
merge-multiple: true
- name: "Install ORAS"
uses: oras-project/setup-oras@5e2f2482b30b0cb3e14e67f89a80c88c659a7c63 # v2.0.3
- name: "Push to ghcr.io"
env:
PACKAGE: ghcr.io/reubeno/brush-canary
REGISTRY_TOKEN: ${{ github.token }}
run: |
oras login ghcr.io --username "${GITHUB_ACTOR}" --password-stdin <<<"${REGISTRY_TOKEN}"
created="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
targets=()
for archive in brush-*.tar.gz brush-*.zip; do
target="${archive#brush-}"
target="${target%.tar.gz}"
target="${target%.zip}"
case "${archive}" in
*.zip) media_type="application/zip" ;;
*) media_type="application/gzip" ;;
esac
oras push "${PACKAGE}:${target}-${GITHUB_SHA}" \
--artifact-type "application/vnd.reubeno.brush.canary.v1" \
--annotation "org.opencontainers.image.source=https://github.com/${GITHUB_REPOSITORY}" \
--annotation "org.opencontainers.image.revision=${GITHUB_SHA}" \
--annotation "org.opencontainers.image.created=${created}" \
--annotation "org.opencontainers.image.description=Unreleased, unstable builds of brush from main." \
"${archive}:${media_type}"
targets+=("${target}")
done
# Only now move each `<target>` tag, so it never points at a partially published build.
for target in "${targets[@]}"; do
oras tag "${PACKAGE}:${target}-${GITHUB_SHA}" "${target}"
done
# Deletes old canary builds. Kept apart from publish-canary so that nothing here can push builds.
prune-canary:
name: "Prune old canary builds"
needs: publish-canary
permissions:
packages: write
runs-on: ubuntu-26.04
timeout-minutes: 10
steps:
- name: "Delete all but the newest ~100 builds"
env:
GH_TOKEN: ${{ github.token }}
run: |
versions="/users/${GITHUB_REPOSITORY_OWNER}/packages/container/brush-canary/versions"
# Each build adds one version per target (8 of them), so keeping the newest 800 versions
# keeps about 100 builds. Versions still tagged with a bare `<target>` (the newest build
# of that target) are never deleted.
gh api --paginate --slurp "${versions}?per_page=100" |
jq -r 'add | sort_by(.created_at) | reverse | .[800:][]
| select(.metadata.container.tags | all(test("-[0-9a-f]{40}$")))
| .id' |
while read -r id; do
# GitHub refuses to delete public versions with over 5,000 downloads; don't fail over that.
gh api --method DELETE "${versions}/${id}" || echo "::warning::could not delete ${versions}/${id}"
done
+138
View File
@@ -0,0 +1,138 @@
name: "PR Reports"
on:
# We intentionally run this workflow in the context of the target of a PR; we are careful
# an intentional about how we handle the data coming from the PR.
workflow_run: # zizmor: ignore[dangerous-triggers]
workflows: ["CI"]
types:
- completed
permissions: {}
jobs:
report:
name: "Report"
permissions:
actions: read
checks: write
contents: read
pull-requests: write
runs-on: ubuntu-26.04
# We only run this job if the workflow run that completed was triggered by a pull request.
if: github.event.workflow_run.event == 'pull_request'
steps:
- name: Extract PR number and create event file
id: get-pr
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const fs = require('fs');
// Find the workflow run that triggered this job.
const workflowRun = await github.rest.actions.getWorkflowRun({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: context.payload.workflow_run.id,
});
// Extract the source commit info.
const { head_branch, head_sha, head_repository } = workflowRun.data;
const head_repo_owner = head_repository.owner.login;
core.setOutput('head_branch', head_branch);
core.setOutput('head_sha', head_sha);
core.setOutput('head_repo', head_repository.full_name);
// Try to find all PRs from that source.
const { data: prs } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
head: `${head_repo_owner}:${head_branch}`,
per_page: 20
});
// Filter PRs to find the one that targets the main branch.
// NOTE: This only supports a target branch of 'main'.
const filtered_prs = prs.filter(pr => pr.base.ref === 'main');
if (filtered_prs.length > 0) {
const pr = filtered_prs[0];
core.setOutput('pr_number', pr.number);
// Write out a mostly-stubbed event file.
const eventData = {
pull_request: {
head: {
sha: head_sha,
repo: {
full_name: head_repository.full_name
}
}
}
};
fs.writeFileSync('event-file.json', JSON.stringify(eventData), 'utf-8');
} else {
core.setOutput('pr_number', '');
}
- name: Download code coverage reports
if: steps.get-pr.outputs.pr_number != ''
continue-on-error: true
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
pattern: codecov-reports*
merge-multiple: true
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
path: reports/
- name: Download performance reports
if: steps.get-pr.outputs.pr_number != ''
continue-on-error: true
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
pattern: perf-reports*
merge-multiple: true
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
path: reports/
- name: Download test results
if: steps.get-pr.outputs.pr_number != ''
continue-on-error: true
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
pattern: test-reports*
merge-multiple: true
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
path: reports/
- name: Show published reports
continue-on-error: true
if: steps.get-pr.outputs.pr_number != ''
run: |
ls -lR reports/
- name: "Publish test results"
uses: EnricoMi/publish-unit-test-result-action@7622c5bf3d112a72ab4fa3951c2aa5723d8ab92d # v2.25.0
if: steps.get-pr.outputs.pr_number != ''
continue-on-error: true
with:
commit: ${{ github.event.workflow_run.head_sha }}
event_file: event-file.json
event_name: ${{ github.event.workflow_run.event }}
files: reports/test-results-*.xml
- name: "Publish available .md reports to PR"
uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
if: steps.get-pr.outputs.pr_number != ''
with:
path: reports/*.md
number: ${{ steps.get-pr.outputs.pr_number }}
+848
View File
@@ -0,0 +1,848 @@
name: "CI"
on:
pull_request:
paths-ignore:
- "release-plz.toml"
push:
paths-ignore:
- "docs/**"
- "**.md"
- "LICENSE"
- "release-plz.toml"
branches:
- main
env:
# Incremental compilation only pays off across repeated edit-build cycles.
# Each CI job builds once, so it would only add compile time and bloat
# target/. rust-cache sets this itself, but not every job here uses it.
CARGO_INCREMENTAL: 0
RUST_BACKTRACE: 1
CARGO_TERM_COLOR: always
CLICOLOR: 1
CLICOLOR_FORCE: 1
# The toolchain that every job needing nightly uses: matrix legs that say
# `toolchain: "nightly"` resolve to it, and check-deps uses it directly.
# (`env` isn't available in `strategy.matrix`, hence the indirection.)
#
# NIGHTLY PIN (temporary): this is nightly-2026-10-31 (rustc 92b5b8c87,
# 2026-10-29) rather than floating `nightly`. The next nightly,
# nightly-2026-11-01 (rustc f9ceb4a4d), fails to compile brush-core with
# "error[E0391]: cycle detected when computing when `ArithmeticTarget` needs
# async drop": a rustc regression on mutually recursive enums held across an
# `.await`, likely from rust-lang/rust#164987 (stable and beta are
# unaffected). Once a nightly compiles brush again, set this back to
# `nightly`. analyze-public-api doesn't use it and stays on floating
# `nightly`: rustdoc doesn't hit the regression.
NIGHTLY_TOOLCHAIN: nightly-2026-10-31
permissions:
actions: read
contents: read
# Only allow one run of the workflow per branch / PR at a time.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
# Build and upload release binaries for all relevant architectures.
build:
strategy:
fail-fast: false
matrix:
include:
# Build for x86_64/linux target on native host.
# N.B. We intentionally pin to Ubuntu 22.04 for now to increase
# the range of distros that will be able to run the produced binaries.
# Newer release of Ubuntu upgrade glibc to a point not yet supported
# by some latest stable versions of distros.
- host: "ubuntu-22.04"
target: ""
cross_command: ""
cross_tool_to_install: ""
os: "linux"
arch: "x86_64"
binary_name: "brush"
extra_build_args: ""
# Build for x86_64/linux using musl as target C runtime.
# We don't bother pinning to an earlier version of Ubuntu because
# the produced binary will statically link the C runtime anyhow.
- host: "ubuntu-26.04"
target: "x86_64-unknown-linux-musl"
cross_command: "cross"
cross_tool_to_install: "cross"
os: "linux-musl"
arch: "x86_64"
binary_name: "brush"
extra_build_args: ""
# Build for aarch64/macos target on native host.
- host: "macos-latest"
target: ""
cross_command: ""
cross_tool_to_install: ""
os: "macos"
arch: "aarch64"
required_tools: ""
binary_name: "brush"
extra_build_args: ""
# Build for aarch64/linux target on x86_64/Linux host.
- host: "ubuntu-26.04"
target: "aarch64-unknown-linux-gnu"
cross_command: "cross"
cross_tool_to_install: "cross"
os: "linux"
arch: "aarch64"
required_tools: "gcc-aarch64-linux-gnu"
binary_name: "brush"
extra_build_args: ""
# Build for wasm32 target on x86_64/linux host.
- host: "ubuntu-26.04"
target: "wasm32-unknown-unknown"
cross_command: "cross"
cross_tool_to_install: "cross"
os: "unknown"
arch: "wasm32"
required_tools: ""
binary_name: "brush.wasm"
extra_build_args: ""
# Build for WASI-0.3 target on x86_64/linux host. wasm32-wasip3 first
# ships prebuilt std in Rust 1.100; until rust-toolchain.toml is at
# 1.100+, use nightly. Drop `toolchain` once it is.
- host: "ubuntu-26.04"
target: "wasm32-wasip3"
cross_command: "cargo"
cross_tool_to_install: ""
os: "wasi-0.3"
arch: "wasm32"
required_tools: ""
binary_name: "brush.wasm"
extra_build_args: ""
toolchain: "nightly"
# Build for x86_64/windows target on x86_64/linux host.
# cross 0.2.5's windows-gnu container exits 1, without an error, on ubuntu-26.04.
- host: "ubuntu-24.04"
target: "x86_64-pc-windows-gnu"
cross_command: "cross"
cross_tool_to_install: "cross"
os: "windows"
arch: "x86_64"
required_tools: ""
binary_name: "brush.exe"
extra_build_args: ""
# Build for x86_64/android target on x86_64/linux host.
- host: "ubuntu-26.04"
target: "x86_64-linux-android"
cross_command: "cargo ndk"
cross_tool_to_install: "cargo-ndk"
os: "android"
arch: "x86_64"
required_tools: ""
binary_name: "brush"
extra_build_args: ""
# x86_64/FreeBSD target on x86_64/linux host, via cross. Compile-check
# only (check_only): cross's curated sysroot omits some base-system
# libraries (libgeom/libkvm/...) that `sysinfo` links against, so the
# final link of a real binary fails. Checking still validates that the
# shell compiles for the target; producing a binary is a TODO (would
# need a full sysroot with those libraries present).
- host: "ubuntu-26.04"
target: "x86_64-unknown-freebsd"
cross_command: "cross"
cross_tool_to_install: "cross"
os: "freebsd"
arch: "x86_64"
required_tools: ""
binary_name: "brush"
extra_build_args: ""
check_only: true
# x86_64/NetBSD target on x86_64/linux host, via cross. Compile-check
# only for the same reason as FreeBSD above.
- host: "ubuntu-26.04"
target: "x86_64-unknown-netbsd"
cross_command: "cross"
cross_tool_to_install: "cross"
os: "netbsd"
arch: "x86_64"
required_tools: ""
binary_name: "brush"
extra_build_args: ""
check_only: true
# OpenBSD is tier-3 with no prebuilt std, so even compile-checking it
# requires building std from source (-Zbuild-std, hence nightly +
# rust-src + no rustup target). panic_abort is built explicitly because
# our release profile sets panic = "abort" (plain -Zbuild-std only
# builds std + panic_unwind). Compile-check only, like the other BSDs;
# check links nothing, so no C cross-compiler/sysroot/linker is needed
# (none of the workspace's C-compiling deps are in this default graph).
- host: "ubuntu-26.04"
target: "x86_64-unknown-openbsd"
cross_command: "cargo"
cross_tool_to_install: ""
os: "openbsd"
arch: "x86_64"
required_tools: ""
binary_name: "brush"
extra_build_args: "-Zbuild-std=std,panic_abort"
toolchain: "nightly"
components: "rust-src"
# std is built from source, so there's no prebuilt target to install.
skip_rustup_target: true
check_only: true
name: "${{ matrix.check_only && 'Check' || 'Build' }} (${{ matrix.arch }}/${{ matrix.os }})"
runs-on: ${{ matrix.host }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up rust toolchain
shell: bash
env:
# Empty -- meaning the toolchain pinned in rust-toolchain.toml -- for
# every leg that doesn't set `toolchain` itself.
TOOLCHAIN: ${{ matrix.toolchain == 'nightly' && env.NIGHTLY_TOOLCHAIN || matrix.toolchain }}
TARGET: ${{ !matrix.skip_rustup_target && matrix.target || '' }}
COMPONENTS: ${{ matrix.components }}
run: |
if [[ -n "${TOOLCHAIN}" ]]; then
rustup toolchain install "${TOOLCHAIN}" --profile minimal
# A directory override takes precedence over rust-toolchain.toml.
rustup override set "${TOOLCHAIN}"
else
rustup toolchain install --profile minimal
fi
# Added separately: the nameless `rustup toolchain install` form
# (driven by rust-toolchain.toml) silently ignores --target and
# --component. These act on the now-active toolchain.
if [[ -n "${TARGET}" ]]; then rustup target add "${TARGET}"; fi
if [[ -n "${COMPONENTS}" ]]; then rustup component add "${COMPONENTS}"; fi
- name: Enable cargo cache
uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
key: "${{ matrix.target }}"
- name: Install additional prerequisite tools
if: ${{ matrix.required_tools != '' }}
env:
REQUIRED_TOOLS: ${{ matrix.required_tools }}
run: sudo apt-get update -y && sudo apt-get install -y ${REQUIRED_TOOLS}
- name: Install cross-compilation toolchain
if: ${{ matrix.cross_tool_to_install != '' }}
uses: taiki-e/install-action@68a389c788b2cd04016ce8c80ee23b80d2ab3d68 # v2.88.1
with:
tool: ${{ matrix.cross_tool_to_install }}
- name: "Build (native)"
if: ${{ matrix.target == '' }}
env:
EXTRA_BUILD_ARGS: ${{ matrix.extra_build_args }}
run: cargo build --release --all-targets ${EXTRA_BUILD_ARGS}
- name: "${{ matrix.check_only && 'Check' || 'Build' }} (cross)"
if: ${{ matrix.target != '' }}
env:
CROSS_COMMAND: ${{ matrix.cross_command }}
CARGO_SUBCOMMAND: ${{ matrix.check_only && 'check' || 'build' }}
TARGET: ${{ matrix.target }}
EXTRA_BUILD_ARGS: ${{ matrix.extra_build_args }}
CARGO_NDK_PLATFORM: 24
run: ${CROSS_COMMAND} ${CARGO_SUBCOMMAND} --release --target=${TARGET} ${EXTRA_BUILD_ARGS}
- name: "Upload binaries"
# check_only targets don't produce a binary to upload.
if: ${{ !matrix.check_only }}
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: binaries-${{ matrix.arch }}-${{ matrix.os }}
path: target/${{ matrix.target }}/release/${{ matrix.binary_name }}
- name: "Upload integration test binaries"
if: ${{ matrix.target == '' }}
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: integration-tests-${{ matrix.arch }}-${{ matrix.os }}
path: |
target/${{ matrix.target }}/release/deps/brush_*_tests-*
!**/*.d
# Test functional correctness
test:
strategy:
fail-fast: false
matrix:
include:
- host: "ubuntu-26.04"
variant: "linux-x86_64"
artifact_suffix: "linux-x86_64"
name_suffix: "(linux/x86_64)"
homebrew_supported: true
coverage: true
coverage_min_percent: 70
- host: "ubuntu-26.04-arm"
variant: "linux-aarch64"
artifact_suffix: "linux-aarch64"
name_suffix: "(linux/aarch64)"
homebrew_supported: false
coverage: true
coverage_min_percent: 70
- host: "macos-latest"
variant: "macos"
artifact_suffix: "-macos"
name_suffix: "(macOS)"
homebrew_supported: true
coverage: true
coverage_min_percent: 70
- host: "windows-2025"
variant: "windows"
artifact_suffix: "-windows"
name_suffix: "(windows)"
homebrew_supported: false
coverage: true
coverage_min_percent: 20
- host: "ubuntu-26.04"
variant: "wasi"
artifact_suffix: "-wasi"
name_suffix: "(wasm32/wasi-0.3)"
homebrew_supported: false
coverage: false
# See the wasm32-wasip3 build leg: nightly until Rust 1.100.
toolchain: "nightly"
extra_rust_targets: "wasm32-wasip3"
wasi_runtime: "wasmtime"
xtask_test_args: "--wasi"
name: "Test ${{ matrix.name_suffix }}"
runs-on: ${{ matrix.host }}
defaults:
run:
shell: bash
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up rust toolchain
env:
# Empty -- meaning the toolchain pinned in rust-toolchain.toml -- for
# every leg that doesn't set `toolchain` itself.
TOOLCHAIN: ${{ matrix.toolchain == 'nightly' && env.NIGHTLY_TOOLCHAIN || matrix.toolchain }}
TARGETS: ${{ matrix.extra_rust_targets }}
# Targets/components are added separately: the nameless
# `rustup toolchain install` form ignores --target and --component.
run: |
if [[ -n "${TOOLCHAIN}" ]]; then
rustup toolchain install "${TOOLCHAIN}" --profile minimal
# A directory override takes precedence over rust-toolchain.toml.
rustup override set "${TOOLCHAIN}"
else
rustup toolchain install --profile minimal
fi
rustup component add llvm-tools-preview
if [[ -n "${TARGETS}" ]]; then rustup target add "${TARGETS}"; fi
- name: Enable cargo cache
uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
- name: Install cargo-nextest
uses: taiki-e/install-action@68a389c788b2cd04016ce8c80ee23b80d2ab3d68 # v2.88.1
with:
tool: cargo-nextest
- name: Install cargo-llvm-cov
if: ${{ matrix.coverage }}
uses: taiki-e/install-action@68a389c788b2cd04016ce8c80ee23b80d2ab3d68 # v2.88.1
with:
tool: cargo-llvm-cov
- name: Install WASI runtime
if: ${{ matrix.wasi_runtime }}
uses: taiki-e/install-action@68a389c788b2cd04016ce8c80ee23b80d2ab3d68 # v2.88.1
with:
tool: ${{ matrix.wasi_runtime }}
- name: Set up Homebrew
if: ${{ matrix.homebrew_supported }}
id: set-up-homebrew
# We ignore the stale-action-refs check because this action does not have any releases or tags.
# We're forced to pick a specific commit.
uses: Homebrew/actions/setup-homebrew@c262868c096d909fc278daa87ed9772967331ea3 # zizmor: ignore[stale-action-refs]
with:
stable: true
- name: "Install recent bash for tests"
if: ${{ matrix.homebrew_supported }}
run: |
set -x
# Install it
brew install bash
# Find the path
BASH_PATH="$(brew --prefix bash)/bin/bash"
# Log what we're using
echo "Using bash from: ${BASH_PATH}"
echo "bash version:"
${BASH_PATH} --version
echo "BASH_PATH=${BASH_PATH}">>$GITHUB_ENV
- name: "Download recent bash-completion sources for tests"
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
repository: "scop/bash-completion"
ref: "2.17.0"
path: "bash-completion"
- name: "Setup bash-completion"
run: echo "BASH_COMPLETION_PATH=${GITHUB_WORKSPACE}/bash-completion/bash_completion">>$GITHUB_ENV
- name: Test
env:
VARIANT: ${{ matrix.variant }}
run: |
args="${{ matrix.xtask_test_args || '' }}"
args="${args} --results-output ./test-results-${VARIANT}.xml"
if [[ "${{ matrix.coverage }}" == "true" ]]; then
args="${args} --coverage --coverage-output ./codecov-${VARIANT}.xml"
fi
cargo xtask test integration ${args}
- name: "Upload test results"
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: always()
with:
name: test-reports${{ matrix.artifact_suffix }}
path: test-results-*.xml
- name: "Generate code coverage report"
uses: clearlyip/code-coverage-report-action@110af9d4ec87b6706f182fd90e3102af9e5203bf # v7.0.0
if: ${{ always() && matrix.coverage }}
id: "code_coverage_report"
with:
artifact_download_workflow_names: "CI"
artifact_name: coverage-%name%${{ matrix.artifact_suffix }}
filename: codecov-${{ matrix.variant }}.xml
overall_coverage_fail_threshold: ${{ matrix.coverage_min_percent }}
only_list_changed_files: ${{ github.event_name == 'pull_request' }}
fail_on_negative_difference: true
negative_difference_by: "overall"
negative_difference_threshold: 5
- name: "Upload code coverage report"
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: always()
with:
name: codecov-reports${{ matrix.artifact_suffix }}
path: code-coverage-results.md
# Static analysis of the code.
check:
name: "Source code checks"
runs-on: ${{ matrix.os }}
strategy:
matrix:
# Test the pinned toolchain as well as MSRV. The "stable" leg means the
# toolchain pinned in rust-toolchain.toml, which Dependabot keeps at the
# latest stable release; the label is kept so check names don't change.
rust-version: ["stable", "1.88.0"]
# Run checks on Linux, Windows, and macOS.
os: ["ubuntu-26.04", "windows-2025", "macos-latest"]
# Extra args for the build check, per toolchain. Spelled out for both
# legs so it's clear that stable compiles every crate in the workspace.
include:
- rust-version: "stable"
build_check_args: ""
# The application-tier crates declare a higher MSRV than the
# workspace-wide one, so they can't be built by the MSRV toolchain;
# see docs/reference/msrv-policy.md.
- rust-version: "1.88.0"
build_check_args: "--workspace-msrv"
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up rust toolchain (${{ matrix.rust-version }})
shell: bash
env:
MSRV: ${{ matrix.rust-version != 'stable' && matrix.rust-version || '' }}
run: |
if [[ -n "${MSRV}" ]]; then
rustup toolchain install "${MSRV}" --profile minimal --component clippy,rustfmt
# A directory override takes precedence over rust-toolchain.toml.
rustup override set "${MSRV}"
else
rustup toolchain install --profile minimal
fi
- name: Enable cargo cache
uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
- name: Format check
run: cargo xtask check fmt
- name: Check
run: cargo xtask check build ${{ matrix.build_check_args }}
- name: Crate structure check
run: cargo xtask check crates
- name: Clippy check
if: matrix.rust-version == 'stable'
run: cargo xtask check lint
# Check for unneeded dependencies.
check-deps:
name: "Check for unneeded dependencies"
runs-on: ubuntu-26.04
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# Overriding (not just installing) nightly means xtask builds with it too,
# so the pinned toolchain doesn't have to be installed as well.
- name: Set up nightly rust toolchain
run: |
rustup toolchain install "${NIGHTLY_TOOLCHAIN}" --profile minimal
rustup override set "${NIGHTLY_TOOLCHAIN}"
- name: Install cargo-udeps
uses: taiki-e/install-action@68a389c788b2cd04016ce8c80ee23b80d2ab3d68 # v2.88.1
with:
tool: cargo-udeps
# xtask runs `cargo +<toolchain> udeps`, which takes precedence over the override.
- name: Check for unused dependencies
run: cargo xtask check unused-deps --toolchain "${NIGHTLY_TOOLCHAIN}"
# Check that generated schemas are up-to-date
check-schemas:
name: "Check generated schemas"
runs-on: ubuntu-26.04
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up rust toolchain
run: rustup toolchain install --profile minimal
- name: Enable cargo cache
uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
- name: Check generated schemas
run: cargo xtask check schemas
# Analyze public API surface
analyze-public-api:
if: github.event_name == 'pull_request'
name: "Analyze public API"
runs-on: ubuntu-26.04
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# Overriding (not just installing) nightly means xtask builds with it too,
# so the pinned toolchain doesn't have to be installed as well.
- name: Set up nightly rust toolchain
run: |
rustup toolchain install nightly --profile minimal
rustup override set nightly
- name: Install cargo-public-api
uses: taiki-e/install-action@68a389c788b2cd04016ce8c80ee23b80d2ab3d68 # v2.88.1
with:
tool: cargo-public-api
- name: Set up branches
run: |
set -euo pipefail
git fetch origin main
- name: Compare public APIs with main
run: cargo xtask analyze public-api --base origin/main --output-dir reports
- name: Upload test report
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: test-reports-public-api
path: reports/*.md
# Performance analysis of the code.
benchmark:
if: github.event_name == 'pull_request'
name: "Benchmarks"
runs-on: ubuntu-26.04
steps:
- name: Checkout PR sources
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
path: pr
- name: Checkout main sources
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
path: main
ref: main
# main/ is built with its own pinned toolchain, which differs from the PR's
# only when the PR changes rust-toolchain.toml.
- name: Set up rust toolchain
run: rustup toolchain install --profile minimal
working-directory: pr
- name: Enable cargo cache
uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
workspaces: |
./pr
./main
# Criterion keeps past results under target/criterion by default, which the cargo cache
# saves; a later run on the branch then fails to load them as a baseline, and its errors
# break the bencher-format output. CRITERION_HOME keeps results out of the cache.
- name: Performance analysis on PR
run: cargo xtask analyze bench --output benchmarks.txt
working-directory: pr
env:
CRITERION_HOME: ${{ runner.temp }}/criterion-pr
- name: Performance analysis on main
run: cargo bench --workspace --benches -- --output-format bencher | tee benchmarks.txt
working-directory: main
env:
CRITERION_HOME: ${{ runner.temp }}/criterion-main
- name: Compare benchmark results
run: |
./pr/scripts/compare-benchmark-results.py -b main/benchmarks.txt -t pr/benchmarks.txt >benchmark-results.md
- name: Upload performance results
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: perf-reports
path: |
pr/benchmarks.txt
main/benchmarks.txt
benchmark-results.md
# Run real applications' own shell-integration suites (e2e/) against the release binary.
# The default adapter set plus bash-completion, which is report-only: its failures show as a pass
# rate in the dashboard without failing the job. blesh and bash-tests are opt-in (see
# e2e/README.md).
e2e-tests:
name: "External tests / e2e suites"
runs-on: ubuntu-26.04
needs: build
timeout-minutes: 45
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Download prebuilt brush binaries
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: "binaries-x86_64-linux"
path: "binaries"
- name: Setup downloads
run: chmod +x binaries/*
- name: Set up rust toolchain
run: rustup toolchain install --profile minimal
- name: Enable cargo cache
uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
- name: Run e2e suites
run: |
cargo xtask test --brush-path "${GITHUB_WORKSPACE}/binaries/brush" \
e2e --with bash-completion --timeout 600 \
--results-dir e2e-results --summary-output e2e-results/dashboard.md
# The dashboard goes to the run page as well as the PR comment; failing adapters' logs go
# to the job log, so the comment stays short and the details are one click away.
- name: Publish dashboard
if: always()
run: |
dashboard=e2e-results/dashboard.md
[[ -f "${dashboard}" ]] || exit 0
# The blank line ends the last <details> block; without it the link stays raw HTML.
printf '\n📎 [Run logs](%s/%s/actions/runs/%s) · artifact `e2e-logs`\n' \
"${GITHUB_SERVER_URL}" "${GITHUB_REPOSITORY}" "${GITHUB_RUN_ID}" >> "${dashboard}"
cat "${dashboard}" >> "${GITHUB_STEP_SUMMARY}"
cp "${dashboard}" test-results-e2e.md
for log in e2e-results/run-*/*/log.txt; do
app=$(basename "$(dirname "${log}")")
if grep -qE "^\| (❌|💥) \| ${app} \|" "${dashboard}"; then
echo "::group::${app} log.txt"
cat "${log}"
echo "::endgroup::"
fi
done
- name: Upload dashboard
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: test-reports-e2e
path: test-results-e2e.md
- name: Upload logs
if: always()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: e2e-logs
path: e2e-results/
retention-days: 7
# Test release binary on a variety of OS platforms.
os-tests:
strategy:
fail-fast: false
matrix:
include:
# N.B. We don't include Ubuntu because it's already covered by the initial test job.
- container: "archlinux:latest"
description: "Arch Linux/latest"
prereqs_command: "pacman -Sy --noconfirm bash-completion iputils grep less sed util-linux which"
- container: "mcr.microsoft.com/azurelinux/core:4"
description: "Azure Linux/4"
prereqs_command: "dnf install -y bash-completion ca-certificates git iputils grep less procps-ng sed util-linux which"
- container: "debian:testing"
description: "Debian/testing"
prereqs_command: "apt-get update -y && apt-get install -y bash-completion bsdmainutils iputils-ping grep less sed which"
- container: "fedora:latest"
description: "Fedora/latest"
prereqs_command: "dnf install -y bash-completion iputils grep less sed util-linux which"
- container: "nixos/nix:latest"
description: "NixOS/latest"
prereqs_command: "nix --extra-experimental-features nix-command --extra-experimental-features flakes profile install nixpkgs#gnused nixpkgs#hexdump nixpkgs#bash-completion"
# NOTE: We use Tumbleweed to make sure we get a recent enough version of bash.
- container: "opensuse/tumbleweed:latest"
description: "openSUSE Tumbleweed/latest"
prereqs_command: "zypper install -y bash-completion git which"
name: "OS target tests (${{ matrix.description }})"
runs-on: ubuntu-26.04
# The floating tags in the matrix above are deliberate: the whole point of
# this job is to catch breakage against *current* distro releases, so
# digest-pinning these images would defeat it.
container: ${{ matrix.container }} # zizmor: ignore[unpinned-images]
needs: build
env:
# Reference: https://github.com/Azure/azure-cli/issues/29835
GNUPGHOME: /root/.gnupg
steps:
# Install prerequisites *first*; may need some of them for checkout itself.
- name: Install prerequisites
if: ${{ matrix.prereqs_command != '' }}
env:
PREREQS_COMMAND: ${{ matrix.prereqs_command }}
run: bash -c "${PREREQS_COMMAND}"
# Workaround path issues on NixOS
- name: "Apply workarounds: NixOS"
if: ${{ matrix.container == 'nixos/nix:latest' }}
run: |
set -x
# Allow use of experimental features to avoid nix from complaining.
export NIX_CONFIG="extra-experimental-features = nix-command flakes"
# The nixos/nix image ships no /etc/os-release (unlike a full NixOS install
# and every other container we test against). Provide one so the test harness
# can identify the OS, which it relies on for tests' `incompatible_os` tags.
printf 'NAME=NixOS\nID=nixos\nPRETTY_NAME="NixOS (nix container)"\n' > /etc/os-release
# Hacks to get the mounted nodejs by github actions work as it's dynamically linked
# https://github.com/actions/checkout/issues/334#issuecomment-716068696
nix build --no-link --max-jobs 2 --cores 0 'nixpkgs#stdenv.cc.cc.lib' 'nixpkgs#glibc'
echo "LD_LIBRARY_PATH=$(nix path-info 'nixpkgs#stdenv.cc.cc.lib')/lib" >> "$GITHUB_ENV"
ln -s "$(nix path-info 'nixpkgs#glibc' --recursive | grep glibc | grep -v bin)/lib64" /lib64
# Provide alternate path for bash-completion
echo "BASH_COMPLETION_PATH=$(nix path-info 'nixpkgs#bash-completion')/share/bash-completion/bash_completion">>"$GITHUB_ENV"
# Forcibly add system paths
echo "BRUSH_TEST_PATH_VAR=$PATH">>"$GITHUB_ENV"
# Checkout sources for YAML-based test cases
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
path: sources
- name: Download prebuilt brush binaries
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: binaries-x86_64-linux
path: binaries
- name: Download integration test binaries
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: integration-tests-x86_64-linux
path: binaries
- name: Setup downloads
run: |
# N.B. Can't use -o pipefail because it's not supported on Debian.
set -eux
chmod +x binaries/*
ls -l -R sources/brush-shell/tests
ls -l binaries
- name: Run tests
shell: bash
run: |
export BRUSH_PATH=$PWD/binaries/brush
export BRUSH_VERBOSE=true
result=0
for test_name in binaries/*tests*; do
if [[ ${test_name} == *compat* ]]; then
export BRUSH_TEST_CASES=$PWD/sources/brush-shell/tests/cases/compat
elif [[ ${test_name} == *integration* ]]; then
export BRUSH_TEST_CASES=$PWD/sources/brush-shell/tests/cases/brush
elif [[ ${test_name} == *interactive* || ${test_name} == *version* ]]; then
# TODO(tests): Re-enable these tests.
echo "WARNING: skipping test: ${test_name}"
continue
fi
echo "Running test: ${test_name}"
chmod +x ${test_name}
${test_name} || result=$?
done
exit ${result}
+55
View File
@@ -0,0 +1,55 @@
name: "CodeQL Advanced"
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
schedule:
- cron: "35 1 * * 3"
permissions: {}
# Only allow one run of the workflow per branch / PR at a time.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ubuntu-26.04
permissions:
# required for all workflows
security-events: write
# required to fetch internal or private CodeQL packs
packages: read
strategy:
fail-fast: false
matrix:
include:
- language: actions
build-mode: none
- language: python
build-mode: none
- language: rust
build-mode: none
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
queries: security-extended,security-and-quality
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with:
category: "/language:${{matrix.language}}"
@@ -0,0 +1,72 @@
name: "Devcontainer"
on:
push:
branches:
- main
pull_request:
paths:
- ".devcontainer/**"
permissions: {}
# Only allow one run of the workflow per branch / PR at a time.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
build:
name: "Build devcontainer"
runs-on: ubuntu-26.04
permissions:
contents: read
packages: read
steps:
- name: Checkout sources
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Login to GitHub Container Registry
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Pre-build dev container image
uses: devcontainers/ci@513af61f4de4f75d37e4438f184ba4358f0fc1ca # v0.3.1900000450
with:
imageName: ghcr.io/reubeno/brush/devcontainer
imageTag: latest
cacheFrom: ghcr.io/reubeno/brush/devcontainer
push: never
build_and_publish:
name: "Build and publish devcontainer"
runs-on: ubuntu-26.04
permissions:
contents: read
packages: write
steps:
- name: Checkout sources
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Login to GitHub Container Registry
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Pre-build dev container image
uses: devcontainers/ci@513af61f4de4f75d37e4438f184ba4358f0fc1ca # v0.3.1900000450
with:
imageName: ghcr.io/reubeno/brush/devcontainer
imageTag: latest
cacheFrom: ghcr.io/reubeno/brush/devcontainer
push: filter
refFilterForPush: refs/heads/main
eventFilterForPush: push
+59
View File
@@ -0,0 +1,59 @@
name: "Docs"
on:
workflow_dispatch:
pull_request:
push:
branches:
- main
env:
RUST_BACKTRACE: 1
CARGO_TERM_COLOR: always
CLICOLOR: 1
CLICOLOR_FORCE: 1
permissions:
contents: read
# Only allow one run of the workflow per branch / PR at a time.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
# Generate docs content
generate-docs:
name: "Generate usage docs"
runs-on: ubuntu-26.04
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up rust toolchain
run: rustup toolchain install --profile minimal
- name: Enable cargo cache
uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
- name: Create dirs
run: mkdir -p ./md ./man
- name: Build markdown info
run: cargo xtask gen docs markdown --out ./md/brush.md
- name: Upload markdown docs
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: docs-markdown
path: md
- name: Build man pages
run: cargo xtask gen docs man --output-dir ./man
- name: Upload man pages
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: docs-man
path: man
+72
View File
@@ -0,0 +1,72 @@
# Every hook defined in .pre-commit-config.yaml: file hygiene, spelling,
# workflow analysis, link checking, the cargo-deny audit, and rustfmt. Runs the
# same `cargo xtask check hooks` a contributor runs.
#
# This is its own workflow rather than a job in ci.yaml so that it can run on
# every change -- including docs-only pushes, which ci.yaml skips -- without
# dragging the build and test matrix along with it.
name: "Pre-commit hooks"
on:
pull_request:
push:
branches:
- main
env:
CARGO_INCREMENTAL: 0
RUST_BACKTRACE: 1
CARGO_TERM_COLOR: always
CLICOLOR: 1
CLICOLOR_FORCE: 1
permissions:
contents: read
# Only allow one run of the workflow per branch / PR at a time.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
hooks:
name: "Pre-commit hooks"
runs-on: ubuntu-26.04
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up rust toolchain
run: rustup toolchain install --profile minimal
- name: Enable cargo cache
uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
- name: Install prek
uses: taiki-e/install-action@68a389c788b2cd04016ce8c80ee23b80d2ab3d68 # v2.88.1
with:
tool: prek
- name: Cache hook environments
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/prek
key: prek-${{ runner.os }}-${{ hashFiles('.pre-commit-config.yaml') }}
restore-keys: prek-${{ runner.os }}-
- name: Prepare hooks
run: prek prepare-hooks
env:
# Pass through the token in case 'cargo binstall' needs it
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Run pre-commit hooks
run: cargo xtask check hooks
# The fixing hooks rewrite files in place and then fail. Show what they
# changed so the required fix is visible in the log.
- name: Show changes made by hooks
if: failure()
run: git diff
@@ -0,0 +1,77 @@
name: "Install script"
on:
pull_request:
paths:
- "scripts/install/**"
- ".github/workflows/install-script.yaml"
push:
branches: ["main"]
paths:
- "scripts/install/**"
- ".github/workflows/install-script.yaml"
# Weekly, to catch release changes (e.g., renamed assets) that break the script.
schedule:
- cron: "0 6 * * 1"
workflow_dispatch: null
permissions: {}
# Only allow one run of the workflow per branch / PR at a time.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
test:
name: "Test (${{ matrix.name }})"
permissions:
contents: read
attestations: read # needed for `gh attestation verify`
strategy:
fail-fast: false
matrix:
include:
- name: "linux-x86_64"
os: ubuntu-26.04
- name: "linux-aarch64"
os: ubuntu-26.04-arm
- name: "macos-aarch64"
os: macos-latest
- name: "macos-x86_64"
os: macos-26-intel
# musl + busybox userland, no gh.
- name: "alpine"
os: ubuntu-26.04
container: "alpine:3"
prepare: "apk add --no-cache bash curl"
# glibc too old for the gnu build; exercises the musl fallback.
- name: "almalinux-8"
os: ubuntu-26.04
container: "almalinux:8"
runs-on: ${{ matrix.os }}
container: ${{ matrix.container }}
timeout-minutes: 15
steps:
- name: "Install container prerequisites"
if: ${{ matrix.prepare }}
shell: sh # bash may not be installed yet
run: ${{ matrix.prepare }}
- name: "Checkout repository"
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: "Install uv"
uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
- name: "Run tests"
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: uvx 'pytest==8.*' -p no:cacheprovider -v scripts/install/tests
+42
View File
@@ -0,0 +1,42 @@
name: "Shell scripts"
on:
pull_request:
paths:
- "**.sh"
- ".github/workflows/shellcheck.yaml"
push:
branches: ["main"]
paths:
- "**.sh"
- ".github/workflows/shellcheck.yaml"
workflow_dispatch: null
permissions: {}
# Only allow one run of the workflow per branch / PR at a time.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
shellcheck:
name: "shellcheck"
permissions:
contents: read
runs-on: ubuntu-26.04
timeout-minutes: 10
# Pinned, since findings differ across shellcheck versions.
container: koalaman/shellcheck-alpine:v0.11.0@sha256:9955be09ea7f0dbf7ae942ac1f2094355bb30d96fffba0ec09f5432207544002
steps:
- name: "Checkout repository"
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: "Run shellcheck"
run: find . -name '*.sh' -not -path './.git/*' -exec shellcheck {} +
@@ -0,0 +1,45 @@
#
# This workflow was based on https://docs.zizmor.sh/integrations/
#
name: GitHub Actions Security Analysis with zizmor 🌈
on:
push:
branches: ["main"]
pull_request:
branches: ["**"]
permissions: {}
# N.B. This is the *online* zizmor pass: it runs the audits that need network
# access and uploads SARIF to code scanning for stateful triage in the Security
# tab, and it deliberately does not fail the build.
#
# The complementary offline pass -- which does fail, and fails fast -- is the
# `zizmor` hook in .pre-commit-config.yaml, run in CI by hooks.yaml and locally
# by `cargo xtask check hooks workflows`. The two are not redundant; do not
# collapse them without checking which audits each one actually runs.
# Only allow one run of the workflow per branch / PR at a time.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
zizmor:
name: zizmor latest via zizmor-action
runs-on: ubuntu-26.04
permissions:
security-events: write # needed for SARIF uploads
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# Reporting pass: uploads SARIF to code scanning, giving us stateful
# analysis and incremental triage in the Security tab. Does not fail
# with non-zero result but reports them.
- name: Run zizmor 🌈
uses: zizmorcore/zizmor-action@53290badfa369cb0bf7ad16342cb62c2fa47ac7b # v0.8.0
+4
View File
@@ -0,0 +1,4 @@
target/
mutants.out*/
__pycache__/
.pytest_cache/
@@ -0,0 +1,247 @@
# Third-party linter definitions, and the single place their versions are
# pinned. As with GitHub Actions, each `rev:` is a full commit SHA, since a tag
# can be moved to point at different code; the `# frozen: <tag>` comment records
# which release that is. Dependabot's `pre-commit` ecosystem keeps both current
# (see .github/dependabot.yml).
#
# Two different front ends read this file:
#
# cargo xtask check hooks # runs every hook; needs no knowledge of prek
# cargo xtask check hooks <HOOK>... # runs only the named hooks, by id or by the
# # aliases below: spelling, links, workflows, deps,
# # structure
#
# ...and prek (https://prek.j178.dev) directly, as installed git hooks. CI's
# Pre-commit hooks workflow (.github/workflows/hooks.yaml) goes through
# `cargo xtask check hooks` as well, so CI and a local run execute the same
# command. prek is a single-binary Rust
# reimplementation of pre-commit; it reads this file and reimplements the
# common hygiene hooks natively, so no Python environment is needed for them.
#
# Install: cargo binstall prek (or: uv tool install prek / brew install prek)
# Enable the git hooks (optional): prek install
#
# Useful invocations:
# prek run # staged files, commit-stage hooks
# prek run --all-files # whole tree, commit-stage hooks (= check hooks)
# prek run --all-files --stage pre-push # the cargo-backed check
# prek run --all-files typos # a single hook
# prek run --all-files --skip rustfmt # skip one hook
#
# The upstream Python `pre-commit` also reads this file, but spells two of those
# flags differently: `--hook-stage` rather than `--stage`, and `SKIP=<id>` in the
# environment rather than `--skip`.
#
# Git hooks are entirely optional -- `cargo xtask ci quick` and
# `cargo xtask ci full` are the documented contributor path and neither requires
# `prek install`.
default_install_hook_types: [pre-commit, commit-msg, pre-push]
# `default_stages` only applies to hooks whose upstream manifest doesn't declare
# its own `stages`; several of the hygiene hooks declare [pre-commit, pre-push,
# manual], so they're pinned to the commit stage individually below. Keeping the
# fixing hooks off the pre-push stage matters: a hook that rewrites a file during
# `git push` leaves you with an already-made commit and unstaged fixes.
default_stages: [pre-commit]
repos:
# ---------------------------------------------------------------------------
# File hygiene. prek substitutes its own native Rust implementations for these
# automatically, so nothing is cloned or installed for this block.
# ---------------------------------------------------------------------------
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # frozen: v6.0.0
hooks:
- id: trailing-whitespace
args: [--markdown-linebreak-ext=md]
stages: [pre-commit]
# Shell fragments inside compat test cases carry semantically meaningful
# trailing whitespace; insta snapshots must match byte-for-byte; the
# changelog is generated by git-cliff.
exclude: ^(brush-shell/tests/cases/|CHANGELOG\.md$)|\.snap$
- id: end-of-file-fixer
stages: [pre-commit]
exclude: ^(brush-shell/tests/cases/|CHANGELOG\.md$)|\.snap$
- id: mixed-line-ending
args: [--fix=lf]
stages: [pre-commit]
exclude: ^(brush-shell/tests/cases/|CHANGELOG\.md$)|\.snap$
- id: check-merge-conflict
- id: check-symlinks
- id: destroyed-symlinks
stages: [pre-commit]
- id: check-added-large-files
stages: [pre-commit]
- id: detect-private-key
# brush is built and tested on macOS and Windows too.
- id: check-case-conflict
- id: check-illegal-windows-names
- id: check-yaml
- id: check-toml
- id: check-json
# .vscode/*.json are JSONC (comments), not strict JSON.
exclude: ^\.vscode/
- id: check-executables-have-shebangs
stages: [pre-commit]
- id: check-shebang-scripts-are-executable
stages: [pre-commit]
# Rust files opening with an inner attribute (`#![...]`) look like they
# have a shebang.
exclude: \.rs$
# ---------------------------------------------------------------------------
# Schema validation for release-plz.toml, so a misspelled key or wrong type
# fails here instead of at release time. The schema is the one release-plz
# publishes, as of a release-plz release tag. Dependabot doesn't track that
# URL; bump the tag by hand when adopting newer release-plz config.
# check-jsonschema caches the download, so only the first run needs the
# network.
# ---------------------------------------------------------------------------
- repo: https://github.com/python-jsonschema/check-jsonschema
rev: a76b92851fc9eede9f9bc4073eb55202f34eb0df # frozen: 0.39.0
hooks:
- id: check-jsonschema
name: Validate release-plz.toml
files: ^release-plz\.toml$
# Upstream's manifest limits this hook to [json, yaml].
types_or: [toml]
args:
- --schemafile
- https://raw.githubusercontent.com/release-plz/release-plz/release-plz-v0.4.3/.schema/latest.json
# ---------------------------------------------------------------------------
# Spelling. Configured by Cargo.toml [workspace.metadata.typos].
# ---------------------------------------------------------------------------
- repo: https://github.com/crate-ci/typos
rev: b859c0df7f391deba73030f79b957e62b4d81dc6 # frozen: varcon-core-v5.0.7
hooks:
- id: typos
alias: spelling
# Report, don't rewrite. Overriding `args` replaces the upstream
# manifest's `[--write-changes, --force-exclude]`, and dropping
# --write-changes is the point: automatic corrections inside shell test
# cases would silently change what the tests exercise. --force-exclude
# is what makes typos honor its own exclude config for files named
# explicitly on the command line, so keep it.
args: [--force-exclude]
stages: [pre-commit]
# ---------------------------------------------------------------------------
# GitHub Actions security analysis. Configured by zizmor.yml. This is the
# offline pass, which fails fast; the online pass that uploads SARIF lives in
# .github/workflows/workflow-checks.yaml and is not redundant with it.
# ---------------------------------------------------------------------------
- repo: https://github.com/zizmorcore/zizmor-pre-commit
rev: c92afdf2c6ca336aaa8f39e2bd8d48f62dbd8e1f # frozen: v1.31.1
hooks:
- id: zizmor
alias: workflows
args: [--no-progress, --offline]
# ---------------------------------------------------------------------------
# Link checking. Configured by lychee.toml. `types_or` mirrors lychee's own
# default `--extensions` list (markdown, html, css, txt, xml), so this reaches
# the same files as pointing lychee at the whole tree.
#
# Dependabot bumps this SHA but can't rewrite the comment, because lychee's
# tags carry a `lychee-` prefix; fix the comment by hand in its bump PRs.
# ---------------------------------------------------------------------------
- repo: https://github.com/lycheeverse/lychee
rev: 2bba271688c1abb1503097a064e6c3bc1d1b6a9b # frozen: lychee-v0.24.2
hooks:
- id: lychee
alias: links
args: [--no-progress, --offline]
types_or: [markdown, html, css, plain-text, xml]
# ---------------------------------------------------------------------------
# Dependency audit: advisories, licenses, bans, sources. Configured by
# deny.toml. cargo-deny is a third-party cargo subcommand, not a toolchain
# component, so rust-toolchain.toml does not pin it; this does. prek builds
# the pinned release from source (`cargo install --locked`), a one-time cost
# per version bump that is cached afterwards. Upstream's hook already passes
# `--all-features check`, which runs every check, and only fires when a
# Cargo.toml or Cargo.lock is among the files.
# ---------------------------------------------------------------------------
- repo: https://github.com/EmbarkStudios/cargo-deny
rev: bca0dde53651ee946720e4540b5ce2610bec8f06 # frozen: 0.20.2
hooks:
- id: cargo-deny
alias: deps
# ---------------------------------------------------------------------------
# Structural lints over the syntax tree, for architectural rules that clippy
# can't express. Configured by sgconfig.yml; the rules and their tests live
# in .config/ast-grep/. Pre-existing violations are baselined in place, and
# --error=unused-suppression fails once one of those is no longer needed, so
# the baseline can only shrink. This is a thin community wrapper around the
# official @ast-grep/cli npm package, whose version it tracks; a `repo: local`
# hook would avoid it, but Dependabot skips those.
# ---------------------------------------------------------------------------
- repo: https://github.com/boidolr/ast-grep-pre-commit
rev: 13126a89e67880b0dac82cf40350abd2cfedd458 # frozen: 0.51.0
hooks:
- id: ast-grep
alias: structure
args: [--error=unused-suppression]
types: [rust]
# Each rule's test cases must or mustn't match. Skipping snapshot tests
# skips the extra comparison of each match's exact output (span, message)
# against a recorded __snapshots__ file, which would need regenerating on
# every rule edit without telling us more about what the rule catches.
- id: ast-grep-rule-tests
args: [--skip-snapshot-tests]
files: ^(sgconfig\.yml|\.config/ast-grep/)
# ---------------------------------------------------------------------------
# Conventional Commits, as required by CONTRIBUTING.md and consumed by
# git-cliff and release-plz.
# ---------------------------------------------------------------------------
- repo: https://github.com/compilerla/conventional-pre-commit
rev: 33b50eb042bee26eac0ee96ad84aa9c1b4dccdd8 # frozen: v4.5.0
hooks:
- id: conventional-pre-commit
stages: [commit-msg]
args: [build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test]
# ---------------------------------------------------------------------------
# Repo-specific checks. These delegate to xtask so that the Rust-side checks
# have exactly one definition, and so that hook users and non-hook users run
# identical code.
# ---------------------------------------------------------------------------
- repo: local
hooks:
# `rustfmt` rather than `cargo fmt --all`: passing the staged files formats
# only what is being committed. `cargo fmt --all` would rewrite files
# outside the commit, and pre-commit stashes unstaged changes before
# running hooks, so rewriting a file that also has unstaged edits makes the
# stash restore conflict. rustfmt reads rustfmt.toml for the edition.
- id: rustfmt
name: rustfmt
entry: rustfmt
language: system
types: [rust]
require_serial: true
# One hook, not four. `ci quick` is the same command CONTRIBUTING.md tells
# contributors to run, so people who install the hooks and people who
# don't execute identical code. It covers fmt, build, clippy, and unit
# tests; schemas, cargo-deny, and integration tests are left to
# `cargo xtask ci full` and to CI.
#
# Deliberately `ci quick` and not `ci full`: `ci full` runs the pre-commit
# hooks, which invoke prek, which is what is running this hook. xtask
# refuses to start prek when PRE_COMMIT is set, so getting this wrong
# fails fast rather than recursing.
- id: xtask-ci-quick
name: cargo xtask ci quick
entry: cargo xtask ci quick
language: system
files: \.rs$|(^|/)Cargo\.(toml|lock)$|(^|/)(rustfmt|clippy)\.toml$
pass_filenames: false
require_serial: true
stages: [pre-push]
+5
View File
@@ -0,0 +1,5 @@
# Search dot-prefixed files and directories (e.g. .github, .gitignore) by default.
!.*
# Re-exclude dot-prefixed directories that would otherwise be ignored.
.git
.pytest_cache/
+7
View File
@@ -0,0 +1,7 @@
{
// List of extensions to recommend for users of this workspace.
// See https://go.microsoft.com/fwlink/?LinkId=827846 for more details.
"recommendations": [
"rust-lang.rust-analyzer",
],
}
+65
View File
@@ -0,0 +1,65 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "lldb",
"request": "launch",
"name": "Debug executable 'brush'",
"cargo": {
"args": [
"build",
"--bin=brush",
"--package=brush-shell"
],
"filter": {
"name": "brush",
"kind": "bin"
}
},
"args": [
"--enable-highlighting"
],
"cwd": "${workspaceFolder}"
},
{
"type": "lldb",
"request": "launch",
"name": "Debug integration test 'brush-compat-tests'",
"cargo": {
"args": [
"test",
"--no-run",
"--test=brush-compat-tests",
"--package=brush-shell"
],
"filter": {
"name": "brush-compat-tests",
"kind": "test"
}
},
"args": [],
"cwd": "${workspaceFolder}"
},
{
"type": "lldb",
"request": "launch",
"name": "Debug executable 'xtask'",
"cargo": {
"args": [
"build",
"--bin=xtask",
"--package=xtask"
],
"filter": {
"name": "xtask",
"kind": "bin"
}
},
"args": [],
"cwd": "${workspaceFolder}"
}
]
}
+20
View File
@@ -0,0 +1,20 @@
{
"editor.formatOnSave": true,
"files.exclude": {
"target/": true
},
"terminal.integrated.defaultProfile.linux": "bash",
"terminal.integrated.profiles.linux": {
"bash": {
"path": "/bin/bash"
}
},
"rust-analyzer.check.command": "clippy",
"rewrap.wrappingColumn": 90,
"rust-analyzer.debug.openDebugPane": true,
"rust-analyzer.testExplorer": true,
"rust-analyzer.check.features": "all",
"rust-analyzer.check.allTargets": true,
"rust-analyzer.check.workspace": true,
"sarif-viewer.connectToGithubCodeScanning": "on",
}
+349
View File
@@ -0,0 +1,349 @@
# Agent Development Guide for `brush`
This guide helps AI agents work efficiently on the `brush` codebase by providing essential context about architecture, patterns, and development workflows.
## Required: Independent Implementation
Before investigating or implementing a change, read and follow the
[independent-implementation and provenance policy](CONTRIBUTING.md#independent-implementation-and-provenance).
It is the canonical policy for contributors and all agents, including delegated
agents and their tools. Use Bash as a black-box behavioral oracle, not as
implementation source material. If you encounter Bash implementation material,
stop implementation and disclose the exposure; do not translate, summarize, or
rewrite it into brush. Disclose this to the human contributor to clarify how
to proceed.
## 1. Architecture Overview & Navigation
### Architecture
[docs/explanation/architecture.md](docs/explanation/architecture.md) is the definition of
brush's architecture: its crates, how they depend on each other, how `brush-core` is
structured, and the layering principles that decide which crate code belongs in (e.g.
what `brush-core` owns vs. the interactive front end). Read it before adding a crate,
moving code between crates, or changing a public API's shape, and keep it up to date when
the architecture changes.
### Key Files & Entry Points
**Critical files to understand first:**
- `brush-core/src/shell.rs` - Main `Shell` struct and creation logic
- `brush-core/src/lib.rs` - Public API exports
- `brush-shell/src/main.rs` - CLI application entry point
**Architecture patterns:**
- Shell instances are created via `Shell::builder()`
- The project uses builder patterns for type-safe configuration
- Platform-specific code in `brush-core` MUST live under the `sys` module
- Follows Rust 2024 edition standards
### Module Dependencies
```text
brush-shell → brush-interactive ─────────────────────────→ brush-core → brush-parser
↘ brush-builtins → brush-winnow-args-adapter ↗
```
## 2. Testing Strategy
### Test Execution Priority
**Recommended development workflow:**
#### Using xtask (Recommended)
The project provides a `cargo xtask` command that centralizes common development tasks:
```bash
# Run quick inner-loop checks (~7s warm): fmt, build, lint, unit tests
cargo xtask ci quick
# Run the full workflow (~60s warm): quick + pre-commit hooks, schemas, integration tests
cargo xtask ci full
# Run with --continue-on-error to see all failures at once
cargo xtask ci full -k
# Add -v for verbose output showing exact commands being run
cargo xtask -v ci full
```
`cargo xtask ci quick` needs only the Rust toolchain. `cargo xtask ci full` also
needs `prek` on `PATH` (see CONTRIBUTING.md for install commands). If `prek` is
unavailable in your environment, run `cargo xtask ci full --no-hooks`; CI
still runs the hooks.
#### Individual Test Commands
```bash
# Run unit tests (fast tests excluding integration binaries)
cargo xtask test unit
# Run integration tests (all workspace tests including compat tests)
cargo xtask test integration
# Run tests with coverage
cargo xtask test integration --coverage --coverage-output codecov.xml
```
#### Manual Approach (Alternate)
For finer-grained control:
##### Inner Loop (Fast Iteration)
1. **Quick validation**: `cargo check --package <changed-package>` - Fast syntax/type checking
2. **Correctness validation**: `cargo test --package <changed-package>` - Target specific crates for faster feedback
##### Outer Loop (Comprehensive Testing)
1. **Compatibility tests**: `cargo test --test brush-compat-tests` - Bash compatibility validation
2. **Full workspace tests**: `cargo test --workspace` - Complete test suite
#### Pre-Finish Quality Validation
**Recommended:** Run the xtask full workflow:
```bash
cargo xtask ci full
```
**Manual approach:** Before considering work complete, run these validation steps:
- **Compatibility tests**: `cargo test --test brush-compat-tests`
- **Linting**: `cargo clippy`
- **Formatting**: `cargo fmt --check`
- **Pre-commit hooks** (spelling, links, workflows, dependency audit, structural lints): `cargo xtask check hooks` (needs `prek`)
- **Full test suite**: `cargo test --workspace`
**When tests fail:**
- Focus on failures in the area you changed first
- Compatibility test failures often indicate shell behavior changes
- Check if new functionality needs corresponding test cases
- Format/clippy failures should be fixed before proceeding
**Common pitfalls:**
- **Test scope mistakes**: Running full test suite too early instead of targeting specific areas first
- **Skipping test-driven development**: Add tests that specify desired behavior before implementing
**Test-driven development approach:**
- When possible, write tests first that specify the desired behavior
- Use unit tests for logic changes, compatibility tests for shell behavior changes
- Use these tests as validation that your implementation is working correctly
**Pro tip**: For specific compatibility test cases, use:
```bash
cargo test --test brush-compat-tests -- '<name of test case>'
```
**Fast iteration strategies:**
- Target specific crates: `cargo test --package <changed-package>`
- Target specific test cases: `cargo test <test-name>` or `cargo test --test <test-file>`
- Requires knowledge of which tests best exercise the code being changed
**Testing approach:**
- Follow good software engineering practice: start by validating the specific area being changed, then iteratively move to incrementally broader sets of tests
### Test Organization
**Testing expectations for new public APIs:**
- Unit tests are expected if feasible
- Examples are nice to have and worthwhile for sufficiently critical APIs
**Test patterns and conventions:**
- **Compatibility tests**: For any compatibility-related fixes, it's critical to add new test cases to the compat tests (see docs/how-to/run-tests.md and section 3 for when breaking changes apply)
**Test categories:**
- Unit tests: In `src/` files with `#[cfg(test)]`
- Integration tests: In `tests/` directories
- Examples: In `examples/` directories (must be runnable)
- Shell script tests: YAML-based test cases in `brush-shell/tests/cases/`
### Performance Testing
**Performance regression testing:**
- Not a chief concern for most changes
- For performance-specific work, benchmarks are available (see docs/how-to/run-benchmarks.md)
- Performance sensitivity will be identified in the initial brief if relevant
## 3. Breaking Changes & Compatibility
### API Stability Guidelines
**Breaking change policy:**
- Non-backwards compatible changes to public APIs are considered breaking
- Breaking changes are still in consideration, but need to be highlighted and carefully reviewed
- Any APIs exported from crates are considered public because all of the crates are published to crates.io
**Adding new fields to public structs:**
- New optional fields are fine to add as long as the struct implements the Default trait and as long as the defaulted value is a sensible one
### Dependency Impact
When changing public APIs in `brush-core` (see section 3 for breaking change policy):
1. Check `brush-shell/src/main.rs` for struct initialization sites
2. Check `brush-interactive/` for any usage
## 4. Documentation & Examples Standards
### Documentation Requirements
**Rustdoc documentation standards:**
- At minimum we must have good rustdoc documentation for exported types, functions, traits, etc. as well as on all exported modules and crates
- Documentation for internal components should be a best-effort, nice to have thing
**Examples for new features:**
- Unless explicitly requested, only major feature additions warrant an example.
**Documentation style:**
- Follow general best practices for Rust
### Example Standards
Examples should:
- Be self-contained and runnable with `cargo run --package brush-core --example <name>`
- Include comprehensive error handling
- Demonstrate both basic and advanced usage patterns
- Include output examples in comments when helpful
## 5. Build & Release Process
### Development Tools
The project uses several tools for code quality:
**Using xtask (Recommended):**
The project provides a `cargo xtask` command that centralizes common development tasks:
```bash
# Run all checks (comprehensive)
cargo xtask ci full
# Individual checks
cargo xtask check fmt # Format check
cargo xtask check lint # Clippy
cargo xtask check build # Compilation check
cargo xtask check crates # Crate structure (e.g. LICENSE symlinks)
cargo xtask check schemas # Schema drift check
cargo xtask check hooks # prek hooks: file hygiene, typos, zizmor, lychee, cargo-deny, ast-grep
cargo xtask check hooks deps # One hook, by id or alias
# Tests
cargo xtask test unit # Fast unit tests (excludes integration binaries)
cargo xtask test integration # All workspace tests (unit + compat)
# Analysis
cargo xtask analyze bench # Run benchmarks
```
**Manual approach (Alternate):**
- Standard cargo commands (e.g., check, test, build, run, clippy)
- You may need to reverse engineer some of the args looking at CI checks in .github/*.yml
**Command frequency guidelines:**
- **Frequent (inner loop)**: `cargo xtask ci quick`, `cargo check`, `cargo test --package <pkg>`
- **Regular (before commits)**: `cargo xtask ci quick` or `cargo fmt` + `cargo clippy`
- **Occasional (outer loop)**: `cargo xtask test integration` or `cargo test --workspace`
- **Rare (before opening a PR)**: `cargo xtask ci full`, which adds the pre-commit hooks, schemas, and integration tests
**Pre-commit validation:**
- Recommended: `cargo xtask ci quick`
- Before opening a PR: `cargo xtask ci full`
- Manual: Run `cargo fmt` and `cargo clippy` before committing
**Outer loop validation:**
- `cargo xtask check hooks` should pass (file hygiene, typos, zizmor, lychee, cargo-deny, ast-grep) - not for frequent use during development; `cargo xtask ci full --no-hooks` skips it when `prek` is unavailable
## 6. Performance & Error Handling Patterns
### Error Handling
**Error handling patterns:**
- `thiserror` is used for implementing crate-specific errors
- Use `anyhow` only in tests
**Logging and tracing patterns:**
- Use `tracing` for debug logging with predefined categories
- Categories are defined in `trace_categories.rs` modules (e.g., `COMMANDS`, `COMPLETION`, `EXPANSION`, `FUNCTIONS`, `INPUT`, `JOBS`, `PARSE`, `PATTERN`, `UNIMPLEMENTED`)
- Usage pattern: `tracing::debug!(target: trace_categories::CATEGORY_NAME, "message")`
- Example: `tracing::debug!(target: trace_categories::JOBS, "Polling job {} for completion...", job_id)`
### Performance Considerations
**Clone vs references:**
- Avoid cloning by default, no reason to make extra copies
- Only use cloning when you really must capture a separate copy for async safety or similarly important reasons
---
## 7. Commit Message Conventions
Following the Linux kernel convention, when AI assistants or advanced coding tools have been used in the development of a change, include an `Assisted-by:` tag in the commit message. This provides transparency about the use of such tools in the development process.
The format for the Assisted-by tag is:
```
Assisted-by: AGENT_NAME:MODEL_VERSION [TOOL1] [TOOL2]
```
Example:
```
Assisted-by: Mistral Vibe:mistral-medium-3.5
```
## Quick Reference Checklist
When making changes to brush:
### Before Starting
- [ ] Understand which crate(s) are affected
- [ ] Check if changes might break dependent crates
- [ ] Identify relevant test files and examples
### During Development
- [ ] Run `cargo check` frequently during development
- [ ] Test changes with package-specific tests first (see section 2 for testing workflow)
- [ ] Update dependent crate usage if needed (see section 3 for compatibility considerations)
- [ ] Add/update examples for major feature additions only (see section 4)
### Before Committing
- [ ] Run full test suite: `cargo test` (see section 2 for complete testing workflow)
- [ ] Format code: `cargo fmt` (see section 5 for tool details)
- [ ] Check linting: `cargo clippy`
- [ ] Use conventional commit format
- [ ] If using AI assistants or advanced coding tools, include an `Assisted-by:` tag in the format `AGENT_NAME:MODEL_VERSION` (following Linux kernel convention)
### Documentation
- [ ] Add rustdoc to exported APIs (see section 4 for documentation standards)
- [ ] Include working examples for major features only
- [ ] Update this guide if new patterns emerge
File diff suppressed because it is too large Load Diff
+131
View File
@@ -0,0 +1,131 @@
# Contributor Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, caste, color, religion, or sexual
identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the overall
community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or advances of
any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email address,
without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official email address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the project administrators. All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series of
actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or permanent
ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within the
community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.1, available at
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
Community Impact Guidelines were inspired by
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
For answers to common questions about this code of conduct, see the FAQ at
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
[https://www.contributor-covenant.org/translations][translations].
[homepage]: https://www.contributor-covenant.org
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
[Mozilla CoC]: https://github.com/mozilla/diversity
[FAQ]: https://www.contributor-covenant.org/faq
[translations]: https://www.contributor-covenant.org/translations
+230
View File
@@ -0,0 +1,230 @@
<!-- omit in toc -->
# Contributing to brush
First off, thanks for taking the time to contribute! ❤️
All types of contributions are encouraged and valued. See the [Table of Contents](#table-of-contents) for different ways to help and details about how this project handles them. Please make sure to read the relevant section before making your contribution. It will make it a lot easier for us maintainers and smooth out the experience for all involved. The community looks forward to your contributions. 🎉
> And if you like the project, but just don't have time to contribute, that's fine. There are other easy ways to support the project and show your appreciation, which we would also be very happy about:
> - Star the project
> - Send us feedback via issues or discussion
> - Refer this project in your project's readme
> - Post about it
> - Tell your friends/colleagues
<!-- omit in toc -->
## Table of Contents
- [Code of Conduct](#code-of-conduct)
- [I Have a Question](#i-have-a-question)
- [I Want To Contribute](#i-want-to-contribute)
- [Independent Implementation and Provenance](#independent-implementation-and-provenance)
- [AI Policy](#ai-policy)
- [Reporting Bugs](#reporting-bugs)
- [Suggesting Enhancements](#suggesting-enhancements)
- [Your First Code Contribution](#your-first-code-contribution)
- [Development Setup and Checks](#development-setup-and-checks)
- [Improving The Documentation](#improving-the-documentation)
- [Styleguides](#styleguides)
- [Commit Messages](#commit-messages)
## Code of Conduct
This project and everyone participating in it is governed by the
[brush Code of Conduct](CODE_OF_CONDUCT.md).
By participating, you are expected to uphold this code. Please report unacceptable behavior
to project admins.
## I Have a Question
> If you want to ask a question, we assume that you have read the available [Documentation](docs).
Before you ask a question, it is best to search for existing [Issues](https://github.com/reubeno/brush/issues) that might help you. In case you have found a suitable issue and still need clarification, you can write your question in this issue. It is also advisable to search the internet for answers first.
If you then still feel the need to ask a question and need clarification, we recommend the following:
- Open an [Issue](https://github.com/reubeno/brush/issues/new).
- Provide as much context as you can about what you're running into.
- Provide project and platform versions, depending on what seems relevant.
We will then take care of the issue as soon as possible.
## I Want To Contribute
### Legal Notice <!-- omit in toc -->
> [!NOTE]
> By contributing, you confirm that you have the necessary rights to submit your
> contribution under the project's [MIT license](LICENSE). Identify any
> third-party material and its license, and preserve required notices. Using AI
> tools does not remove these obligations.
### Independent Implementation and Provenance
Brush is independently implemented. Matching Bash's observable behavior is a
project goal; deriving brush's implementation from Bash's implementation is not
an acceptable way to achieve it.
#### Implementation boundaries
- **Do not copy, translate, port, or adapt Bash implementation material into
brush.** This includes code, comments, implementation-specific tables, and
upstream tests from its code base. Changing languages, identifiers,
formatting, or structure, removing notices, or adding attribution does not
establish independent provenance or grant permission to use the material
under brush's license.
- **Do not inspect or retrieve Bash source implementation material for
implementation guidance.** This includes mirrors, patches, source excerpts in
issues or articles, and source packages installed locally. Do not ask another
person or agent to inspect it on your behalf.
- **Do not route that material through an intermediary.** AI-generated
translations, pseudocode, and implementation summaries derived from
Bash source are not independent inputs to a brush implementation.
- **Apply the same licensing discipline to other third-party material.**
Identify the source, license, and required notices before including it. If
the terms do not permit the proposed inclusion, or the provenance is unclear,
stop and ask maintainers rather than attempting to rewrite around the terms.
#### Behavioral compatibility work
Use documented interfaces, specifications, and independently written behavioral
reproductions. It's useful to run an installed Bash executable as a black-box
oracle; we've implemented a testing framework that simplifies passing original
input scripts through both Bash and brush and compare stdout, stderr, exit
status, filesystem effects, etc. We strongly encourage adding original compat
cases under `brush-shell/tests/cases/`.
Our build and CI processes clone and run third-party test suites without
importing their sources into this repository. Use them as external tests,
not as source material or implementation guidance for brush.
### AI Policy
> [!NOTE]
> AI assistants have arrived. With them come many questions and much debate about policy, rights, appropriate usage, etc. Our evolving experiences show that they can be wielded as powerful tools, but we acknowledge that it is critical that they be used *responsibly* and thoughtfully. We set out the following principles for our project.
* **Human supervision and review remain critical.** You, the human authors, are the contributors to this project. Regardless of which tools you use along the way, you are all fully accountable for your contributions. This means that you are still accountable for ensuring that you have the necessary rights to the content you submit to this project. It also remains your responsibility to review, test, understand, and vouch for all code and content that you submit. Accordingly, you must fully understand all code and other content in your contributions and be able to explain their behavior and purpose.
* **Please be transparent about your use of AI.** When contributions have been significantly assisted by AI tools, note this in your PR description or commit message via an `Assisted-by:` trailer.
* **Our policies and practices will evolve.** Things are moving quickly; we consider it far better to adapt as we go, learn, and iterate rather than to remain static. We remain open to your input and feedback on this policy as it evolves, and commit to engaged, respectful discussion regarding it.
#### References
We take inspiration (and direct language) from the emerging Fedora proposal for [policy regarding AI-assisted contributions](https://communityblog.fedoraproject.org/council-policy-proposal-policy-on-ai-assisted-contributions/) as well as from the Apache Software Foundation's [Generative Tooling Guidance](https://www.apache.org/legal/generative-tooling.html).
### Reporting Bugs
<!-- omit in toc -->
#### Before Submitting a Bug Report
A good bug report shouldn't leave others needing to chase you up for more information. Therefore, we ask you to investigate carefully, collect information and describe the issue in detail in your report. Please complete the following steps in advance to help us fix any potential bug as fast as possible.
- Make sure that you are using the latest version.
- Determine if your bug is really a bug and not an error on your side e.g. using incompatible environment components/versions (Make sure that you have read the [documentation](docs). If you are looking for support, you might want to check [this section](#i-have-a-question)).
- To see if other users have experienced (and potentially already solved) the same issue you are having, check if there is not already a bug report existing for your bug or error in the [bug tracker](https://github.com/reubeno/brush/issues?q=label%3Abug).
- Also make sure to search the internet (including Stack Overflow) to see if users outside of the GitHub community have discussed the issue.
- Collect information about the bug:
- Stack trace (Traceback)
- OS, Platform and Version (Windows, Linux, macOS, x86, ARM)
- Version of the interpreter, compiler, SDK, runtime environment, package manager, depending on what seems relevant.
- Possibly your input and the output
- Can you reliably reproduce the issue? And can you also reproduce it with older versions?
<!-- omit in toc -->
#### How Do I Submit a Good Bug Report?
We use GitHub issues to track bugs and errors. If you run into an issue with the project:
- Open an [Issue](https://github.com/reubeno/brush/issues/new).
- Explain the behavior you would expect and the actual behavior.
- Please provide as much context as possible and describe the *reproduction steps* that someone else can follow to recreate the issue on their own. This usually includes your code. For good bug reports you should isolate the problem and create a reduced test case.
- Provide the information you collected in the previous section.
Once it's filed:
- The project team will label the issue accordingly.
- A team member will try to reproduce the issue with your provided steps. If there are no reproduction steps or no obvious way to reproduce the issue, the team will ask you for those steps and mark the issue as `needs-repro`. Bugs with the `needs-repro` tag will not be addressed until they are reproduced.
- If the team is able to reproduce the issue, it will be marked `needs-fix`, as well as possibly other tags (such as `critical`), and the issue will be left to be [implemented by someone](#your-first-code-contribution).
### Suggesting Enhancements
This section guides you through submitting an enhancement suggestion for brush, **including completely new features and minor improvements to existing functionality**. Following these guidelines will help maintainers and the community to understand your suggestion and find related suggestions.
<!-- omit in toc -->
#### Before Submitting an Enhancement
- Make sure that you are using the latest version.
- Read the [documentation](docs) carefully and find out if the functionality is already covered, maybe by an individual configuration.
- Perform a [search](https://github.com/reubeno/brush/issues) to see if the enhancement has already been suggested. If it has, add a comment to the existing issue instead of opening a new one.
- Find out whether your idea fits with the scope and aims of the project. It's up to you to make a strong case to convince the project's developers of the merits of this feature. Keep in mind that we want features that will be useful to the majority of our users and not just a small subset. If you're just targeting a minority of users, consider writing an add-on/plugin library.
<!-- omit in toc -->
#### How Do I Submit a Good Enhancement Suggestion?
Enhancement suggestions are tracked as [GitHub issues](https://github.com/reubeno/brush/issues).
- Use a **clear and descriptive title** for the issue to identify the suggestion.
- Provide a **step-by-step description of the suggested enhancement** in as many details as possible.
- **Describe the current behavior** and **explain which behavior you expected to see instead** and why. At this point you can also tell which alternatives do not work for you.
- **Explain why this enhancement would be useful** to most brush users. You may also want to point out the other projects that solved it better and which could serve as inspiration.
### Your First Code Contribution
We're excited to see your first contribution! We don't bite, so we'd much rather you publish something than not. If you submit your PR as a draft we're happy to give it a quick scan and provide some early input. Once the PR is marked "ready for review" we'll look through it more carefully.
### Development Setup and Checks
Building brush needs nothing but [rustup](https://rustup.rs/). `rust-toolchain.toml`
pins the exact toolchain, along with the `rustfmt` and `clippy` components, and
rustup installs it automatically the first time you run `cargo` in the repository,
so a fresh clone is ready to go after `cargo build`. The pin keeps formatting and
lint results identical between your machine and CI; Dependabot bumps it as new
Rust releases ship.
All development tasks run through `cargo xtask`, which is the single entry point
and is what CI invokes too:
| Command | When | Needs |
|---|---|---|
| `cargo xtask ci quick` | Inner loop (~7s warm): fmt, crate structure, build, clippy, unit tests | Rust toolchain only |
| `cargo xtask ci full` | Before opening a PR (~60s warm): adds the pre-commit hooks, schemas, integration tests | also `prek` |
| `cargo xtask check <name>` | One check at a time; `cargo xtask check --help` lists them | varies |
`cargo xtask ci full` also runs the third-party linters -- spelling (typos),
GitHub Actions analysis (zizmor), link checking (lychee), dependency auditing
(cargo-deny), structural lints (ast-grep), `release-plz.toml` schema validation
(check-jsonschema), and assorted file hygiene. Those are defined and version-pinned in
`.pre-commit-config.yaml` and executed by [prek](https://prek.j178.dev):
```bash
cargo binstall prek # or: uv tool install prek / brew install prek
```
If you would rather not install it, `cargo xtask ci full --no-hooks` skips that
portion; CI will still run it.
`cargo xtask ci full` reproduces every check CI performs that does not need a
different machine or toolchain. CI additionally covers the three-OS matrix, the
MSRV toolchain, cross-compilation, coverage, benchmarks, the bash-completion
suite, the nightly-only checks, and CodeQL -- so a green local run is a strong
signal, not a guarantee.
#### Git hooks (optional)
Git hooks are entirely optional; the commands above are the supported path. If
you want them:
```bash
prek install
```
That wires up formatting and hygiene fixes on commit, Conventional Commits
validation on the commit message, and `cargo xtask ci quick` on push -- the same
command you would otherwise run by hand.
### Improving The Documentation
We appreciate documentation updates as much as code updates! We try to follow the [Diátaxis](https://diataxis.fr/) approach to technical docs. The short version is we recognize the need for tutorials, how-to guides, reference material, and sometimes longer-form explanation docs--*and* we recognize the value of being intentional about organizing by those categories.
## Styleguides
### Commit Messages
When you look through our repo history, you'll see commits prefixed with a tag like `feat:` or `fix:`. We follow the [Conventional Commits](https://www.conventionalcommits.org/) specification to maintain consistent readability of commits, both for humans and mechanical release processes.
## Attribution
This guide is based on the **contributing-gen**. [Make your own](https://github.com/bttger/contributing-gen)!
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 reuben olinsky
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+163
View File
@@ -0,0 +1,163 @@
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/extras/brand/brush-wordmark-white.svg">
<img src="docs/extras/brand/brush-wordmark-black.svg" alt="brush" width="380">
</picture>
</div>
<p align="center"><em>Bash-compatible. Embeddable. Extensible.</em></p>
<p align="center">
<a href="https://crates.io/crates/brush-shell"><img src="https://img.shields.io/crates/v/brush-shell?style=flat-square" alt="crates.io version"/></a>
<a href="https://crates.io/crates/brush-shell"><img src="https://img.shields.io/crates/d/brush-shell?style=flat-square" alt="crates.io downloads"/></a>
<a href="https://github.com/reubeno/brush/actions/workflows/ci.yaml"><img src="https://img.shields.io/github/actions/workflow/status/reubeno/brush/ci.yaml?branch=main&style=flat-square&label=CI" alt="CI status"/></a>
<a href="brush-shell/tests/cases"><img src="https://img.shields.io/badge/compat_tests-2%2C500%2B-0d9488?style=flat-square" alt="2,500+ compatibility tests"/></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-0d9488?style=flat-square" alt="MIT license"/></a>
<a href="https://discord.gg/kPRgC9j3Tj"><img src="https://img.shields.io/badge/discord-join-5865F2?style=flat-square&logo=discord&logoColor=white" alt="Discord"/></a>
</p>
<p align="center">
<a href="https://brush.sh">Website</a> ·
<a href="https://brush.sh/getting-started/install/">Install</a> ·
<a href="https://brush.sh/reference/compatibility/">Compatibility</a> ·
<a href="https://brush.sh/releases/">Release notes</a> ·
<a href="https://discord.gg/kPRgC9j3Tj">Discord</a>
</p>
<hr/>
[brush](https://brush.sh) is a Bash-compatible shell written in Rust.
***Run it as your everyday shell.*** It loads your existing `.bashrc`, aliases, functions, and completions, runs the scripts you already have, and adds some modern shell amenities: history-based suggestions and live syntax highlighting.
***Build with it.*** [`brush-core`](https://docs.rs/brush-core) is a tested implementation of Bash semantics that your own Rust software can embed and extend. [`brush-parser`](https://docs.rs/brush-parser), which turns shell source into a syntax tree, is used on its own by other projects, including [Zed](https://github.com/zed-industries/zed), [Vite+](https://github.com/voidzero-dev/vite-plus), and [oh-my-pi](https://github.com/can1357/oh-my-pi), to parse shell commands.
<p align="center">
<img src="https://github.com/user-attachments/assets/0e64d1b9-7e4e-43be-8593-6c1b9607ac52" alt="brush in action: bash-completion, job control, and shell functions" width="80%"/>
</p>
## Why brush
Bash is the shell most of us already know. It's in our fingers, our scripts, and our team's runbooks. brush keeps that behavior as it is, and adds suggestions, highlighting, and the other modern conveniences for those who want them. Keeping that promise means being faithful, so every change is tested against Bash itself.
The brush shell and its libraries are the same code. Embedding `brush-core` gives your software the behavior the shell has, and an improvement to one is an improvement to the other. brush is written in Rust with extension points designed in: custom builtins already plug in alongside the standard ones, and we intend to open more of the shell's internals the same way, so that tools can observe and extend it natively.
## Get started
Install the latest release on Linux or macOS:
```sh
curl --proto '=https' --tlsv1.2 -fsSL https://brush.sh/install.sh | sh
```
Or with Homebrew or cargo:
```sh
brew install brush # Homebrew, on macOS or Linux
cargo install --locked brush-shell # build from crates.io
cargo binstall brush-shell # prebuilt, via cargo-binstall
```
Packagers have also brought brush to [Homebrew](https://formulae.brew.sh/formula/brush), [Arch Linux](https://archlinux.org/packages/extra/x86_64/brush/), [Fedora via Terra](https://terrapkg.com/), [MSYS2](https://packages.msys2.org/base/mingw-w64-brush), [Nix](https://search.nixos.org/packages?channel=unstable&query=brush), and [more](https://repology.org/project/brush/versions). See [all install options](https://brush.sh/getting-started/install/) for details.
Then run `brush`. It reads the same startup files Bash does, so it picks up your Bash setup as it is. To give brush a look of its own, add a `~/.brushrc`.
## Use the shell
- **Your configuration comes with you.** `.bashrc`, `.bash_profile`, aliases, functions, `PS1`, `PROMPT_COMMAND`, and prompt tools like [starship](https://starship.rs) all work as they do in Bash.
- **Programmable completion.** Works with the [bash-completion](https://github.com/scop/bash-completion) package you already have installed, so `git`, `docker`, `systemctl`, and the rest complete as usual.
- **Job control.** Background jobs, suspend and resume, `fg`, `bg`, and `jobs`.
- **Auto-suggestions.** History-based hints as you type, on by default. Powered by [reedline](https://github.com/nushell/reedline).
- **Syntax highlighting.** Live, as you type, one setting away: `brush --enable-highlighting` or `syntax-highlighting = true` under `[ui]` in brush's [TOML config file](https://brush.sh/reference/config-files/).
- **Scripts, too.** The builtins, expansions, arrays, redirections, and options your scripts already use, with `set -e`, `pipefail`, `extglob`, `globstar`, and friends.
- **Experimental extras.** zsh-style `precmd` and `preexec` hooks, and terminal shell integration for VS Code, iTerm2, and other supporting terminals. Both are off by default; see [experimental features](https://brush.sh/reference/experimental/).
> Not everything is there yet. Most notably, `select`, `wait -n`, `disown`, some traps, and a set of edge cases are still missing. The [compatibility reference](https://brush.sh/reference/compatibility/) lists what works, what's partial, and what isn't implemented. If you spot something that doesn't look right, please let us know by filing an issue.
## Build with the engine
The same implementation that runs the shell is available as a set of crates. Create a shell, run Bash-compatible code in it, and inspect the result:
```rust
let mut shell = brush_core::Shell::builder().build().await?;
let result = shell
.run_string(
r#"greet() { echo "Hello, $1!"; }; greet world"#,
&brush_core::SourceInfo::default(),
&shell.default_exec_params(),
)
.await?;
assert!(result.is_success());
```
For more, see the examples: [register a builtin written in Rust](brush-clap-adapter/examples/custom-builtin.rs) alongside the standard ones, [call a shell function from Rust](brush-core/examples/call-func.rs) with control over its I/O, or [parse a script and serialize its syntax tree](brush-parser/examples/serde.rs). The [examples guide](docs/reference/examples.md) describes each one and when it's useful.
| Crate | API docs | What it provides |
|---|---|---|
| [`brush-core`](brush-core) | [docs.rs](https://docs.rs/brush-core) | The shell runtime: expansion, execution, jobs, completion, and the `Shell` API. Start here to embed. |
| [`brush-parser`](brush-parser) | [docs.rs](https://docs.rs/brush-parser) | Tokenizer and parser for Bash and POSIX shell syntax, with an optional `serde` AST. Usable independently from the other crates. |
| [`brush-builtins`](brush-builtins) | [docs.rs](https://docs.rs/brush-builtins) | The standard builtins, usable as a set. Depends on `brush-core`. |
| [`brush-interactive`](brush-interactive) | [docs.rs](https://docs.rs/brush-interactive) | Line editing, highlighting, suggestions, and completion UI, built on [reedline](https://github.com/nushell/reedline). Depends on `brush-core`. |
| [`brush-shell`](brush-shell) | [docs.rs](https://docs.rs/brush-shell) | The `brush` binary and its command line. Depends on everything else. |
Optional crates add bundled [coreutils builtins](brush-coreutils-builtins) and [experimental builtins](brush-experimental-builtins).
## How we test it
- **Compatibility suite.** More than 2,500 [test cases](brush-shell/tests/cases) run the same script under brush and Bash and compare stdout, stderr, exit status, and filesystem side effects. Every pull request runs them on Linux (x86_64 and aarch64) and macOS, and inside Arch Linux, Debian, Fedora, NixOS, openSUSE, and Azure Linux containers.
- **Real tools, real tests.** [End-to-end suites](e2e) exercise brush with the tools people pair with a shell: [fzf](https://github.com/junegunn/fzf), [atuin](https://github.com/atuinsh/atuin), [starship](https://github.com/starship/starship), [zoxide](https://github.com/ajeetdsouza/zoxide), [mise](https://github.com/jdx/mise), and others. Where a project has its own shell-integration tests, those run against brush; where it doesn't, we've added some.
- **Everything else.** CodeQL, dependency auditing, and benchmarks on every pull request, plus fuzz targets for the parser and the highlighter.
- **Verifiable releases.** Binaries are built by the release workflow with signed build provenance. The install script checks each download's SHA-256 checksum and, when the GitHub CLI is available, its attestation.
## Platforms
| Tier | Platforms | What that means |
|---|---|---|
| **Supported** | _Linux_: x86_64, aarch64 (glibc, musl) <br/> macOS: aarch64 | Prebuilt binaries for every release, the full test suite on Linux x86_64 and aarch64 (glibc) and macOS aarch64, build checks for Linux musl and macOS x86_64, and daily-driver quality. |
| **Preview** | _Windows_: x86_64, aarch64 | Windows receives build and static checks in CI along with a small brush-specific test suite; prebuilt binaries are on the way. Parts of the shell are still missing or limited but it's functional for basic usage, particularly when paired with Microsoft's build of [coreutils for Windows](https://github.com/microsoft/coreutils). |
| **Experimental** | _WASI 0.3_ | WASI builds run under wasmtime in CI. There are *many* significant gaps; more of a starting point for further experimentation. |
| **Builds only** | _Android_, _FreeBSD_, _NetBSD_, _OpenBSD_ | Cross-compiled in CI so they keep compiling. No official tests, binaries, or validation. |
## Community and contributing
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/extras/brand/mascot/turtle-dark.png">
<img src="docs/extras/brand/mascot/turtle-light.png" alt="" width="110" align="right" hspace="24" vspace="8">
</picture>
brush started as a curiosity-driven project, and that curiosity is still what drives it. Most contributors arrived the same way: tried brush, hit something unexpected, and helped fix it. However much time you have, there's a way in.
- **Try it and tell us what you find.** A script or command that behaves differently in Bash is the most useful report we get. [File a compatibility bug](https://github.com/reubeno/brush/issues/new?template=compatibility-bug.yml), or a [feature request](https://github.com/reubeno/brush/issues/new?template=feature-request.yml) if brush could do more for you.
- **Say hello on [Discord](https://discord.gg/kPRgC9j3Tj)**, whether you have a question, an idea, or a shell setup you'd like to see work. Or just come to chat and hang out.
- **Pick up an issue.** Issues labeled ["good first issue"](https://github.com/reubeno/brush/labels/good%20first%20issue) are intended for newcomers, and ["help wanted"](https://github.com/reubeno/brush/labels/help%20wanted) tag additional items where an extra pair of hands would help. Draft pull requests are welcome; if you ask us, we'll take an early look before you polish.
- **Read the [contribution guidelines](CONTRIBUTING.md)** for the workflow, and the [technical docs](docs/README.md) for how brush is built and tested. Everyone here is expected to follow the [code of conduct](CODE_OF_CONDUCT.md).
A star, a mention in your own project's README, or a post about brush all help too.
Curious how brush relates to other shells? See [related projects](docs/reference/related-projects.md).
### Contributors
brush is shaped by everyone who has given it their time: bug reports with a reproducer, reviews that caught what we missed, packages for distributions we'd never touched, and a good deal of patient chat. Thank you, all of you. It's appreciated more than a line in a README can say.
<a href="https://github.com/reubeno/brush/graphs/contributors"><img src="https://contrib.rocks/image?repo=reubeno/brush" alt="brush contributors"/></a>
There's room here for you, too.
## Credits
brush stands on the shoulders of excellent open source projects, including (but not limited to):
- [reedline](https://github.com/nushell/reedline) for line editing and interactive features
- [winnow-args](https://github.com/lu-zero/winnow-args) for command-line parsing
- [fancy-regex](https://github.com/fancy-regex/fancy-regex) for regular expressions
- [tokio](https://github.com/tokio-rs/tokio) as the async runtime
- [nix](https://github.com/nix-rust/nix) for accessing Unix and POSIX APIs
- [criterion.rs](https://github.com/bheisler/criterion.rs) for benchmarking
- [bash-completion](https://github.com/scop/bash-completion) for its completion test suite
---
Released as open source under the [MIT license](LICENSE). Built in the open by the [brush community](https://github.com/reubeno/brush/graphs/contributors).
@@ -0,0 +1 @@
__pycache__/
+974
View File
@@ -0,0 +1,974 @@
#!/usr/bin/env python3
"""
Shell benchmarking harness for brush.
Runs shell script snippets against test and reference shells using
warmup, calibration, and timed execution phases.
NOTE: This script was authored by an AI coding agent in conjunction
with human guidance.
"""
import argparse
import json
import math
import os
import re
import shutil
import statistics
import subprocess
import sys
from dataclasses import dataclass
from typing import Optional
@dataclass
class BenchmarkCase:
"""
A benchmark case measuring a specific shell operation.
The benchmark runs `loop_body` in a tight loop, with optional `setup`
executed once before timing and `cleanup` after.
Exit-code contract:
- The `setup` phase may validate preconditions and exit with non-zero
status to signal failure (e.g., missing external commands).
- The generated script always ends with `exit 0` to confirm success.
- Any non-zero exit from the shell is treated as a fatal benchmark error.
"""
loop_body: str
setup: Optional[str] = None
cleanup: Optional[str] = None
@dataclass
class TimingResult:
"""
Timing data parsed from shell's `time` builtin output.
Captures wall-clock time (real), user CPU time, and system CPU time.
"""
real_seconds: float
user_seconds: float
sys_seconds: float
@dataclass
class BenchmarkResult:
"""
Aggregated results from running a benchmark across multiple samples.
Contains per-iteration timing statistics (median, MAD, min, max) computed
from `sample_count` independent timed runs. Uses median and MAD (median
absolute deviation) instead of mean and stddev for robustness against
outliers from system jitter.
"""
case_name: str
iterations_per_sample: int
sample_count: int
per_iteration_ns: float # median across samples
per_iteration_ns_mad: float # MAD scaled by 1.4826 for normal comparability
per_iteration_ns_min: float
per_iteration_ns_max: float
shell_path: str
@dataclass
class ComparisonResult:
"""
Performance comparison between test shell and reference shell.
The `change` ratio is reference_time / test_time, so values > 1.0
indicate the test shell is faster than the reference.
"""
case_name: str
test_result: BenchmarkResult
reference_result: BenchmarkResult
change: float # ratio: ref_time / test_time (>1 = test is faster)
# =============================================================================
# Built-in benchmark cases
# =============================================================================
BENCHMARK_CASES: dict[str, BenchmarkCase] = {
"assignment": BenchmarkCase(
loop_body="x=42",
),
"colon": BenchmarkCase(
loop_body=":",
),
"echo_builtin": BenchmarkCase(
loop_body="echo >/dev/null",
),
"echo_cmd": BenchmarkCase(
setup="command -v /usr/bin/echo >/dev/null || exit 1",
loop_body="/usr/bin/echo >/dev/null",
),
"increment": BenchmarkCase(
setup="x=0",
loop_body="((x++))",
),
"subshell": BenchmarkCase(
loop_body="(:)",
),
"cmdsubst": BenchmarkCase(
loop_body=': $(:)',
),
"var_expand": BenchmarkCase(
setup='myvar="hello world"',
loop_body=': "$myvar"',
),
"func_call": BenchmarkCase(
setup="myfunc() { :; }",
loop_body="myfunc",
),
"array_access": BenchmarkCase(
setup='myarr=(a b c d e f g h i j)',
loop_body=': "${myarr[5]}"',
),
"pattern_match": BenchmarkCase(
loop_body='[[ "hello world" == hello* ]]',
),
"regex_match": BenchmarkCase(
loop_body='[[ "hello world" =~ ^hello.* ]]',
),
"if_taken": BenchmarkCase(
loop_body="if [[ 1 -eq 1 ]]; then :; fi",
),
"if_not_taken": BenchmarkCase(
loop_body="if [[ 0 -eq 1 ]]; then :; fi",
),
}
# =============================================================================
# Time output parsing
# =============================================================================
def parse_time_output(stderr: str) -> TimingResult:
"""
Parse bash's default time output format.
Expected format (on stderr):
real 0m0.123s
user 0m0.045s
sys 0m0.012s
The generated benchmark scripts explicitly set TIMEFORMAT to match bash's
default format, ensuring consistent parsing across shells that support
this variable. Locale settings (LC_NUMERIC) could theoretically affect
decimal separators, but this is rare in practice.
"""
# Pattern matches: real/user/sys followed by XmY.ZZZs format
pattern = r"(real|user|sys)\s+(\d+)m([\d.]+)s"
matches = re.findall(pattern, stderr)
if len(matches) < 3:
raise ValueError(f"Failed to parse time output: {stderr!r}")
results = {}
for label, minutes, seconds in matches:
total_seconds = int(minutes) * 60 + float(seconds)
results[label] = total_seconds
return TimingResult(
real_seconds=results.get("real", 0.0),
user_seconds=results.get("user", 0.0),
sys_seconds=results.get("sys", 0.0),
)
# =============================================================================
# Script generation
# =============================================================================
def generate_benchmark_script(case: BenchmarkCase, iterations: int) -> str:
"""
Generate a complete benchmark script for the given case.
The script:
1. Sets TIMEFORMAT to match bash's default output format
2. Runs optional setup (which may exit non-zero to abort)
3. Times the loop body for the specified iterations
4. Runs optional cleanup
5. Exits with 0 to confirm successful completion
"""
lines = []
# Pin TIMEFORMAT to bash's default format for consistent parsing
# Format: real\t%lR\nuser\t%lU\nsys\t%lS (where %l uses Mm.SSSs format)
lines.append(r"TIMEFORMAT=$'real\t%lR\nuser\t%lU\nsys\t%lS'")
# Setup (optional) - may exit non-zero to signal precondition failure
if case.setup:
lines.append(case.setup)
# Timed loop (no braces needed around for loop)
lines.append(
f"time for ((i=0; i<{iterations}; i++)); do {case.loop_body}; done"
)
# Cleanup (optional)
if case.cleanup:
lines.append(case.cleanup)
# Explicit success exit to confirm script completed without error
lines.append("exit 0")
return "\n".join(lines)
# =============================================================================
# Shell execution
# =============================================================================
def get_shell_flags(shell_path: str) -> list[str]:
"""Get appropriate flags for running a shell in benchmark mode."""
# Check if this looks like brush
shell_name = shell_path.split("/")[-1]
if "brush" in shell_name:
return [
"--norc",
"--noprofile",
"--input-backend=basic",
"--disable-bracketed-paste",
"--disable-color",
]
else:
# Assume bash-like shell
return ["--norc", "--noprofile"]
def run_shell_script(shell_path: str, script: str) -> subprocess.CompletedProcess:
"""Run a script using the specified shell."""
flags = get_shell_flags(shell_path)
cmd = [shell_path] + flags + ["-c", script]
return subprocess.run(
cmd,
capture_output=True,
text=True,
)
# =============================================================================
# Benchmark execution
# =============================================================================
# -----------------------------------------------------------------------------
# Unit conversion constants
# -----------------------------------------------------------------------------
# Nanoseconds per second, used for timing conversions.
NS_PER_SECOND = 1_000_000_000
# -----------------------------------------------------------------------------
# Warmup phase constants
# -----------------------------------------------------------------------------
# Default warmup duration in seconds. Warmup ensures CPU caches are hot and
# the shell's internal state is stable before measurement begins.
# Set to 0 to skip warmup entirely.
DEFAULT_WARMUP_DURATION_SECONDS = 0.5
# Number of iterations for the quick probe used to estimate per-iteration time
# during warmup. This should be small enough to complete quickly but large
# enough to get a rough timing estimate.
WARMUP_PROBE_ITERATIONS = 100
# Multiplier applied to WARMUP_PROBE_ITERATIONS when the probe completes too
# fast to measure (real_seconds == 0). This is a fallback for extremely fast
# operations where even 100 iterations complete in sub-millisecond time.
WARMUP_FALLBACK_MULTIPLIER = 10
# -----------------------------------------------------------------------------
# Calibration phase constants
# -----------------------------------------------------------------------------
# Starting iteration count for calibration. We double this until we get a
# measurable time (>= CALIBRATION_MIN_TIME_SECONDS).
CALIBRATION_MIN_ITERATIONS = 100
# Minimum elapsed time in seconds for reliable calibration measurement.
# Below this threshold, timer resolution and process startup overhead
# dominate the measurement.
CALIBRATION_MIN_TIME_SECONDS = 0.1
# Maximum iterations to attempt during calibration. Prevents runaway loops
# if something is fundamentally broken.
CALIBRATION_MAX_ITERATIONS = 100_000_000
# Maximum iterations per sample. Prevents extremely long-running samples
# even if per-iteration time is very small.
MAX_TARGET_ITERATIONS = 1_000_000_000
# -----------------------------------------------------------------------------
# Statistical sampling constants
# -----------------------------------------------------------------------------
# Default number of independent samples to collect. More samples improve
# statistical reliability but increase total benchmark time.
DEFAULT_SAMPLES = 10
# Minimum duration per sample in seconds. Samples shorter than this are
# unreliable due to timer resolution and system noise.
MIN_SAMPLE_DURATION_SECONDS = 0.5
# Scale factor to convert MAD (median absolute deviation) to an estimate
# comparable to standard deviation for normally distributed data.
# For a normal distribution, MAD * 1.4826 ≈ stddev.
MAD_NORMAL_SCALE_FACTOR = 1.4826
# Coefficient of variation threshold (as percentage) above which we flag
# results as having high variance, indicating unreliable measurements.
HIGH_VARIANCE_THRESHOLD_PCT = 10.0
# -----------------------------------------------------------------------------
# Performance comparison constants
# -----------------------------------------------------------------------------
# Default benchmark duration in seconds (total time budget per benchmark).
DEFAULT_DURATION_SECONDS = 10.0
# Threshold for classifying performance change as "faster" (test outperforms
# reference). A value of 1.1 means test must be at least 10% faster.
CHANGE_FASTER_THRESHOLD = 1.1
# Threshold for classifying performance change as "similar" (no significant
# difference). A value of 0.95 means test can be up to 5% slower and still
# be considered similar. Below this is classified as "slower".
CHANGE_SIMILAR_THRESHOLD = 0.95
def run_benchmark_phase(
shell_path: str, case: BenchmarkCase, iterations: int
) -> TimingResult:
"""
Run a single benchmark phase and return timing.
Raises RuntimeError if the shell exits with non-zero status, which
indicates either a setup precondition failure or an execution error.
"""
script = generate_benchmark_script(case, iterations)
result = run_shell_script(shell_path, script)
if result.returncode != 0:
raise RuntimeError(
f"Benchmark failed (exit {result.returncode}). This may indicate "
f"a setup precondition failure or shell execution error.\n"
f"stdout: {result.stdout}\n"
f"stderr: {result.stderr}"
)
return parse_time_output(result.stderr)
def run_benchmark(
shell_path: str,
case_name: str,
case: BenchmarkCase,
target_duration: float,
num_samples: int = DEFAULT_SAMPLES,
warmup_duration: float = DEFAULT_WARMUP_DURATION_SECONDS,
verbose: bool = False,
) -> BenchmarkResult:
"""
Run a complete benchmark with warmup, calibration, and multiple sample phases.
Args:
shell_path: Path to shell executable
case_name: Name of the benchmark case
case: Benchmark case to run
target_duration: Total time budget in seconds (divided among samples)
num_samples: Number of independent samples to collect
warmup_duration: Time in seconds to spend warming up (default: 0.5s; 0 to skip)
verbose: Print progress information
Returns:
BenchmarkResult with timing statistics (median, MAD) across all samples
"""
# Determine actual sample count based on duration constraints
sample_duration = target_duration / num_samples
actual_samples = num_samples
if sample_duration < MIN_SAMPLE_DURATION_SECONDS:
actual_samples = max(1, int(target_duration / MIN_SAMPLE_DURATION_SECONDS))
sample_duration = target_duration / actual_samples
# Always warn when we reduce samples (even in non-verbose mode)
print(
f" ⚠️ Duration too short for {num_samples} samples; "
f"using {actual_samples} sample(s) of {sample_duration:.1f}s instead",
file=sys.stderr,
)
# Phase 1: Time-based warmup (skip if warmup_duration is 0)
if warmup_duration > 0:
if verbose:
print(f" 🔥 Warming up ({warmup_duration}s)...", file=sys.stderr)
warmup_probe = run_benchmark_phase(shell_path, case, WARMUP_PROBE_ITERATIONS)
warmup_per_iter = warmup_probe.real_seconds / WARMUP_PROBE_ITERATIONS
if warmup_per_iter > 0:
warmup_iterations = max(1, int(warmup_duration / warmup_per_iter))
else:
# Probe was too fast to measure - use fallback iteration count
warmup_iterations = WARMUP_PROBE_ITERATIONS * WARMUP_FALLBACK_MULTIPLIER
if verbose:
print(
f" (warmup probe too fast to measure, using {warmup_iterations} iterations)",
file=sys.stderr,
)
run_benchmark_phase(shell_path, case, warmup_iterations)
elif verbose:
print(" 🔥 Skipping warmup (duration=0)", file=sys.stderr)
if verbose:
print(" 📏 Calibrating...", file=sys.stderr)
# Phase 2: Adaptive calibration
# Keep doubling iterations until we get a measurable time
calibration_iterations = CALIBRATION_MIN_ITERATIONS
calibration_timing: Optional[TimingResult] = None
while calibration_iterations <= CALIBRATION_MAX_ITERATIONS:
calibration_timing = run_benchmark_phase(shell_path, case, calibration_iterations)
if calibration_timing.real_seconds >= CALIBRATION_MIN_TIME_SECONDS:
# We have enough time for reliable measurement
break
if verbose:
print(
f" (calibration too fast: {calibration_timing.real_seconds:.4f}s "
f"for {calibration_iterations} iters, doubling...)",
file=sys.stderr,
)
calibration_iterations *= 2
assert calibration_timing is not None # Loop always runs at least once
if calibration_timing.real_seconds < CALIBRATION_MIN_TIME_SECONDS:
# Even at max iterations, still too fast - use what we have
if verbose:
print(
f" (warning: calibration capped at {calibration_iterations} iterations)",
file=sys.stderr,
)
# Calculate iterations needed for each sample
per_iteration_seconds = calibration_timing.real_seconds / calibration_iterations
if per_iteration_seconds <= 0:
# Fallback if calibration was too fast: use minimum measurable time
# divided by max iterations as a conservative lower bound
per_iteration_seconds = CALIBRATION_MIN_TIME_SECONDS / CALIBRATION_MAX_ITERATIONS
iterations_per_sample = max(1, int(sample_duration / per_iteration_seconds))
iterations_per_sample = min(iterations_per_sample, MAX_TARGET_ITERATIONS)
if verbose:
print(
f" ⏱️ Running {actual_samples} samples of {iterations_per_sample} iterations "
f"(est. {sample_duration:.1f}s each)...",
file=sys.stderr,
)
# Phase 3: Collect multiple samples
sample_ns_values: list[float] = []
for sample_idx in range(actual_samples):
sample_timing = run_benchmark_phase(shell_path, case, iterations_per_sample)
per_iter_ns = (sample_timing.real_seconds / iterations_per_sample) * NS_PER_SECOND
sample_ns_values.append(per_iter_ns)
if verbose:
print(
f" Sample {sample_idx + 1}/{actual_samples}: "
f"{format_duration_ns(per_iter_ns)}/iter",
file=sys.stderr,
)
# Compute statistics using median and MAD for robustness against outliers
median_ns = statistics.median(sample_ns_values)
if len(sample_ns_values) > 1:
# MAD = median(|x_i - median(x)|), scaled to be comparable to stddev
deviations = [abs(x - median_ns) for x in sample_ns_values]
mad_ns = statistics.median(deviations) * MAD_NORMAL_SCALE_FACTOR
else:
mad_ns = 0.0
min_ns = min(sample_ns_values)
max_ns = max(sample_ns_values)
return BenchmarkResult(
case_name=case_name,
iterations_per_sample=iterations_per_sample,
sample_count=actual_samples,
per_iteration_ns=median_ns,
per_iteration_ns_mad=mad_ns,
per_iteration_ns_min=min_ns,
per_iteration_ns_max=max_ns,
shell_path=shell_path,
)
# =============================================================================
# Output formatting
# =============================================================================
def format_duration_ns(ns: float) -> str:
"""Format a duration in nanoseconds to a human-readable string."""
if ns >= NS_PER_SECOND:
return f"{ns / NS_PER_SECOND:.3f} s"
elif ns >= 1e6:
return f"{ns / 1e6:.3f} ms"
elif ns >= 1e3:
return f"{ns / 1e3:.3f} µs"
else:
return f"{ns:.3f} ns"
def format_result_with_variance(result: BenchmarkResult) -> str:
"""
Format a benchmark result with variance indicator.
Uses MAD-based coefficient of variation for robustness against outliers.
"""
median_str = format_duration_ns(result.per_iteration_ns)
if result.sample_count <= 1 or result.per_iteration_ns == 0:
return f"{median_str}/iter"
# Coefficient of variation using MAD (scaled to be stddev-comparable)
cv_pct = (result.per_iteration_ns_mad / result.per_iteration_ns) * 100
# Flag high variance with warning indicator
if cv_pct > HIGH_VARIANCE_THRESHOLD_PCT:
return f"{median_str}/iter (±{cv_pct:.1f}% ⚠️)"
else:
return f"{median_str}/iter (±{cv_pct:.1f}%)"
def format_change(change: float) -> str:
"""
Format performance change ratio with emoji indicator and percentage.
Args:
change: Ratio of reference_time / test_time. Values > 1.0 mean
the test shell is faster than the reference.
Returns:
Formatted string with emoji and percentage change.
"""
# Calculate percentage change (negative = faster, positive = slower)
# change > 1 means test is faster, so pct_change should be negative
pct_change = (1.0 / change - 1.0) * 100
if change >= CHANGE_FASTER_THRESHOLD:
emoji = "🚀"
text = f"{pct_change:.1f}% (faster)"
elif change >= CHANGE_SIMILAR_THRESHOLD:
emoji = "⚖️ "
text = f"{pct_change:+.1f}% (similar)"
else:
emoji = "🐢"
text = f"+{pct_change:.1f}% (slower)"
return f"{emoji} {text}"
def print_human_single_shell_results(results: list[BenchmarkResult], shell: str):
"""Print results for a single shell (no comparison)."""
print()
print("=" * 70)
print("📊 Shell Benchmark Results")
print("=" * 70)
print(f" Shell: {shell}")
print("=" * 70)
print()
for result in results:
print(f"🧪 {result.case_name}")
print(f" {format_result_with_variance(result)}")
print(f" ({result.sample_count} samples, {result.iterations_per_sample} iters/sample)")
print()
def compute_geometric_mean_change(comparisons: list[ComparisonResult]) -> float:
"""
Compute the geometric mean of change ratios across comparisons.
Geometric mean is the correct way to average ratios/rates. Returns 1.0
if the list is empty or if any change ratio is non-positive (which would
indicate a measurement error).
"""
if not comparisons:
return 1.0
# Filter out any non-positive values (shouldn't happen, but guard against it)
valid_changes = [comp.change for comp in comparisons if comp.change > 0]
if not valid_changes:
return 1.0
log_sum = sum(math.log(change) for change in valid_changes)
return math.exp(log_sum / len(valid_changes))
def print_human_results(
comparisons: list[ComparisonResult], test_shell: str, ref_shell: str
):
"""Print comparison results in human-readable format with emoji."""
print()
print("=" * 70)
print("📊 Shell Benchmark Results")
print("=" * 70)
print(f" Test shell: {test_shell}")
print(f" Reference shell: {ref_shell}")
print("=" * 70)
print()
for comp in comparisons:
print(f"🧪 {comp.case_name}")
print(f" Test: {format_result_with_variance(comp.test_result)}")
print(f" Reference: {format_result_with_variance(comp.reference_result)}")
print(f" Change: {format_change(comp.change)}")
print()
# Summary - use geometric mean for averaging ratios (more statistically sound)
print("-" * 70)
avg_change = compute_geometric_mean_change(comparisons)
print(f"📈 Overall change (geometric mean): {format_change(avg_change)}")
print()
def _result_to_json(result: BenchmarkResult) -> dict:
"""
Convert a BenchmarkResult to a JSON-serializable dict.
Note: per_iteration_ns is the median, and per_iteration_ns_mad is the
MAD (median absolute deviation) scaled by 1.4826 for normal comparability.
"""
return {
"iterations_per_sample": result.iterations_per_sample,
"sample_count": result.sample_count,
"per_iteration_ns": result.per_iteration_ns,
"per_iteration_ns_mad": result.per_iteration_ns_mad,
"per_iteration_ns_min": result.per_iteration_ns_min,
"per_iteration_ns_max": result.per_iteration_ns_max,
}
def print_json_single_shell_results(results: list[BenchmarkResult], shell: str):
"""Print results for a single shell in JSON format."""
output = {
"shell": shell,
"results": [
{
"name": result.case_name,
**_result_to_json(result),
}
for result in results
],
}
print(json.dumps(output, indent=2))
def print_json_results(
comparisons: list[ComparisonResult], test_shell: str, ref_shell: str
):
"""Print comparison results in JSON format."""
avg_change = compute_geometric_mean_change(comparisons)
output = {
"test_shell": test_shell,
"reference_shell": ref_shell,
"results": [
{
"name": comp.case_name,
"test": _result_to_json(comp.test_result),
"reference": _result_to_json(comp.reference_result),
"change": comp.change,
}
for comp in comparisons
],
"overall_change": avg_change,
}
print(json.dumps(output, indent=2))
# =============================================================================
# Main entry point
# =============================================================================
def resolve_shell_path(shell: str) -> str:
"""
Resolve shell path, checking it exists and is executable.
Args:
shell: Shell name or path (e.g., 'bash' or '/usr/bin/bash')
Returns:
Resolved absolute path to the shell executable.
Exits with error if shell is not found or not executable.
"""
# If it's an absolute or relative path, use as-is
if "/" in shell:
resolved = shell
else:
# Search PATH
resolved = shutil.which(shell)
if resolved is None:
print(f"Error: Shell '{shell}' not found in PATH", file=sys.stderr)
sys.exit(1)
# Verify it exists and is executable
if not os.path.isfile(resolved):
print(f"Error: Shell path '{resolved}' does not exist or is not a file", file=sys.stderr)
sys.exit(1)
if not os.access(resolved, os.X_OK):
print(f"Error: Shell '{resolved}' is not executable", file=sys.stderr)
sys.exit(1)
return resolved
def _positive_float(value: str) -> float:
"""Argparse type validator for positive float values."""
try:
fval = float(value)
except ValueError:
raise argparse.ArgumentTypeError(f"invalid float value: '{value}'")
if fval < 0:
raise argparse.ArgumentTypeError(f"value must be non-negative: {fval}")
return fval
def _positive_int(value: str) -> int:
"""Argparse type validator for positive integer values."""
try:
ival = int(value)
except ValueError:
raise argparse.ArgumentTypeError(f"invalid integer value: '{value}'")
if ival <= 0:
raise argparse.ArgumentTypeError(f"value must be positive: {ival}")
return ival
def main():
parser = argparse.ArgumentParser(
description="Shell benchmarking harness for brush",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Available benchmarks:
assignment - Variable assignment (x=42)
colon - No-op builtin (:)
echo_builtin - Echo builtin to /dev/null
echo_cmd - External /usr/bin/echo to /dev/null
increment - Arithmetic increment ((x++))
subshell - Subshell fork ((:))
cmdsubst - Command substitution ($(...))
var_expand - Variable expansion ("$var")
func_call - Function invocation
array_access - Array element access (${arr[i]})
pattern_match - Glob pattern match in [[ ]]
regex_match - Regex match in [[ =~ ]]
if_taken - If conditional (branch taken)
if_not_taken - If conditional (branch not taken)
Examples:
%(prog)s --shell ./target/release/brush
%(prog)s --shell brush --reference-shell bash --benchmarks colon increment
%(prog)s --shell brush --duration 5 --json
""",
)
parser.add_argument(
"-s",
"--shell",
dest="shell",
type=str,
required=True,
help="Path to test shell binary",
)
parser.add_argument(
"-r",
"--reference-shell",
dest="reference_shell",
type=str,
default=None,
help="Path to reference shell for comparison (optional; if omitted, only test shell is benchmarked)",
)
parser.add_argument(
"-b",
"--benchmarks",
dest="benchmarks",
type=str,
nargs="+",
default=list(BENCHMARK_CASES.keys()),
choices=list(BENCHMARK_CASES.keys()),
help="Name(s) of benchmark(s) to run (default: all)",
)
parser.add_argument(
"--samples",
dest="samples",
type=_positive_int,
default=DEFAULT_SAMPLES,
help=f"Number of samples to collect per benchmark (default: {DEFAULT_SAMPLES})",
)
parser.add_argument(
"-d",
"--duration",
dest="duration",
type=_positive_float,
default=DEFAULT_DURATION_SECONDS,
help=f"Number of seconds to run each test (default: {DEFAULT_DURATION_SECONDS})",
)
parser.add_argument(
"-w",
"--warmup-duration",
dest="warmup_duration",
type=_positive_float,
default=DEFAULT_WARMUP_DURATION_SECONDS,
help=f"Warmup duration in seconds; 0 to skip (default: {DEFAULT_WARMUP_DURATION_SECONDS})",
)
parser.add_argument(
"-j",
"--json",
dest="json_output",
action="store_true",
help="Output results in JSON format",
)
parser.add_argument(
"-v",
"--verbose",
dest="verbose",
action="store_true",
help="Print progress information",
)
args = parser.parse_args()
# Resolve shell paths
test_shell = resolve_shell_path(args.shell)
ref_shell = resolve_shell_path(args.reference_shell) if args.reference_shell else None
if not args.json_output:
print("🐚 Shell Benchmark Harness", file=sys.stderr)
print(f" Test shell: {test_shell}", file=sys.stderr)
if ref_shell:
print(f" Reference shell: {ref_shell}", file=sys.stderr)
print(f" Duration: {args.duration}s per benchmark", file=sys.stderr)
print(f" Warmup: {args.warmup_duration}s", file=sys.stderr)
print(f" Samples: {args.samples}", file=sys.stderr)
print(f" Benchmarks: {', '.join(args.benchmarks)}", file=sys.stderr)
print(file=sys.stderr)
# Single-shell mode (no reference)
if ref_shell is None:
results = []
for bench_name in args.benchmarks:
case = BENCHMARK_CASES[bench_name]
if not args.json_output:
print(f"▶️ Running benchmark: {bench_name}", file=sys.stderr)
print(f" Testing: {test_shell}", file=sys.stderr)
result = run_benchmark(
test_shell,
bench_name,
case,
args.duration,
args.samples,
args.warmup_duration,
verbose=args.verbose,
)
results.append(result)
if not args.json_output:
print(file=sys.stderr)
if args.json_output:
print_json_single_shell_results(results, test_shell)
else:
print_human_single_shell_results(results, test_shell)
return
# Comparison mode (test vs reference)
comparisons = []
for bench_name in args.benchmarks:
case = BENCHMARK_CASES[bench_name]
if not args.json_output:
print(f"▶️ Running benchmark: {bench_name}", file=sys.stderr)
# Run on test shell
if not args.json_output:
print(f" Testing: {test_shell}", file=sys.stderr)
test_result = run_benchmark(
test_shell,
bench_name,
case,
args.duration,
args.samples,
args.warmup_duration,
verbose=args.verbose,
)
# Run on reference shell
if not args.json_output:
print(f" Testing: {ref_shell}", file=sys.stderr)
ref_result = run_benchmark(
ref_shell,
bench_name,
case,
args.duration,
args.samples,
args.warmup_duration,
verbose=args.verbose,
)
# Calculate performance change (reference time / test time)
# > 1 means test shell is faster
# Note: per_iteration_ns is the median value
if test_result.per_iteration_ns > 0:
change = ref_result.per_iteration_ns / test_result.per_iteration_ns
else:
# Test result was unmeasurably fast; treat as equivalent
change = 1.0
comparisons.append(
ComparisonResult(
case_name=bench_name,
test_result=test_result,
reference_result=ref_result,
change=change,
)
)
if not args.json_output:
print(file=sys.stderr)
# Output results
if args.json_output:
print_json_results(comparisons, test_shell, ref_shell)
else:
print_human_results(comparisons, test_shell, ref_shell)
if __name__ == "__main__":
main()
@@ -0,0 +1,181 @@
[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.command']
'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-winnow-args-adapter = { version = "^0.1.0", path = "../brush-winnow-args-adapter" }
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"
fancy-regex = "0.19.0"
itertools = "0.15.0"
strum = "0.28.0"
thiserror = "2.0.18"
tracing = "0.1.44"
winnow-args = { version = "0.6.0", default-features = false, features = ["derive", "help-text"] }
utf8-chars = "3.0.6"
[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.12.0", default-features = false, features = [
"format",
"signals",
] }
[target.'cfg(target_os = "wasi")'.dependencies]
uucore = { version = "0.12.0", default-features = false, features = ["format"] }
[target.'cfg(unix)'.dependencies]
nix = { version = "0.31.2", features = [
"fs",
"process",
"resource",
"signal",
"term",
"user",
] }
rlimit = "0.11.0"
[target.'cfg(any(target_os = "linux", target_os = "android"))'.dependencies]
procfs = "0.18.0"
[dev-dependencies]
anyhow = "1.0.102"
pretty_assertions = { version = "1.4.1", features = ["unstable"] }
+1
View File
@@ -0,0 +1 @@
../LICENSE
@@ -0,0 +1,61 @@
use itertools::Itertools;
use std::io::Write;
use brush_core::{ExecutionResult, builtins};
use crate::write_alias_definition;
/// Manage aliases within the shell.
#[derive(winnow_args::Args)]
#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)]
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(positional, value_name = "name[=value]")]
aliases: Vec<String>,
}
brush_winnow_args_adapter::impl_parse_args!(AliasCommand);
impl<SE: brush_core::ShellExtensions> builtins::Builtin<SE> for AliasCommand {
type Error = brush_core::Error;
async fn execute(
self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
let mut exit_code = ExecutionResult::success();
if self.print || self.aliases.is_empty() {
// Aliases are stored unordered; bash lists them sorted by name.
for (name, value) in context.shell.aliases().iter().sorted() {
write_alias_definition(context.stdout(), 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) {
write_alias_definition(context.stdout(), alias, value)?;
} else {
writeln!(
context.stderr(),
"{}: {alias}: not found",
context.command_name
)?;
exit_code = ExecutionResult::general_error();
}
}
}
Ok(exit_code)
}
}
@@ -0,0 +1,75 @@
use std::io::Write;
use brush_core::{ExecutionResult, builtins, jobs};
/// Moves a job to run in the background.
#[derive(winnow_args::Args)]
#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)]
pub(crate) struct BgCommand {
/// List of job specs to move to background.
#[arg(positional)]
job_specs: Vec<String>,
}
brush_winnow_args_adapter::impl_parse_args!(BgCommand);
impl<SE: brush_core::ShellExtensions> builtins::Builtin<SE> for BgCommand {
type Error = brush_core::Error;
async fn execute(
self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
let mut exit_code = ExecutionResult::success();
if !self.job_specs.is_empty() {
for job_spec in &self.job_specs {
if let Some(job) = context.shell.jobs_mut().resolve_job_spec(job_spec) {
let running = running_job_id(job);
job.move_to_background()?;
warn_if_running(&context, running)?;
} 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() {
let running = running_job_id(job);
job.move_to_background()?;
warn_if_running(&context, running)?;
} else {
writeln!(context.stderr(), "{}: no current job", context.command_name)?;
exit_code = ExecutionResult::general_error();
}
}
Ok(exit_code)
}
}
/// Returns the ID of the job if it isn't known to be stopped. (`bg` continues such a job
/// anyway, as it may have been stopped without the shell's noticing.)
fn running_job_id(job: &jobs::Job) -> Option<usize> {
(!matches!(job.state, jobs::JobState::Stopped)).then_some(job.id)
}
/// Warns, as bash does, that a job not known to be stopped is already in the background.
fn warn_if_running<SE: brush_core::ShellExtensions>(
context: &brush_core::ExecutionContext<'_, SE>,
running_job_id: Option<usize>,
) -> Result<(), brush_core::Error> {
if let Some(id) = running_job_id {
writeln!(
context.stderr(),
"{}: job {id} already in background",
context.command_name
)?;
}
Ok(())
}
@@ -0,0 +1,575 @@
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,
};
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(winnow_args::Args)]
#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)]
pub(crate) struct BindCommand {
/// Name of key map to use.
#[arg(short = 'm')]
keymap: Option<BindKeyMap>,
/// List functions.
#[arg(short = 'l')]
list_funcs: bool,
/// List functions and bindings.
#[arg(short = 'P')]
list_funcs_and_bindings: bool,
/// List functions and bindings in a format suitable for use as input.
#[arg(short = 'p')]
list_funcs_and_bindings_reusable: bool,
/// List key sequences that invoke macros.
#[arg(short = 'S')]
list_key_seqs_that_invoke_macros: bool,
/// List key sequences that invoke macros in a format suitable for use as input.
#[arg(short = 's')]
list_key_seqs_that_invoke_macros_reusable: bool,
/// List variables.
#[arg(short = 'V')]
list_vars: bool,
/// List variables in a format suitable for use as input.
#[arg(short = 'v')]
list_vars_reusable: bool,
/// Find the keys bound to the given named function.
#[arg(short = 'q', value_name = "FUNC_NAME")]
query_func_bindings: Option<String>,
/// Remove all bindings for the given named function.
#[arg(short = 'u', value_name = "FUNC_NAME")]
remove_func_bindings: Option<String>,
/// Remove the binding for the given key sequence.
#[arg(short = 'r', value_name = "KEY_SEQ")]
remove_key_seq_binding: Option<String>,
/// Import bindings from the given file.
#[arg(short = 'f', value_name = "PATH")]
bindings_file: Option<String>,
/// Bind key sequence to command.
#[arg(short = 'x', value_name = "BINDING")]
key_seq_bindings: Vec<String>,
/// List key sequence bindings.
#[arg(short = 'X')]
list_key_seq_bindings: bool,
/// Key sequence binding to readline function or command.
#[arg(positional)]
key_sequence: Option<String>,
}
/// Identifier for a keymap
#[derive(Clone, winnow_args::ValueEnum)]
pub(crate) enum BindKeyMap {
#[arg(name = "emacs-standard", alias = "emacs")]
EmacsStandard,
#[arg(name = "emacs-meta")]
EmacsMeta,
#[arg(name = "emacs-ctlx")]
EmacsCtlx,
#[arg(name = "vi-command", alias("vi", "vi-move"))]
ViCommand,
#[arg(name = "vi-insert")]
ViInsert,
}
brush_winnow_args_adapter::impl_parse_args!(BindCommand);
#[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<SE: brush_core::ShellExtensions> builtins::Builtin<SE> for BindCommand {
type Error = BindError;
async fn execute(
self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
if let Some(key_bindings) = context.shell.key_bindings() {
Ok(self.execute_impl(key_bindings, &context).await?)
} else {
tracing::debug!(target: trace_categories::INPUT,
"bind: key bindings not supported in this config");
// Silently succeed when key bindings are unavailable (e.g., in
// non-interactive mode or with an input backend that doesn't
// yet support them).
Ok(ExecutionExitCode::Success.into())
}
}
}
impl BindCommand {
#[allow(clippy::too_many_lines)]
async fn execute_impl(
&self,
bindings: &Arc<Mutex<dyn interfaces::KeyBindings>>,
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
) -> Result<ExecutionResult, BindError> {
let mut bindings = bindings.lock().await;
if self.list_funcs {
for func in interfaces::InputFunction::iter() {
writeln!(context.stdout(), "{func}")?;
}
}
if self.list_funcs_and_bindings {
display_funcs_and_bindings(&*bindings, context, false /* reusable? */)?;
}
if self.list_funcs_and_bindings_reusable {
display_funcs_and_bindings(&*bindings, context, true /* reusable? */)?;
}
if self.list_key_seqs_that_invoke_macros {
display_macros(&*bindings, context, false /* reusable? */)?;
}
if self.list_key_seqs_that_invoke_macros_reusable {
display_macros(&*bindings, context, true /* reusable? */)?;
}
if self.list_vars {
let options = &context.shell.completion_config().fallback_options;
// For now we'll just display a few items and show defaults.
writeln!(
context.stdout(),
"mark-directories is set to `{}'",
to_onoff(options.mark_directories)
)?;
writeln!(
context.stdout(),
"mark-symlinked-directories is set to `{}'",
to_onoff(options.mark_symlinked_directories)
)?;
}
if self.list_vars_reusable {
let options = &context.shell.completion_config().fallback_options;
// For now we'll just display a few items and show defaults.
writeln!(
context.stdout(),
"set mark-directories {}",
to_onoff(options.mark_directories)
)?;
writeln!(
context.stdout(),
"set mark-symlinked-directories {}",
to_onoff(options.mark_symlinked_directories)
)?;
}
if let Some(func_str) = &self.query_func_bindings {
let seqs = find_key_seqs_bound_to_function(&*bindings, func_str)?;
if !seqs.is_empty() {
writeln!(
context.stdout(),
"{func_str} can be invoked via {}.",
seqs.iter().map(|seq| std::format!("\"{seq}\"")).join(", ")
)?;
} else {
writeln!(context.stdout(), "{func_str} is not bound to any keys.")?;
return Ok(ExecutionResult::general_error());
}
}
if let Some(func_str) = &self.remove_func_bindings {
let found_seqs = find_key_seqs_bound_to_function(&*bindings, func_str)?;
for seq in found_seqs {
let _ = bindings.try_unbind(seq);
}
}
if let Some(key_seq_str) = &self.remove_key_seq_binding {
let key_seq = parse_key_sequence(key_seq_str)?;
let _ = bindings.try_unbind(key_seq);
}
if self.bindings_file.is_some() {
return Err(BindError::Unimplemented("bind -f"));
}
if self.list_key_seq_bindings {
for (seq, action) in &bindings.get_current() {
let KeyAction::ShellCommand(cmd) = action else {
continue;
};
writeln!(context.stdout(), "\"{seq}\" \"{cmd}\"")?;
}
}
if !self.key_seq_bindings.is_empty() {
if self.keymap.as_ref().is_some_and(|k| k.is_vi()) {
// NOTE(vi): Quietly ignore since we don't support vi mode.
return Ok(ExecutionResult::success());
}
for key_seq_and_command in &self.key_seq_bindings {
let (key_seq, command) = parse_key_sequence_and_shell_command(key_seq_and_command)?;
bind_key_sequence_to_shell_cmd(&mut *bindings, key_seq, command)?;
}
}
if let Some(key_sequence) = &self.key_sequence {
if self.keymap.as_ref().is_some_and(|k| k.is_vi()) {
// NOTE(vi): Quietly ignore since we don't support vi mode.
return Ok(ExecutionResult::success());
}
let (key_seq, target) = parse_key_sequence_and_readline_target(key_sequence.as_str())?;
bind_key_sequence_to_readline_target(&mut *bindings, key_seq, target)?;
}
drop(bindings);
Ok(ExecutionResult::success())
}
}
fn parse_key_sequence(input: &str) -> Result<interfaces::KeySequence, BindError> {
// First trim any whitespace.
let input = input.trim();
let parsed = brush_parser::readline_binding::parse_key_sequence(input)?;
let abstract_seq = key_sequence_to_abstract_strokes(&parsed)?;
Ok(abstract_seq)
}
fn parse_key_sequence_and_shell_command(
input: &str,
) -> Result<(interfaces::KeySequence, String), BindError> {
tracing::debug!(target: trace_categories::INPUT,
"parsing key binding entry: '{input}'"
);
// First trim any whitespace.
let input = input.trim();
// This should be something of the form:
// "KEY-SEQUENCE": SHELL-COMMAND
let binding = brush_parser::readline_binding::parse_key_sequence_shell_cmd_binding(input)?;
let abstract_seq = key_sequence_to_abstract_strokes(&binding.seq)?;
Ok((abstract_seq, binding.shell_cmd))
}
#[derive(Debug)]
#[allow(dead_code, reason = "not all variants implemented yet")]
enum BindableReadlineTarget {
Function(interfaces::InputFunction),
Macro(interfaces::KeySequence),
}
fn parse_key_sequence_and_readline_target(
input: &str,
) -> Result<(interfaces::KeySequence, BindableReadlineTarget), BindError> {
tracing::debug!(target: trace_categories::INPUT,
"parsing key binding entry: '{input}'"
);
// First trim any whitespace.
let input = input.trim();
// This should be of one of these forms:
// "KEY-SEQUENCE":function-name
// "KEY-SEQUENCE":readline-command
let binding = brush_parser::readline_binding::parse_key_sequence_readline_binding(input)?;
let abstract_seq = key_sequence_to_abstract_strokes(&binding.seq)?;
match binding.target {
brush_parser::readline_binding::ReadlineTarget::Function(func_name) => {
let func = parse_readline_function(func_name.as_str())?;
Ok((abstract_seq, BindableReadlineTarget::Function(func)))
}
brush_parser::readline_binding::ReadlineTarget::Macro(target_seq_str) => {
let parsed_target =
brush_parser::readline_binding::parse_key_sequence(&target_seq_str)?;
let abstract_target = key_sequence_to_abstract_strokes(&parsed_target)?;
Ok((abstract_seq, BindableReadlineTarget::Macro(abstract_target)))
}
}
}
fn bind_key_sequence_to_shell_cmd(
bindings: &mut dyn interfaces::KeyBindings,
key_sequence: interfaces::KeySequence,
command: String,
) -> Result<(), BindError> {
tracing::debug!(target: trace_categories::INPUT,
"binding key sequence: '{key_sequence}' => command '{command}'"
);
bindings.bind(key_sequence, interfaces::KeyAction::ShellCommand(command))?;
Ok(())
}
fn bind_key_sequence_to_readline_target(
bindings: &mut dyn interfaces::KeyBindings,
key_sequence: interfaces::KeySequence,
target: BindableReadlineTarget,
) -> Result<(), BindError> {
match target {
BindableReadlineTarget::Function(func) => {
tracing::debug!(target: trace_categories::INPUT,
"binding key sequence: '{key_sequence}' => readline function '{func}'"
);
if matches!(func, interfaces::InputFunction::ViEditingMode) {
// NOTE(vi): We don't support vi mode; silently ignore.
return Ok(());
}
bindings.bind(key_sequence, interfaces::KeyAction::DoInputFunction(func))?;
}
BindableReadlineTarget::Macro(cmd_macro) => {
tracing::debug!(target: trace_categories::INPUT,
"binding key sequence: '{key_sequence}' => readline macro '{cmd_macro}'"
);
bindings.define_macro(key_sequence, cmd_macro)?;
}
}
Ok(())
}
fn key_sequence_to_abstract_strokes(
seq: &brush_parser::readline_binding::KeySequence,
) -> Result<interfaces::KeySequence, BindError> {
let phys_strokes = brush_parser::readline_binding::key_sequence_to_strokes(seq)?;
// Lift from key codes to abstract keys.
let mut abstract_strokes = vec![];
let mut key_code_bytes = vec![];
let mut uninterpretable = false;
for mut phys_stroke in phys_strokes {
let mut key = sys::input::try_get_key_from_key_code(phys_stroke.key_code.as_slice());
// If we couldn't interpret it directly but we see it starts with the escape character,
// try to see if we can parse it as an Alt+<key> sequence.
if key.is_none() && phys_stroke.key_code.len() > 1 && phys_stroke.key_code[0] == b'\x1b' {
key = sys::input::try_get_key_from_key_code(&phys_stroke.key_code[1..]);
if key.is_some() {
phys_stroke.meta = true;
}
}
// When storing as bytes, apply control modifier to the key code.
let mut raw_bytes = phys_stroke.key_code.clone();
if phys_stroke.control {
for byte in &mut raw_bytes {
// Control characters are computed by ANDing with 0x1F
*byte &= 0x1F;
}
}
key_code_bytes.push(raw_bytes);
if let Some(key) = key {
abstract_strokes.push(interfaces::KeyStroke {
alt: phys_stroke.meta,
control: phys_stroke.control,
shift: false,
key,
});
} else {
uninterpretable = true;
}
}
if uninterpretable {
Ok(interfaces::KeySequence::Bytes(key_code_bytes))
} else {
Ok(interfaces::KeySequence::Strokes(abstract_strokes))
}
}
fn parse_readline_function(func_name: &str) -> Result<interfaces::InputFunction, BindError> {
interfaces::InputFunction::from_str(func_name)
.map_err(|_err| BindError::UnknownKeyBindingFunction(func_name.to_owned()))
}
const fn to_onoff(value: bool) -> &'static str {
if value { "on" } else { "off" }
}
fn display_funcs_and_bindings(
bindings: &dyn interfaces::KeyBindings,
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
reusable: bool,
) -> Result<(), BindError> {
let mut sequences_by_func: HashMap<InputFunction, Vec<KeySequence>> = HashMap::new();
for (seq, action) in &bindings.get_current() {
let KeyAction::DoInputFunction(func) = action else {
continue;
};
sequences_by_func
.entry(func.clone())
.or_default()
.push(seq.clone());
}
let sorted_funcs = interfaces::InputFunction::iter().sorted_by_key(|f| f.to_string());
for func in sorted_funcs {
match sequences_by_func.get(&func) {
Some(seqs) if reusable => {
for seq in seqs {
writeln!(context.stdout(), "\"{seq}\": {func}")?;
}
}
Some(seqs) => {
writeln!(
context.stdout(),
"{func} can be found on {}.",
seqs.iter().map(|seq| std::format!("\"{seq}\"")).join(", ")
)?;
}
None if reusable => {
writeln!(context.stdout(), "# {func} (not bound)")?;
}
None => {
writeln!(context.stdout(), "{func} is not bound to any keys")?;
}
}
}
Ok(())
}
fn display_macros(
bindings: &dyn interfaces::KeyBindings,
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
reusable: bool,
) -> Result<(), BindError> {
for (left, right) in bindings.get_macros() {
if reusable {
writeln!(context.stdout(), "\"{left}\": \"{right}\"")?;
} else {
writeln!(context.stdout(), "{left} outputs {right}")?;
}
}
Ok(())
}
fn find_key_seqs_bound_to_function(
bindings: &dyn interfaces::KeyBindings,
func_str: &str,
) -> Result<Vec<interfaces::KeySequence>, BindError> {
let Ok(func_to_find) = InputFunction::from_str(func_str) else {
return Err(BindError::UnknownFunction(func_str.to_owned()));
};
let mut found_seqs = vec![];
for (seq, action) in &bindings.get_current() {
if let KeyAction::DoInputFunction(func) = action
&& *func == func_to_find
{
found_seqs.push(seq.clone());
}
}
Ok(found_seqs)
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::{assert_eq, assert_matches};
#[test]
fn parse_example_key_sequence_and_readline_func() {
let (key_seq, target) =
parse_key_sequence_and_readline_target(r#""\C-a":beginning-of-line"#).unwrap();
assert_eq!(
key_seq,
interfaces::KeySequence::Strokes(vec![interfaces::KeyStroke {
alt: false,
control: true,
shift: false,
key: interfaces::Key::Character('a'),
}])
);
assert_matches!(
target,
BindableReadlineTarget::Function(interfaces::InputFunction::BeginningOfLine)
);
}
#[test]
fn parse_escape_char_key_binding() {
let (key_seq, target) =
parse_key_sequence_and_readline_target(r#""\er":transpose-chars"#).unwrap();
assert_eq!(
key_seq,
interfaces::KeySequence::Strokes(vec![interfaces::KeyStroke {
alt: true,
control: false,
shift: false,
key: interfaces::Key::Character('r'),
}])
);
assert_matches!(
target,
BindableReadlineTarget::Function(interfaces::InputFunction::TransposeChars)
);
}
}
@@ -0,0 +1,43 @@
use std::io::Write;
use brush_core::{ExecutionControlFlow, ExecutionResult, builtins};
/// Breaks out of a control-flow loop.
#[derive(winnow_args::Args)]
#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)]
pub(crate) struct BreakCommand {
/// If specified, indicates which nested loop to break out of.
#[arg(positional, default = "1", allow_negative_numbers)]
which_loop: i8,
}
brush_winnow_args_adapter::impl_parse_args!(BreakCommand);
impl<SE: brush_core::ShellExtensions> builtins::Builtin<SE> for BreakCommand {
type Error = brush_core::Error;
async fn execute(
self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
// A count that isn't positive is reported.
if self.which_loop <= 0 {
writeln!(
context.stderr(),
"{}: {}: loop count out of range",
context.command_name,
self.which_loop
)?;
return Ok(ExecutionResult::general_error());
}
let mut result = ExecutionResult::success();
result.next_control_flow = ExecutionControlFlow::BreakLoop {
#[expect(clippy::cast_sign_loss)]
levels: (self.which_loop - 1) as usize,
};
Ok(result)
}
}
@@ -0,0 +1,20 @@
use crate::BuiltinSet;
/// Extension trait that simplifies adding default builtins to a shell builder.
pub trait ShellBuilderExt {
/// Add default builtins to the shell being built.
///
/// # Arguments
///
/// * `set` - The well-known set of built-ins to add.
#[must_use]
fn default_builtins(self, set: BuiltinSet) -> Self;
}
impl<SE: brush_core::extensions::ShellExtensions, S: brush_core::ShellBuilderState> ShellBuilderExt
for brush_core::ShellBuilder<SE, S>
{
fn default_builtins(self, set: BuiltinSet) -> Self {
self.builtins(crate::default_builtins(set))
}
}
@@ -0,0 +1,67 @@
use brush_core::{ExecutionResult, builtins};
/// Directly invokes a built-in, without going through typical search order.
pub(crate) struct BuiltinCommand {
args: Vec<brush_core::CommandArg>,
}
impl builtins::ParseArgs for BuiltinCommand {
const DECLARATION_ROLE: builtins::DeclarationRole = builtins::DeclarationRole::Utility;
fn content(
name: &str,
content_type: builtins::ContentType,
_options: &builtins::ContentOptions,
) -> Result<String, brush_core::Error> {
match content_type {
builtins::ContentType::DetailedHelp => Ok(
"Execute SHELL-BUILTIN with arguments ARGs without performing command lookup.\n"
.into(),
),
builtins::ContentType::ShortUsage => {
Ok(format!("{name}: {name} [shell-builtin [arg ...]]\n"))
}
builtins::ContentType::ShortDescription => {
Ok(format!("{name} - Execute shell builtins\n"))
}
builtins::ContentType::ManPage => {
brush_core::error::unimp("man page not yet implemented")
}
}
}
fn parse(invocation: builtins::Invocation<'_>) -> Result<Self, builtins::UsageError> {
Ok(Self {
args: invocation.into_args(),
})
}
}
impl<SE: brush_core::ShellExtensions> builtins::Builtin<SE> for BuiltinCommand {
type Error = brush_core::Error;
async fn execute(
self,
mut context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
let args = self.args;
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)
.filter(|builtin| builtin.is_enabled())
.cloned()
{
context.command_name = builtin_name;
builtin.execute(context, args).await
} else {
Err(brush_core::ErrorKind::BuiltinNotFound(builtin_name).into())
}
}
}
@@ -0,0 +1,58 @@
use brush_core::{ExecutionResult, builtins, callstack};
use std::io::Write;
/// Return the context of the current subroutine call.
#[derive(winnow_args::Args)]
#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)]
pub(crate) struct CallerCommand {
/// The number of call frames to go back.
#[arg(positional)]
expr: Option<usize>,
}
brush_winnow_args_adapter::impl_parse_args!(CallerCommand);
impl<SE: brush_core::ShellExtensions> builtins::Builtin<SE> for CallerCommand {
type Error = brush_core::Error;
async fn execute(
self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<ExecutionResult, Self::Error> {
let stack = context.shell.call_stack();
// See how far back we need to look. Frame N represents the Nth caller
// (e.g., 0 = immediate caller, 1 = caller's caller, etc.).
let expr = self.expr.unwrap_or(0);
// Get all frames into a vector we can easily index into.
let frames: Vec<_> = stack
.iter()
.filter(|frame| frame.frame_type.is_function() || frame.frame_type.is_script())
.collect();
// Look for the last-known location in the parent of frame N.
let Some(calling_frame) = frames.get(expr + 1) else {
return Ok(ExecutionResult::general_error());
};
let line = calling_frame.current_line().unwrap_or(1);
let filename = &calling_frame.source_info.source;
// When the expr is provided, we display "LINE FUNCTION_NAME FILENAME"
// When the expr is omitted, we only display "LINE FILENAME"
if self.expr.is_some() {
let function_name = match &calling_frame.frame_type {
callstack::FrameType::Function(func_call) => func_call.name(),
callstack::FrameType::Script(..) => "source".into(),
_ => "".into(),
};
writeln!(context.stdout(), "{line} {function_name} {filename}")?;
} else {
writeln!(context.stdout(), "{line} {filename}")?;
}
Ok(ExecutionResult::success())
}
}
@@ -0,0 +1,99 @@
use std::io::Write;
use std::path::PathBuf;
use brush_core::{ExecutionResult, builtins, error};
/// Change the current shell working directory.
#[derive(winnow_args::Args)]
#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)]
pub(crate) struct CdCommand {
/// Force following symlinks.
#[arg(short = 'L', overrides = "-P")]
force_follow_symlinks: bool,
/// Use physical dir structure without following symlinks.
#[arg(short = 'P', overrides = "-L")]
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.
#[arg(positional)]
target_dir: Option<PathBuf>,
}
brush_winnow_args_adapter::impl_parse_args!(CdCommand);
impl<SE: brush_core::ShellExtensions> builtins::Builtin<SE> for CdCommand {
type Error = brush_core::Error;
async fn execute(
self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<ExecutionResult, Self::Error> {
// TODO(cd): implement 'cd -@'
if self.file_with_xattr_as_dir {
return error::unimp("cd -@");
}
let mut should_print = false;
let mut target_dir = if let Some(target_dir) = &self.target_dir {
// `cd -', equivalent to `cd $OLDPWD'
if target_dir.as_os_str() == "-" {
should_print = true;
if let Some(oldpwd) = context.shell.env_str("OLDPWD") {
PathBuf::from(oldpwd.to_string())
} else {
writeln!(context.stderr(), "OLDPWD not set")?;
return Ok(ExecutionResult::general_error());
}
} else {
// TODO(cd): remove clone, and use temporary lifetime extension after rust 1.75
target_dir.clone()
}
// `cd' without arguments is equivalent to `cd $HOME'
} else {
if let Some(home_var) = context.shell.env_str("HOME") {
PathBuf::from(home_var.to_string())
} else {
writeln!(context.stderr(), "HOME not set")?;
return Ok(ExecutionResult::general_error());
}
};
if self.use_physical_dir
|| context
.shell
.options()
.do_not_resolve_symlinks_when_changing_dir
{
// -e is only relevant in physical mode.
if self.exit_on_failed_cwd_resolution {
return error::unimp("cd -e");
}
target_dir = context.shell.absolute_path(target_dir).canonicalize()?;
}
context.shell.set_working_dir(&target_dir)?;
// Bash compatibility
// https://www.gnu.org/software/bash/manual/bash.html#index-cd
// If a non-empty directory name from CDPATH is used, or if '-' is the first argument, and
// the directory change is successful, the absolute pathname of the new working
// directory is written to the standard output.
if should_print {
writeln!(context.stdout(), "{}", target_dir.display())?;
}
Ok(ExecutionResult::success())
}
}
@@ -0,0 +1,36 @@
use brush_core::{ExecutionResult, builtins, error};
/// No-op command.
pub(crate) struct ColonCommand {}
impl builtins::ParseArgs for ColonCommand {
fn content(
name: &str,
content_type: builtins::ContentType,
_options: &builtins::ContentOptions,
) -> Result<String, brush_core::Error> {
match content_type {
builtins::ContentType::DetailedHelp => {
Ok("Null command; always returns success.".into())
}
builtins::ContentType::ShortUsage => Ok(format!("{name}: {name}\n")),
builtins::ContentType::ShortDescription => Ok(format!("{name} - Null command\n")),
builtins::ContentType::ManPage => error::unimp("man page not yet implemented"),
}
}
fn parse(_invocation: builtins::Invocation<'_>) -> Result<Self, builtins::UsageError> {
Ok(Self {})
}
}
impl<SE: brush_core::ShellExtensions> builtins::Builtin<SE> for ColonCommand {
type Error = brush_core::Error;
async fn execute(
self,
_context: brush_core::ExecutionContext<'_, SE>,
) -> Result<ExecutionResult, Self::Error> {
Ok(ExecutionResult::success())
}
}
@@ -0,0 +1,155 @@
use std::io::Write;
use std::path::PathBuf;
use brush_core::{ExecutionResult, builtins, commands, sys};
use crate::lookup::{self, Resolved};
use crate::write_alias_definition;
/// Directly invokes an external command, without going through typical search order.
#[derive(Default, winnow_args::Args)]
#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)]
pub(crate) struct CommandCommand {
/// Use default PATH value.
#[arg(short = 'p')]
pub(crate) use_default_path: bool,
/// Display a more verbose description of the command.
#[arg(short = 'V', overrides = "-v")]
pub(crate) print_verbose_description: bool,
/// Display a short description of the command.
#[arg(short = 'v', overrides = "-V")]
pub(crate) print_description: bool,
/// Command and arguments.
#[arg(positional, double_dash = "automatic")]
pub(crate) command_and_args: Vec<String>,
}
brush_winnow_args_adapter::impl_parse_args!(CommandCommand);
impl CommandCommand {
fn command_word(&self) -> Option<&str> {
self.command_and_args.first().map(|s| s.as_str())
}
fn path_dirs(&self) -> Option<Vec<PathBuf>> {
self.use_default_path
.then(sys::fs::get_default_standard_utils_paths)
}
/// Describes every name given, as `-v`/`-V` do; succeeds if any name was found.
fn describe_names<SE: brush_core::ShellExtensions>(
&self,
context: &brush_core::ExecutionContext<'_, SE>,
) -> Result<ExecutionResult, brush_core::Error> {
// With no names to look up there is nothing to fail to find, so this still succeeds.
if self.command_and_args.is_empty() {
return Ok(ExecutionResult::success());
}
let options = lookup::Options {
path_dirs: self.path_dirs(),
..Default::default()
};
let mut any_found = false;
for name in &self.command_and_args {
let Some(mut found) = lookup::resolve(context.shell, name, &options)
.into_iter()
.next()
else {
if self.print_verbose_description {
writeln!(context.stderr(), "command: {name}: not found")?;
}
continue;
};
any_found = true;
if self.print_description {
// Display in a form that could be reused as shell input.
match &found {
Resolved::Alias(target) => {
write_alias_definition(context.stdout(), name, target)?;
}
Resolved::Keyword | Resolved::Function(_) | Resolved::Builtin => {
writeln!(context.stdout(), "{name}")?;
}
Resolved::File { path, .. } => {
writeln!(context.stdout(), "{}", path.to_string_lossy())?;
}
}
} else {
// A command found by searching PATH is reported with an absolute path, even
// when the PATH entry it came from was relative. Explicit paths and hashed
// entries are reported exactly as they were given. Like bash, this only
// prefixes the working directory; it doesn't otherwise normalize the path,
// except to drop the leading `./` a `.` entry in PATH contributes.
if let Resolved::File {
path,
hashed: false,
} = &mut found
&& !sys::fs::contains_path_separator(name)
{
let relative = path.strip_prefix(".").unwrap_or(&*path);
*path = context.shell.absolute_path(relative);
}
lookup::describe(context.stdout(), name, &found)?;
}
}
if any_found {
Ok(ExecutionResult::success())
} else {
Ok(ExecutionResult::general_error())
}
}
async fn execute_command(
&self,
mut context: brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
command_name: &str,
) -> Result<ExecutionResult, brush_core::Error> {
command_name.clone_into(&mut context.command_name);
let command_and_args = self
.command_and_args
.iter()
.map(brush_core::CommandArg::from);
let 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 = self.path_dirs();
let spawn_result = cmd.execute().await?;
let wait_result = spawn_result.wait().await?;
Ok(wait_result.into())
}
}
impl<SE: brush_core::ShellExtensions> builtins::Builtin<SE> for CommandCommand {
type Error = brush_core::Error;
async fn execute(
self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<ExecutionResult, Self::Error> {
if self.print_description || self.print_verbose_description {
return self.describe_names(&context);
}
// Silently exit if no command was provided.
let Some(command_name) = self.command_word() else {
return Ok(ExecutionResult::success());
};
self.execute_command(context, command_name).await
}
}
@@ -0,0 +1,788 @@
use std::borrow::Cow;
use std::collections::HashMap;
use std::fmt::Write as _;
use std::io::Write;
use strum::IntoEnumIterator;
use brush_core::completion::{self, CompleteAction, CompleteOption, Spec, SpecName, SpecialSpec};
use brush_core::{ExecutionExitCode, ExecutionResult, builtins, escape};
/// A completion option or action name (`-o nospace`, `-A arrayvar`), parsed by
/// its strum name. `CompleteAction` and `CompleteOption` live in brush-core,
/// which does not depend on winnow-args, so they cannot implement `FromArg`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Choice<T>(T);
impl<T> winnow_args::FromArg for Choice<T>
where
T: std::str::FromStr + strum::VariantNames + strum::EnumMessage,
{
const CHOICES: &'static [&'static str] = T::VARIANTS;
const DESCRIBES_CHOICES: bool = true;
/// The text written in `choices(…)`, which the pages show too, else the
/// variant's doc comment in brush-core, without its final period.
fn choice_help(index: usize, default: &'static str) -> &'static str {
if !default.is_empty() {
return default;
}
let variant = T::VARIANTS
.get(index)
.and_then(|name| name.parse::<T>().ok());
let doc = variant.and_then(|variant| variant.get_documentation());
doc.map_or("", |doc| doc.trim_end().trim_end_matches('.'))
}
fn from_arg(value: &winnow_args::BStr) -> Result<Self, winnow_args::error::BoxError> {
std::str::from_utf8(value)
.ok()
.and_then(|name| name.parse().ok())
.map(Choice)
.ok_or_else(|| {
Box::new(winnow_args::ChoiceError {
choices: T::VARIANTS,
}) as winnow_args::error::BoxError
})
}
}
#[derive(winnow_args::Args)]
struct CommonCompleteCommandArgs {
/// Options governing the behavior of completions.
// The values are stated for the generated documentation, which cannot
// read them from the type; the tests check them against it.
#[arg(
short = 'o',
choices(
"bashdefault" = "Perform rest of default completions if no completions are generated",
"default" = "Use default filename completion if no completions are generated",
"dirnames" = "Treat completions as directory names",
"filenames" = "Treat completions as filenames",
"fullquote" = "Quote completions even if they aren't file names. Not yet implemented: it's accepted and shown, but has no effect",
"noquote" = "Suppress default auto-quotation of completions",
"nosort" = "Do not sort completions",
"nospace" = "Do not append a trailing space to completions at the end of the input line",
"plusdirs" = "Also generate directory completions"
)
)]
options: Vec<Choice<CompleteOption>>,
/// Actions to apply to generate completions.
#[arg(
short = 'A',
choices(
"alias" = "Complete with valid aliases",
"arrayvar" = "Complete with names of array shell variables",
"binding" = "Complete with names of key bindings",
"builtin" = "Complete with names of shell builtins",
"command" = "Complete with names of executable commands",
"directory" = "Complete with directory names",
"disabled" = "Complete with names of disabled shell builtins",
"enabled" = "Complete with names of enabled shell builtins",
"export" = "Complete with names of exported shell variables",
"file" = "Complete with filenames",
"function" = "Complete with names of shell functions",
"group" = "Complete with valid user groups",
"helptopic" = "Complete with names of valid shell help topics",
"hostname" = "Complete with the system's hostname(s)",
"job" = "Complete with the command names of shell-managed jobs",
"keyword" = "Complete with valid shell keywords",
"running" = "Complete with the command names of running shell-managed jobs",
"service" = "Complete with names of system services",
"setopt" = "Complete with the names of options settable via set -o",
"shopt" = "Complete with the names of options settable via shopt",
"signal" = "Complete with the names of trappable signals",
"stopped" = "Complete with the command names of stopped shell-managed jobs",
"user" = "Complete with valid usernames",
"variable" = "Complete with names of shell variables"
)
)]
actions: Vec<Choice<CompleteAction>>,
/// File glob pattern to be expanded to generate completions.
#[arg(short = 'G', allow_hyphen_values, value_name = "GLOB")]
glob_pattern: Option<String>,
/// List of words that will be considered as completions.
#[arg(short = 'W', allow_hyphen_values)]
word_list: Option<String>,
/// Name of a shell function to invoke to generate completions.
#[arg(short = 'F', allow_hyphen_values, value_name = "FUNC_NAME")]
function_name: Option<String>,
/// Command to execute to generate completions.
#[arg(short = 'C', allow_hyphen_values)]
command: Option<String>,
/// Pattern used as filter for completions.
#[arg(short = 'X', allow_hyphen_values, value_name = "PATTERN")]
filter_pattern: Option<String>,
/// Prefix pattern used as filter for completions.
#[arg(short = 'P', allow_hyphen_values)]
prefix: Option<String>,
/// Suffix pattern used as filter for completions.
#[arg(short = 'S', allow_hyphen_values)]
suffix: Option<String>,
/// Complete with valid aliases.
#[arg(short = 'a')]
action_alias: bool,
/// Complete with names of shell builtins.
#[arg(short = 'b')]
action_builtin: bool,
/// Complete with names of executable commands.
#[arg(short = 'c')]
action_command: bool,
/// Complete with directory names.
#[arg(short = 'd')]
action_directory: bool,
/// Complete with names of exported shell variables.
#[arg(short = 'e')]
action_exported: bool,
/// Complete with filenames.
#[arg(short = 'f')]
action_file: bool,
/// Complete with valid user groups.
#[arg(short = 'g')]
action_group: bool,
/// Complete with the command names of shell-managed jobs.
#[arg(short = 'j')]
action_job: bool,
/// Complete with valid shell 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,
}
/// The actions with flags of their own in `complete` and `compgen`, and those flags (e.g.
/// `a`, for `-a`, which is short for `-A alias`), in the order bash shows them.
const ACTION_FLAGS: [(char, CompleteAction); 12] = [
('a', CompleteAction::Alias),
('b', CompleteAction::Builtin),
('c', CompleteAction::Command),
('d', CompleteAction::Directory),
('e', CompleteAction::Export),
('f', CompleteAction::File),
('g', CompleteAction::Group),
('j', CompleteAction::Job),
('k', CompleteAction::Keyword),
('s', CompleteAction::Service),
('u', CompleteAction::User),
('v', CompleteAction::Variable),
];
impl CommonCompleteCommandArgs {
fn create_spec(&self) -> completion::Spec {
completion::Spec {
options: self.options.iter().map(|o| o.0).collect(),
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: self.filter_pattern.clone(),
prefix: self.prefix.clone(),
suffix: self.suffix.clone(),
}
}
/// Returns the actions selected, like bash, each once, in the fixed order bash runs them
/// in (that of [`CompleteAction`]'s variants), however they were given.
fn resolve_actions(&self) -> Vec<CompleteAction> {
// In the order of `ACTION_FLAGS`.
let flags = [
self.action_alias,
self.action_builtin,
self.action_command,
self.action_directory,
self.action_exported,
self.action_file,
self.action_group,
self.action_job,
self.action_keyword,
self.action_service,
self.action_user,
self.action_variable,
];
let selected: Vec<CompleteAction> = self
.actions
.iter()
.map(|a| a.0)
.chain(
ACTION_FLAGS
.iter()
.zip(flags)
.filter_map(|((_, action), given)| given.then_some(*action)),
)
.collect();
CompleteAction::iter()
.filter(|action| selected.contains(action))
.collect()
}
}
/// Configure programmable command completion.
#[derive(winnow_args::Args)]
#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)]
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,
#[arg(flatten)]
common_args: CommonCompleteCommandArgs,
#[arg(positional)]
names: Vec<String>,
}
brush_winnow_args_adapter::impl_parse_args!(CompleteCommand);
/// Returns the flag that selects a special completion spec in `complete` and `compopt`.
const fn special_spec_flag(special: SpecialSpec) -> &'static str {
match special {
SpecialSpec::Default => "-D",
SpecialSpec::EmptyLine => "-E",
SpecialSpec::InitialWord => "-I",
}
}
/// Returns the operand that names the spec `name` in a `complete` or `compopt` command that
/// `complete -p` or `compopt` prints: a special spec's flag, or the command's name, quoted
/// if needed.
fn spec_name_operand(name: SpecName<'_>) -> Cow<'_, str> {
match name {
SpecName::Special(special) => special_spec_flag(special).into(),
SpecName::Command(command) => {
escape::quote_if_needed(command, escape::QuoteMode::SingleQuote)
}
}
}
impl<SE: brush_core::ShellExtensions> builtins::Builtin<SE> for CompleteCommand {
type Error = brush_core::Error;
async fn execute(
self,
mut context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
// Like bash, -D, -E, or -I name a special spec in place of any names given; they
// take precedence in that order.
let special = [
(self.use_as_default, SpecialSpec::Default),
(self.use_for_empty_line, SpecialSpec::EmptyLine),
(self.use_for_initial_word, SpecialSpec::InitialWord),
]
.into_iter()
.find_map(|(selected, special)| selected.then_some(special));
let names: Vec<SpecName<'_>> = match special {
Some(special) => vec![SpecName::Special(special)],
None => self
.names
.iter()
.map(|name| SpecName::parse(name))
.collect(),
};
// With no spec named, list them all (as `complete` with no options does too), or
// with `-r`, remove them all.
if names.is_empty() {
if self.remove && !self.print {
context.shell.completion_config_mut().clear();
} else {
// Sort, so the listing is stable; the special specs come last.
let mut specs: Vec<_> = context.shell.completion_config().iter().collect();
specs.sort_by_key(|(name, _)| *name);
for (name, spec) in specs {
Self::display_spec(&context, name, spec)?;
}
}
return Ok(ExecutionResult::success());
}
let mut result = ExecutionResult::success();
for name in names {
if !self.try_process_for_command(&mut context, name)? {
result = ExecutionResult::general_error();
}
}
Ok(result)
}
}
impl CompleteCommand {
fn try_display_spec_for_command(
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
name: SpecName<'_>,
) -> Result<bool, brush_core::Error> {
if let Some(spec) = context.shell.completion_config().get(name) {
Self::display_spec(context, name, spec)?;
Ok(true)
} else {
writeln!(
context.stderr(),
"complete: {}: no completion specification",
name.as_str()
)?;
Ok(false)
}
}
/// Displays `spec`, registered under `name`, as the `complete` command that recreates
/// it, formatted as bash does.
fn display_spec(
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
name: SpecName<'_>,
spec: &Spec,
) -> Result<(), brush_core::Error> {
let mut s = String::from("complete");
// Options, in the order bash shows them.
let options = &spec.options;
for option in CompleteOption::iter().filter(|option| options.get(*option)) {
write!(s, " -o {}", <&str>::from(option))?;
}
// Like bash, show each action once: those with their own flag first, then the rest
// with `-A`, each in the order bash lists them.
for (flag, action) in &ACTION_FLAGS {
if spec.actions.contains(action) {
write!(s, " -{flag}")?;
}
}
for action in CompleteAction::iter() {
if spec.actions.contains(&action) && !ACTION_FLAGS.iter().any(|(_, a)| *a == action) {
write!(s, " -A {}", <&str>::from(action))?;
}
}
for (flag, arg) in [
("-G", spec.glob_pattern.as_ref()),
("-W", spec.word_list.as_ref()),
("-P", spec.prefix.as_ref()),
("-S", spec.suffix.as_ref()),
("-X", spec.filter_pattern.as_ref()),
("-C", spec.command.as_ref()),
] {
if let Some(arg) = arg {
let arg = escape::force_quote(arg, escape::QuoteMode::SingleQuote);
write!(s, " {flag} {arg}")?;
}
}
if let Some(function_name) = &spec.function_name {
let function_name =
escape::quote_if_needed(function_name, escape::QuoteMode::SingleQuote);
write!(s, " -F {function_name}")?;
}
// Like bash, the spec's name comes last.
writeln!(context.stdout(), "{s} {}", spec_name_operand(name))?;
Ok(())
}
fn try_process_for_command(
&self,
context: &mut brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
name: SpecName<'_>,
) -> Result<bool, brush_core::Error> {
if self.print {
return Self::try_display_spec_for_command(context, name);
} else if self.remove {
let mut result = context.shell.completion_config_mut().remove(name);
if !result {
if context.shell.options().interactive {
writeln!(context.stderr(), "complete: {}: not found", name.as_str())?;
} 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.completion_config_mut().set(name, config);
Ok(true)
}
}
/// Generate command completions.
#[derive(winnow_args::Args)]
#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)]
pub(crate) struct CompGenCommand {
#[arg(flatten)]
common_args: CommonCompleteCommandArgs,
// N.B. The word can only start with a hyphen if it's after a --.
#[arg(positional)]
word: Option<String>,
}
brush_winnow_args_adapter::impl_parse_args!(CompGenCommand);
impl<SE: brush_core::ShellExtensions> builtins::Builtin<SE> for CompGenCommand {
type Error = brush_core::Error;
async fn execute(
self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
let spec = self.common_args.create_spec();
let word = self.word.as_deref().unwrap_or_default();
let candidates = spec.generate(context.shell, word).await?;
// 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}")?;
}
Ok(ExecutionResult::success())
}
}
/// Set programmable command completion options.
#[derive(winnow_args::Args)]
#[arg(
plus_options,
disable_help_short,
disable_version_flag,
disable_help_subcommand
)]
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",
choices(
"bashdefault" = "Perform rest of default completions if no completions are generated",
"default" = "Use default filename completion if no completions are generated",
"dirnames" = "Treat completions as directory names",
"filenames" = "Treat completions as filenames",
"fullquote" = "Quote completions even if they aren't file names. Not yet implemented: it's accepted and shown, but has no effect",
"noquote" = "Suppress default auto-quotation of completions",
"nosort" = "Do not sort completions",
"nospace" = "Do not append a trailing space to completions at the end of the input line",
"plusdirs" = "Also generate directory completions"
)
)]
enabled_options: Vec<Choice<CompleteOption>>,
/// Disable the specified option for selected completion scenarios.
#[arg(
plus = 'o',
value_name = "OPT",
choices(
"bashdefault" = "Perform rest of default completions if no completions are generated",
"default" = "Use default filename completion if no completions are generated",
"dirnames" = "Treat completions as directory names",
"filenames" = "Treat completions as filenames",
"fullquote" = "Quote completions even if they aren't file names. Not yet implemented: it's accepted and shown, but has no effect",
"noquote" = "Suppress default auto-quotation of completions",
"nosort" = "Do not sort completions",
"nospace" = "Do not append a trailing space to completions at the end of the input line",
"plusdirs" = "Also generate directory completions"
)
)]
disabled_options: Vec<Choice<CompleteOption>>,
/// If specified, scopes updates to completions of the named commands.
#[arg(positional)]
names: Vec<String>,
}
brush_winnow_args_adapter::impl_parse_args!(CompOptCommand);
impl<SE: brush_core::ShellExtensions> builtins::Builtin<SE> for CompOptCommand {
type Error = brush_core::Error;
async fn execute(
self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
// Like bash, an option given with both `-o` and `+o` is disabled, whatever their
// order.
let mut options =
HashMap::with_capacity(self.disabled_options.len() + self.enabled_options.len());
for option in &self.enabled_options {
options.insert(option.0, true);
}
for option in &self.disabled_options {
options.insert(option.0, false);
}
if !self.names.is_empty()
&& (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());
}
// -D, -E, and -I select a special spec, which, like bash, messages name by bash's name
// for it.
let special = [
(self.update_default, SpecialSpec::Default),
(self.update_empty, SpecialSpec::EmptyLine),
(self.update_initial_word, SpecialSpec::InitialWord),
]
.into_iter()
.find_map(|(selected, special)| selected.then_some(special));
let names: Vec<SpecName<'_>> = match special {
Some(special) => vec![SpecName::Special(special)],
None => self
.names
.iter()
.map(|name| SpecName::parse(name))
.collect(),
};
if !names.is_empty() {
// Like bash, a spec that doesn't exist is an error, rather than being created.
// With no options to set, each spec's options are shown instead.
let mut result = ExecutionResult::success();
for name in names {
let found = if options.is_empty() {
match context.shell.completion_config().get(name) {
Some(spec) => {
Self::display_options(&context, name, &spec.options)?;
true
}
None => false,
}
} else if let Some(spec) = context.shell.completion_config_mut().get_mut(name) {
Self::set_options_for_spec(spec, &options);
true
} else {
false
};
if !found {
writeln!(
context.stderr(),
"compopt: {}: no completion specification",
name.as_str()
)?;
result = ExecutionResult::general_error();
}
}
return Ok(result);
}
// With no names, apply to the completion in progress, if there is one, or with no
// options to set, show its options.
if options.is_empty() {
if let Some(in_progress) = context.shell.in_progress_completion() {
Self::display_options(&context, in_progress.spec_name(), in_progress.options())?;
return Ok(ExecutionResult::success());
}
} else if let Some(in_progress_options) = context.shell.in_progress_completion_options_mut()
{
Self::set_options(in_progress_options, &options);
return Ok(ExecutionResult::success());
}
writeln!(
context.stderr(),
"compopt: not currently executing completion function"
)?;
Ok(ExecutionResult::general_error())
}
}
impl CompOptCommand {
/// Displays `options`, those of the spec `name`, as the `compopt` command that sets
/// them: each option enabled (`-o`) or disabled (`+o`), formatted as bash does.
fn display_options(
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
name: SpecName<'_>,
options: &completion::GenerationOptions,
) -> Result<(), brush_core::Error> {
let mut s = String::from("compopt");
for option in CompleteOption::iter() {
let sign = if options.get(option) { '-' } else { '+' };
write!(s, " {sign}o {}", <&str>::from(option))?;
}
writeln!(context.stdout(), "{s} {}", spec_name_operand(name))?;
Ok(())
}
fn set_options_for_spec<'a, I>(spec: &mut Spec, options: I)
where
I: IntoIterator<Item = (&'a CompleteOption, &'a bool)>,
{
Self::set_options(&mut spec.options, options);
}
fn set_options<'a, I>(target_options: &mut completion::GenerationOptions, options: I)
where
I: IntoIterator<Item = (&'a CompleteOption, &'a bool)>,
{
for (option, value) in options {
target_options.set(*option, *value);
}
}
}
#[cfg(test)]
#[expect(clippy::panic_in_result_fn)]
mod tests {
use super::*;
use anyhow::Result;
use brush_core::builtins::{Invocation, ParseArgs, UsageError};
use strum::VariantNames;
fn parse<T: ParseArgs>(name: &str, words: [&str; 2]) -> Result<T, UsageError> {
T::parse(Invocation::new(name, words))
}
#[test]
fn every_action_name_parses_to_its_action() -> Result<()> {
for name in CompleteAction::VARIANTS {
let args = parse::<CompleteCommand>("complete", ["-A", name])?;
let parsed: Vec<_> = args
.common_args
.actions
.iter()
.map(|a| <&str>::from(a.0))
.collect();
assert_eq!(parsed, [*name]);
}
Ok(())
}
/// What `choices(…)` says each value means, which the pages show, is
/// what brush-core's doc comment says, which help would show without it.
#[test]
fn each_choice_says_what_its_docs_say() {
use winnow_args::FromArg;
let items = <CompleteCommand as winnow_args::Args>::HELP.items.iter();
let items = items.chain(<CompOptCommand as winnow_args::Args>::HELP.items);
let mut checked = 0;
for item in items.filter(|item| !item.choices.is_empty()) {
// Every value brush-core has is stated, in its order.
let actions = item.choices[0] == "alias";
let variants = if actions {
CompleteAction::VARIANTS
} else {
CompleteOption::VARIANTS
};
assert_eq!(item.choices, variants);
// Without a function, each says nothing, and the docs disagree.
let help = item.choice_help.unwrap_or(|_| "");
for (index, choice) in item.choices.iter().enumerate() {
let from_docs = if actions {
Choice::<CompleteAction>::choice_help(index, "")
} else {
Choice::<CompleteOption>::choice_help(index, "")
};
let from_docs = from_docs.split_whitespace().collect::<Vec<_>>().join(" ");
assert_eq!(help(index), from_docs, "{choice}");
}
checked += 1;
}
assert_eq!(checked, 4);
}
#[test]
fn every_option_name_parses_to_its_option() -> Result<()> {
for name in CompleteOption::VARIANTS {
let option: CompleteOption = name.parse()?;
let args = parse::<CompleteCommand>("complete", ["-o", name])?;
assert_eq!(args.common_args.options, [Choice(option)]);
let args = parse::<CompOptCommand>("compopt", ["+o", name])?;
assert_eq!(args.disabled_options, [Choice(option)]);
}
Ok(())
}
#[test]
fn invalid_names_list_the_accepted_ones() {
let Err(err) = parse::<CompleteCommand>("complete", ["-A", "bogus"]) else {
unreachable!("`bogus` is not an action name");
};
let message = err.to_string();
assert!(
message.contains("bogus") && message.contains("arrayvar"),
"{message}"
);
}
#[test]
fn help_lists_accepted_names() {
let help = <CompleteCommand as ParseArgs>::content(
"complete",
builtins::ContentType::DetailedHelp,
&builtins::ContentOptions::default(),
)
.unwrap_or_default();
assert!(
help.contains("arrayvar") && help.contains("nospace"),
"{help}"
);
}
}
@@ -0,0 +1,43 @@
use std::io::Write;
use brush_core::{ExecutionControlFlow, ExecutionResult, builtins};
/// Continue to the next iteration of a control-flow loop.
#[derive(winnow_args::Args)]
#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)]
pub(crate) struct ContinueCommand {
/// If specified, indicates which nested loop to continue to the next iteration of.
#[arg(positional, default = "1", allow_negative_numbers)]
which_loop: i8,
}
brush_winnow_args_adapter::impl_parse_args!(ContinueCommand);
impl<SE: brush_core::ShellExtensions> builtins::Builtin<SE> for ContinueCommand {
type Error = brush_core::Error;
async fn execute(
self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
// A count that isn't positive is reported.
if self.which_loop <= 0 {
writeln!(
context.stderr(),
"{}: {}: loop count out of range",
context.command_name,
self.which_loop
)?;
return Ok(ExecutionResult::general_error());
}
let mut result = ExecutionResult::success();
result.next_control_flow = ExecutionControlFlow::ContinueLoop {
#[expect(clippy::cast_sign_loss)]
levels: (self.which_loop - 1) as usize,
};
Ok(result)
}
}
@@ -0,0 +1,713 @@
use itertools::Itertools;
use std::{io::Write, sync::LazyLock};
use brush_core::{
ErrorKind, ExecutionResult, builtins,
env::{self, EnvironmentLookup, EnvironmentScope},
parser::ast,
variables::{
self, ArrayLiteral, ShellValue, ShellValueLiteral, ShellValueUnsetType, ShellVariable,
ShellVariableUpdateTransform,
},
};
/// Display or update variables and their attributes.
#[derive(Default, winnow_args::Args)]
#[arg(
usage = "declare [OPTIONS] [DECLARATIONS]...",
plus_options,
disable_help_short,
disable_version_flag,
disable_help_subcommand
)]
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,
/// Make the variable an indexed array.
#[arg(short = 'a', plus = 'a')]
make_indexed_array: Vec<bool>,
/// Make the variable an associative array.
#[arg(short = 'A', plus = 'A')]
make_associative_array: Vec<bool>,
/// Enable capitalize-on-assignment for the variable.
#[arg(short = 'c', plus = 'c')]
capitalize_value_on_assignment: Vec<bool>,
/// Mark the variable as integer-typed.
#[arg(short = 'i', plus = 'i')]
make_integer: Vec<bool>,
/// Enable lowercase-on-assignment for the variable.
#[arg(short = 'l', plus = 'l')]
lowercase_value_on_assignment: Vec<bool>,
/// Mark the variable as a name reference.
#[arg(short = 'n', plus = 'n')]
make_nameref: Vec<bool>,
/// Mark the variable as read-only.
#[arg(short = 'r', plus = 'r')]
make_readonly: Vec<bool>,
/// Enable tracing for the variable.
#[arg(short = 't', plus = 't')]
make_traced: Vec<bool>,
/// Enable uppercase-on-assignment for the variable.
#[arg(short = 'u', plus = 'u')]
uppercase_value_on_assignment: Vec<bool>,
/// Mark the variable for export.
#[arg(short = 'x', plus = 'x')]
make_exported: Vec<bool>,
//
// Declarations
//
// N.B. These are skipped by winnow-args, but filled in by `impl_parse_args!`.
#[arg(skip)]
declarations: Vec<brush_core::CommandArg>,
}
brush_winnow_args_adapter::impl_parse_args!(DeclareCommand, declarations = declarations);
/// The change an attribute's `-X` (`true`) and `+X` (`false`) options make:
/// `+X` wins wherever it is given.
fn change(options: &[bool]) -> Option<bool> {
if options.contains(&false) {
Some(false)
} else if options.contains(&true) {
Some(true)
} else {
None
}
}
#[derive(Clone, Copy)]
enum DeclareVerb {
Declare,
Local,
Readonly,
}
impl<SE: brush_core::ShellExtensions> builtins::Builtin<SE> for DeclareCommand {
type Error = brush_core::Error;
async fn execute(
self,
mut context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
let verb = match context.command_name.as_str() {
"local" => DeclareVerb::Local,
"readonly" => DeclareVerb::Readonly,
_ => DeclareVerb::Declare,
};
if matches!(verb, DeclareVerb::Local) && !context.shell.in_function() {
writeln!(context.stderr(), "can only be used in a function")?;
return Ok(ExecutionResult::general_error());
}
let mut result = ExecutionResult::success();
if !self.declarations.is_empty() {
for declaration in &self.declarations {
if self.print && !matches!(verb, DeclareVerb::Readonly) {
if !self.try_display_declaration(&context, declaration, verb)? {
result = ExecutionResult::general_error();
}
} else {
if !self.process_declaration(&mut context, declaration, verb)? {
result = ExecutionResult::general_error();
}
}
}
} else {
// Display matching declarations from the variable environment.
if !self.function_names_only && !self.function_names_or_defs_only {
self.display_matching_env_declarations(&context, verb)?;
}
// Do the same for functions.
if !matches!(verb, DeclareVerb::Local | DeclareVerb::Readonly)
&& (!self.print || self.function_names_only || self.function_names_or_defs_only)
{
self.display_matching_functions(&context)?;
}
}
Ok(result)
}
}
impl DeclareCommand {
fn try_display_declaration(
&self,
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
declaration: &brush_core::CommandArg,
verb: DeclareVerb,
) -> Result<bool, brush_core::Error> {
let name = match declaration {
brush_core::CommandArg::String(s) => s,
brush_core::CommandArg::Assignment(_) => {
writeln!(context.stderr(), "declare: {declaration}: not found")?;
return Ok(false);
}
};
let lookup = if matches!(verb, DeclareVerb::Local) {
EnvironmentLookup::OnlyInCurrent(EnvironmentScope::Local)
} 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)
}
}
/// `declare -f` with attribute flags applies them to the named function rather than
/// displaying it (e.g. `declare -ft name`, `declare -fx name`).
fn apply_function_attributes(
&self,
context: &mut brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
declaration: &brush_core::CommandArg,
) -> bool {
let func = match declaration {
brush_core::CommandArg::String(name) => context.shell.func_mut(name),
brush_core::CommandArg::Assignment(_) => None,
};
// As with display, bash reports failure without printing an error message here.
let Some(func) = func else {
return false;
};
match change(&self.make_exported) {
Some(true) => func.export(),
Some(false) => func.unexport(),
None => (),
}
// TODO(declare): function tracing (-t) isn't tracked; it's accepted silently.
true
}
fn process_declaration(
&self,
context: &mut brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
declaration: &brush_core::CommandArg,
verb: DeclareVerb,
) -> Result<bool, brush_core::Error> {
let create_var_local = matches!(verb, DeclareVerb::Local)
|| (matches!(verb, DeclareVerb::Declare)
&& context.shell.in_function()
&& !self.create_global);
if (self.function_names_or_defs_only || self.function_names_only)
&& (change(&self.make_traced).is_some() || change(&self.make_exported).is_some())
{
return Ok(self.apply_function_attributes(context, declaration));
}
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, append) =
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 identifier",
context.command_name
)?;
return Ok(false);
}
// Figure out where we should look.
let lookup = if create_var_local {
EnvironmentLookup::OnlyInCurrent(EnvironmentScope::Local)
} else {
EnvironmentLookup::Anywhere
};
// `local -I x[=v]` / `declare -I` (bash 5.0+): the new local inherits
// value and attributes from the nearest same-name variable in an
// enclosing scope instead of starting unset; `+=` appends to the
// inherited value. With no same-name variable anywhere, fall through
// to ordinary creation.
if self.locals_inherit_from_prev_scope && create_var_local {
let inherited = context
.shell
.env()
.get_using_policy(name.as_str(), EnvironmentLookup::Anywhere)
.cloned();
if let Some(mut var) = inherited {
self.apply_attributes_before_update(&mut var)?;
if let Some(initial_value) = initial_value {
var.assign(initial_value, append || assigned_index.is_some())?;
}
if context.shell.options().export_variables_on_modification
&& !var.value().is_array()
{
var.export();
}
self.apply_attributes_after_update(&mut var, verb)?;
context
.shell
.env_mut()
.add(name, var, EnvironmentScope::Local)?;
return Ok(true);
}
}
// Look up the variable.
if let Some(var) = context
.shell
.env_mut()
.get_mut_using_policy(name.as_str(), lookup)
{
if change(&self.make_associative_array).is_some() {
var.convert_to_associative_array()?;
}
if change(&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 for `name+=value`, or if the declaration included
// an explicit index.
var.assign(initial_value, append || assigned_index.is_some())?;
}
self.apply_attributes_after_update(var, verb)?;
} else {
let unset_type = if change(&self.make_indexed_array).is_some() {
ShellValueUnsetType::IndexedArray
} else if change(&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, append)?;
}
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)
}
#[expect(clippy::type_complexity)]
fn declaration_to_name_and_value(
declaration: &brush_core::CommandArg,
) -> Result<
(
String,
Option<String>,
Option<ShellValueLiteral>,
bool,
bool,
),
brush_core::Error,
> {
let name;
let assigned_index;
let initial_value;
let name_is_array;
let append;
match declaration {
brush_core::CommandArg::String(s) => {
// We need to handle the case of someone invoking `declare array[index]`.
// In such case, we ignore the index and treat it as a declaration of
// the array.
#[allow(
clippy::unwrap_in_result,
clippy::unwrap_used,
reason = "regex is valid and should not fail"
)]
static ARRAY_AND_INDEX_RE: LazyLock<fancy_regex::Regex> =
LazyLock::new(|| fancy_regex::Regex::new(r"^(.*?)\[(.*?)\]$").unwrap());
if let Some(captures) = ARRAY_AND_INDEX_RE.captures(s)? {
name = captures
.get(1)
.ok_or_else(|| {
brush_core::ErrorKind::InternalError("declaration parse error".into())
})?
.as_str()
.to_owned();
assigned_index = captures.get(2).map(|m| m.as_str().to_owned());
name_is_array = true;
} else {
name = s.clone();
assigned_index = None;
name_is_array = false;
}
initial_value = None;
append = false;
}
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());
}
}
append = assignment.append;
match &assignment.value {
ast::AssignmentValue::Scalar(s) => {
if let Some(index) = &assigned_index {
initial_value = Some(ShellValueLiteral::Array(ArrayLiteral::new(
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::new(
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, append))
}
fn display_matching_env_declarations(
&self,
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
verb: DeclareVerb,
) -> Result<(), brush_core::Error> {
//
// Dump all declarations. Use attribute flags to filter which variables are dumped.
//
// We start by excluding all variables that are not enumerable.
#[expect(clippy::type_complexity)]
let mut filters: Vec<Box<dyn Fn((&String, &ShellVariable)) -> bool>> =
vec![Box::new(|(_, v)| v.is_enumerable())];
// Add filters depending on verb.
if matches!(verb, DeclareVerb::Readonly) {
filters.push(Box::new(|(_, v)| v.is_readonly()));
}
// Add filters depending on attribute flags. Only `-X` filters; `+X` doesn't.
let given = |options: &[bool]| options.contains(&true);
if given(&self.make_indexed_array) {
filters.push(Box::new(|(_, v)| {
matches!(v.value(), ShellValue::IndexedArray(_))
}));
}
if given(&self.make_associative_array) {
filters.push(Box::new(|(_, v)| {
matches!(v.value(), ShellValue::AssociativeArray(_))
}));
}
if given(&self.make_integer) {
filters.push(Box::new(|(_, v)| v.is_treated_as_integer()));
}
if given(&self.capitalize_value_on_assignment) {
filters.push(Box::new(|(_, v)| {
matches!(
v.get_update_transform(),
ShellVariableUpdateTransform::Capitalize
)
}));
}
if given(&self.lowercase_value_on_assignment) {
filters.push(Box::new(|(_, v)| {
matches!(
v.get_update_transform(),
ShellVariableUpdateTransform::Lowercase
)
}));
}
if given(&self.make_nameref) {
filters.push(Box::new(|(_, v)| v.is_treated_as_nameref()));
}
if given(&self.make_readonly) {
filters.push(Box::new(|(_, v)| v.is_readonly()));
}
if given(&self.make_traced) {
filters.push(Box::new(|(_, v)| v.is_trace_enabled()));
}
if given(&self.uppercase_value_on_assignment) {
filters.push(Box::new(|(_, v)| {
matches!(
v.get_update_transform(),
ShellVariableUpdateTransform::Uppercase
)
}));
}
if given(&self.make_exported) {
filters.push(Box::new(|(_, v)| v.is_exported()));
}
let iter_policy = if matches!(verb, DeclareVerb::Local) {
EnvironmentLookup::OnlyInCurrent(EnvironmentScope::Local)
} 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)]
fn apply_attributes_before_update(
&self,
var: &mut ShellVariable,
) -> Result<(), brush_core::Error> {
if let Some(value) = change(&self.make_integer) {
if value {
var.treat_as_integer();
} else {
var.unset_treat_as_integer();
}
}
if let Some(value) = change(&self.capitalize_value_on_assignment) {
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) = change(&self.lowercase_value_on_assignment) {
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) = change(&self.make_nameref) {
if value {
var.treat_as_nameref();
} else {
var.unset_treat_as_nameref();
}
}
if let Some(value) = change(&self.make_traced) {
if value {
var.enable_trace();
} else {
var.disable_trace();
}
}
if let Some(value) = change(&self.uppercase_value_on_assignment) {
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) = change(&self.make_exported) {
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) = change(&self.make_readonly) {
if value {
var.set_readonly();
} else {
var.unset_readonly()?;
}
}
Ok(())
}
}
@@ -0,0 +1,106 @@
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,
/// There is no other directory to exchange with the current one.
#[error("no other directory")]
NoOtherDirectory,
/// 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 | DirError::NoOtherDirectory => Self::GeneralError,
DirError::ShellError(e) => e.into(),
}
}
}
impl brush_core::BuiltinError for DirError {}
/// Manage the current directory stack.
#[derive(Default, winnow_args::Args)]
#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)]
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
}
brush_winnow_args_adapter::impl_parse_args!(DirsCommand);
impl<SE: brush_core::ShellExtensions> builtins::Builtin<SE> for DirsCommand {
type Error = brush_core::Error;
async fn execute(
self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
if self.clear {
context.shell.directory_stack_mut().clear();
} else {
let dirs = vec![context.shell.working_dir()]
.into_iter()
.chain(
context
.shell
.directory_stack()
.iter()
.rev()
.map(|p| p.as_path()),
)
.collect::<Vec<_>>();
let one_per_line = self.print_one_per_line || self.print_one_per_line_with_index;
for (i, dir) in dirs.iter().enumerate() {
if !one_per_line && i > 0 {
write!(context.stdout(), " ")?;
}
if self.print_one_per_line_with_index {
write!(context.stdout(), "{i:2} ")?;
}
let mut dir_str = dir.to_string_lossy().to_string();
if !self.tilde_long {
dir_str = context.shell.tilde_shorten(dir_str);
}
write!(context.stdout(), "{dir_str}")?;
if one_per_line || i == dirs.len() - 1 {
writeln!(context.stdout())?;
}
}
return Ok(ExecutionResult::success());
}
Ok(ExecutionResult::success())
}
}
@@ -0,0 +1,37 @@
use std::path::Path;
use brush_core::builtins;
/// Evaluate the provided script in the current shell environment.
#[derive(winnow_args::Args)]
#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)]
pub(crate) struct DotCommand {
/// Path to the script to evaluate.
#[arg(positional, double_dash = "automatic")]
script_path: String,
/// Any arguments to be passed as positional parameters to the script.
#[arg(positional, allow_negative_numbers)]
script_args: Vec<String>,
}
brush_winnow_args_adapter::impl_parse_args!(DotCommand);
impl<SE: brush_core::ShellExtensions> builtins::Builtin<SE> for DotCommand {
type Error = brush_core::Error;
async fn execute(
self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
// TODO(dot): Handle trap inheritance.
context
.shell
.source_script(
Path::new(&self.script_path),
self.script_args.iter(),
&context.params,
)
.await
}
}
@@ -0,0 +1,73 @@
use std::io::Write;
use brush_core::{ExecutionResult, builtins, escape};
/// Echo text to standard output.
#[derive(winnow_args::Args)]
#[arg(
disable_help_flag,
disable_version_flag,
disable_help_subcommand,
unknown_flags = "value"
)]
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', overrides = "-E")]
interpret_backslash_escapes: bool,
/// Do not interpret backslash escapes in the provided text.
#[arg(short = 'E', overrides = "-e")]
no_interpret_backslash_escapes: bool,
/// Tokens to echo to standard output.
#[arg(positional, double_dash = "preserve", stop_flags)]
args: Vec<String>,
}
brush_winnow_args_adapter::impl_parse_args!(EchoCommand, keep_double_dash = args);
impl<SE: brush_core::ShellExtensions> builtins::Builtin<SE> for EchoCommand {
type Error = brush_core::Error;
async fn execute(
self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
let mut trailing_newline = !self.no_trailing_newline;
let mut s;
if self.interpret_backslash_escapes {
s = String::new();
for (i, arg) in self.args.iter().enumerate() {
if i > 0 {
s.push(' ');
}
let (expanded_arg, keep_going) = escape::expand_backslash_escapes(
arg.as_str(),
escape::EscapeExpansionMode::EchoBuiltin,
)?;
s.push_str(&String::from_utf8_lossy(expanded_arg.as_slice()));
if !keep_going {
trailing_newline = false;
break;
}
}
} else {
s = self.args.join(" ");
}
if trailing_newline {
s.push('\n');
}
write!(context.stdout(), "{s}")?;
context.stdout().flush()?;
Ok(ExecutionResult::success())
}
}
@@ -0,0 +1,87 @@
use brush_core::ExecutionResult;
use std::io::Write;
use brush_core::builtins;
use brush_core::error;
/// Enable, disable, or display built-in commands.
#[derive(winnow_args::Args)]
#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)]
pub(crate) struct EnableCommand {
/// Print a list of built-in commands.
#[arg(short = 'a')]
print_list: bool,
/// Remove the built-in commands loaded from the indicated object path.
#[arg(short = 'd')]
remove_loaded_builtin: bool,
/// Disables the specified built-in commands.
#[arg(short = 'n')]
disable: bool,
/// Print a list of built-in commands with reusable output.
#[arg(short = 'p')]
print_reusably: bool,
/// Only operate on special built-in commands.
#[arg(short = 's')]
special_only: bool,
/// Path to a shared object from which built-in commands will be loaded.
#[arg(short = 'f', value_name = "PATH")]
shared_object_path: Option<String>,
/// Names of built-in commands to operate on.
#[arg(positional)]
names: Vec<String>,
}
brush_winnow_args_adapter::impl_parse_args!(EnableCommand);
impl<SE: brush_core::ShellExtensions> builtins::Builtin<SE> for EnableCommand {
type Error = brush_core::Error;
async fn execute(
self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<ExecutionResult, Self::Error> {
let mut result = ExecutionResult::success();
if self.shared_object_path.is_some() {
return error::unimp("enable -f");
}
if self.remove_loaded_builtin {
return error::unimp("enable -d");
}
if !self.names.is_empty() {
for name in &self.names {
if let Some(builtin) = context.shell.builtins_mut().get_mut(name) {
builtin.set_enabled(!self.disable);
} else {
writeln!(context.stderr(), "{name}: not a shell builtin")?;
result = ExecutionResult::general_error();
}
}
} else {
for (builtin_name, builtin) in context.shell.builtins().iter() {
// With -a, every builtin is listed. Otherwise -n lists just the disabled
// ones and the default (with or without -p) lists just the enabled ones.
if !self.print_list && builtin.is_enabled() == self.disable {
continue;
}
if self.special_only && !builtin.is_special() {
continue;
}
let prefix = if !builtin.is_enabled() { "-n " } else { "" };
writeln!(context.stdout(), "enable {prefix}{builtin_name}")?;
}
}
Ok(result)
}
}
@@ -0,0 +1,44 @@
use brush_core::{ExecutionResult, builtins};
/// Evaluate the given string as script.
#[derive(winnow_args::Args)]
#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)]
pub(crate) struct EvalCommand {
/// The script to evaluate.
#[arg(positional, double_dash = "automatic")]
args: Vec<String>,
}
brush_winnow_args_adapter::impl_parse_args!(EvalCommand);
impl<SE: brush_core::ShellExtensions> builtins::Builtin<SE> for EvalCommand {
type Error = brush_core::Error;
async fn execute(
self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
if !self.args.is_empty() {
let args_concatenated = self.args.join(" ");
tracing::debug!("Applying eval to: {:?}", args_concatenated);
// Our new source context is relative to the current position because we are only
// providing the raw string being eval'd.
// TODO(source-info): Provide the location of the specific tokens that make up
// `self.args`.
let source_info = context.shell.call_stack().current_pos_as_source_info();
// Return the direct result of running the string; we intentionally
// pass through the result and honor its requested control flow. eval
// executes in the current environment, so all control flow (return,
// exit, break, continue) should propagate.
context
.shell
.run_string(args_concatenated, &source_info, &context.params)
.await
} else {
Ok(ExecutionResult::success())
}
}
}
@@ -0,0 +1,97 @@
use std::borrow::Cow;
use brush_core::{
ErrorKind, ExecutionExitCode, ExecutionResult, builtins, commands,
extensions::ProcessLauncher as _,
};
/// Exec the provided command.
#[derive(winnow_args::Args)]
#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)]
pub(crate) struct ExecCommand {
/// 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,
/// Pass given name as zeroth argument to command.
#[arg(short = 'a', value_name = "NAME", allow_hyphen_values)]
name_for_argv0: Option<String>,
/// Command and args.
#[arg(positional, double_dash = "automatic")]
args: Vec<String>,
}
brush_winnow_args_adapter::impl_parse_args!(ExecCommand);
impl<SE: brush_core::ShellExtensions> builtins::Builtin<SE> for ExecCommand {
type Error = brush_core::Error;
async fn execute(
self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<ExecutionResult, Self::Error> {
if self.args.is_empty() {
// When no arguments are present, then there's nothing for us to execute -- but we need
// to ensure that any redirections setup for this builtin get applied to the calling
// shell instance.
#[allow(clippy::needless_collect)]
let fds: Vec<_> = context.iter_fds().collect();
context.shell.replace_open_files(fds.into_iter());
return Ok(ExecutionResult::success());
}
// If we know we're already running in a subshell, then `exec`ing is actually
// unsafe, since it would also replace the *parent* shell instance. We instead
// delegate to the `command` builtin to perform the execution, with an expectation
// of returning.
if context.shell.is_subshell() {
if self.empty_environment || self.exec_as_login || self.name_for_argv0.is_some() {
return brush_core::error::unimp("exec with options in subshell not yet supported");
}
let cmd_cmd = crate::command::CommandCommand {
command_and_args: self.args.clone(),
..Default::default()
};
return cmd_cmd.execute(context).await;
}
let mut argv0 = Cow::Borrowed(self.name_for_argv0.as_ref().unwrap_or(&self.args[0]));
if self.exec_as_login {
argv0 = Cow::Owned(std::format!("-{argv0}"));
}
// Resolve the program as running it as a command would.
let Some(program) = context.shell.resolve_external_command(&self.args[0]) else {
return Ok(ExecutionExitCode::NotFound.into());
};
let request = commands::compose_launch_request(
&context,
&program,
argv0.as_str(),
&self.args[1..],
if self.empty_environment {
commands::ProgramEnvironment::Empty
} else {
commands::ProgramEnvironment::Exported
},
);
let exec_error = context.shell.process_launcher().exec(request);
if exec_error.kind() == std::io::ErrorKind::NotFound {
Ok(ExecutionExitCode::NotFound.into())
} else {
Err(ErrorKind::from(exec_error).into())
}
}
}
@@ -0,0 +1,33 @@
use brush_core::{ExecutionControlFlow, ExecutionResult, builtins};
/// Exit the shell.
#[derive(winnow_args::Args)]
#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)]
pub(crate) struct ExitCommand {
/// The exit code to return.
#[arg(positional, allow_negative_numbers)]
code: Option<i64>,
}
brush_winnow_args_adapter::impl_parse_args!(ExitCommand);
impl<SE: brush_core::ShellExtensions> builtins::Builtin<SE> for ExitCommand {
type Error = brush_core::Error;
async fn execute(
self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
#[expect(clippy::cast_sign_loss)]
let code_8bit = if let Some(code_32bit) = &self.code {
(code_32bit & 0xFF) as u8
} else {
context.shell.last_exit_status()
};
let mut result = ExecutionResult::new(code_8bit);
result.next_control_flow = ExecutionControlFlow::ExitShell;
Ok(result)
}
}
@@ -0,0 +1,207 @@
use itertools::Itertools;
use std::io::Write;
use brush_core::{
ExecutionExitCode, ExecutionResult, builtins,
env::{self, EnvironmentLookup, EnvironmentScope},
parser::ast,
variables,
};
/// Add or update exported shell variables.
#[derive(winnow_args::Args)]
#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)]
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 winnow-args, but filled in by `impl_parse_args!`.
#[arg(skip)]
declarations: Vec<brush_core::CommandArg>,
}
brush_winnow_args_adapter::impl_parse_args!(ExportCommand, declarations = declarations);
impl<SE: brush_core::ShellExtensions> builtins::Builtin<SE> for ExportCommand {
type Error = brush_core::Error;
async fn execute(
self,
mut context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
if self.declarations.is_empty() {
display_all_exported_vars(&context)?;
return Ok(ExecutionResult::success());
}
let mut result = ExecutionResult::success();
for decl in &self.declarations {
let current_result = self.process_decl(&mut context, decl)?;
if !current_result.is_success() {
result = current_result;
}
}
Ok(result)
}
}
impl ExportCommand {
fn process_decl(
&self,
context: &mut brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
decl: &brush_core::CommandArg,
) -> Result<ExecutionResult, brush_core::Error> {
match decl {
brush_core::CommandArg::String(s) => {
// See if this is supposed to be a function name.
if self.names_are_functions {
// Try to find the function already present; if we find it, then mark it
// exported.
if let Some(func) = context.shell.func_mut(s) {
if self.unexport {
func.unexport();
} else {
func.export();
}
} else {
writeln!(context.stderr(), "{s}: not a function")?;
return Ok(ExecutionExitCode::InvalidUsage.into());
}
}
// A word that expanded into an assignment (`export ${v}=x`): the
// parser can't see it as one, so it is split here, as bash does.
else if let Some((name, value)) = s.split_once('=') {
let (name, append) = name
.strip_suffix('+')
.map_or((name, false), |name| (name, true));
if !env::valid_variable_name(name) {
return Self::invalid_identifier(context, s);
}
let value = variables::ShellValueLiteral::Scalar(value.to_owned());
self.assign_and_mark(context, name, value, append)?;
} else if !env::valid_variable_name(s) {
return Self::invalid_identifier(context, s);
}
// 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(name, index) => {
return Self::invalid_identifier(context, &format!("{name}[{index}]"));
}
};
let value = match &assignment.value {
ast::AssignmentValue::Scalar(s) => {
variables::ShellValueLiteral::Scalar(s.flatten())
}
ast::AssignmentValue::Array(a) => {
variables::ShellValueLiteral::Array(variables::ArrayLiteral::new(
a.iter()
.map(|(k, v)| (k.as_ref().map(|k| k.flatten()), v.flatten()))
.collect(),
))
}
};
self.assign_and_mark(context, name, value, assignment.append)?;
}
}
Ok(ExecutionResult::success())
}
/// Reports `word` as an invalid name, as bash does, and returns the failure.
fn invalid_identifier(
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
word: &str,
) -> Result<ExecutionResult, brush_core::Error> {
writeln!(
context.stderr(),
"{}: `{word}': not a valid identifier",
context.command_name
)?;
Ok(ExecutionResult::general_error())
}
/// Assigns `value` to `name`, appending to it for `name+=value`, and marks
/// the variable exported (or not, for `-n`).
fn assign_and_mark(
&self,
context: &mut brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
name: &str,
value: variables::ShellValueLiteral,
append: bool,
) -> Result<(), brush_core::Error> {
// `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 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(());
}
// 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,
)
}
}
fn display_all_exported_vars(
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
) -> Result<(), brush_core::Error> {
// Enumerate variables, sorted by key.
for (name, variable) in context.shell.env().iter().sorted_by_key(|v| v.0) {
if variable.is_exported() {
let value = variable.value().try_get_cow_str(context.shell);
if let Some(value) = value {
writeln!(context.stdout(), "declare -x {name}=\"{value}\"")?;
} else {
writeln!(context.stdout(), "declare -x {name}")?;
}
}
}
Ok(())
}
@@ -0,0 +1,352 @@
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;
/// Identifies well-known sets of builtins.
#[derive(Clone, Copy, Eq, PartialEq)]
pub enum BuiltinSet {
/// Identifies builtins appropriate for POSIX `sh` compatibility.
ShMode,
/// Identifies builtins appropriate for a more full-featured `bash`-compatible shell.
BashMode,
}
/// Returns the default set of built-in commands.
///
/// # Arguments
///
/// * `set` - The set of built-ins to return.
#[allow(clippy::too_many_lines)]
pub fn default_builtins<SE: brush_core::ShellExtensions>(
set: BuiltinSet,
) -> HashMap<String, builtins::Registration<SE>> {
let mut m = HashMap::<String, builtins::Registration<SE>>::new();
//
// POSIX special builtins
//
// N.B. There seems to be some inconsistency as to whether 'times'
// should be a special built-in.
//
#[cfg(feature = "builtin.break")]
m.insert(
"break".into(),
builtins::Registration::of::<break_::BreakCommand>().special(),
);
#[cfg(feature = "builtin.colon")]
m.insert(
":".into(),
builtins::Registration::of::<colon::ColonCommand>().special(),
);
#[cfg(feature = "builtin.continue")]
m.insert(
"continue".into(),
builtins::Registration::of::<continue_::ContinueCommand>().special(),
);
#[cfg(feature = "builtin.dot")]
m.insert(
".".into(),
builtins::Registration::of::<dot::DotCommand>().special(),
);
#[cfg(feature = "builtin.eval")]
m.insert(
"eval".into(),
builtins::Registration::of::<eval::EvalCommand>().special(),
);
#[cfg(feature = "builtin.exec")]
m.insert(
"exec".into(),
builtins::Registration::of::<exec::ExecCommand>().special(),
);
#[cfg(feature = "builtin.exit")]
m.insert(
"exit".into(),
builtins::Registration::of::<exit::ExitCommand>().special(),
);
#[cfg(feature = "builtin.export")]
m.insert(
"export".into(),
builtins::Registration::of::<export::ExportCommand>().special(),
);
#[cfg(feature = "builtin.return")]
m.insert(
"return".into(),
builtins::Registration::of::<return_::ReturnCommand>().special(),
);
#[cfg(feature = "builtin.set")]
m.insert(
"set".into(),
builtins::Registration::of::<set::SetCommand>().special(),
);
#[cfg(feature = "builtin.shift")]
m.insert(
"shift".into(),
builtins::Registration::of::<shift::ShiftCommand>().special(),
);
#[cfg(feature = "builtin.trap")]
m.insert(
"trap".into(),
builtins::Registration::of::<trap::TrapCommand>().special(),
);
#[cfg(feature = "builtin.unset")]
m.insert(
"unset".into(),
builtins::Registration::of::<unset::UnsetCommand>().special(),
);
#[cfg(feature = "builtin.declare")]
m.insert(
"readonly".into(),
builtins::Registration::of::<declare::DeclareCommand>().special(),
);
#[cfg(feature = "builtin.times")]
m.insert(
"times".into(),
builtins::Registration::of::<times::TimesCommand>().special(),
);
//
// Non-special builtins
//
#[cfg(feature = "builtin.alias")]
m.insert(
"alias".into(),
builtins::Registration::of::<alias::AliasCommand>(),
); // TODO(alias): should be exec_declaration_builtin
#[cfg(feature = "builtin.bg")]
m.insert("bg".into(), builtins::Registration::of::<bg::BgCommand>());
#[cfg(feature = "builtin.cd")]
m.insert("cd".into(), builtins::Registration::of::<cd::CdCommand>());
#[cfg(feature = "builtin.command")]
m.insert(
"command".into(),
builtins::Registration::of::<command::CommandCommand>(),
);
#[cfg(feature = "builtin.false")]
m.insert(
"false".into(),
builtins::Registration::of::<false_::FalseCommand>(),
);
#[cfg(feature = "builtin.fg")]
m.insert("fg".into(), builtins::Registration::of::<fg::FgCommand>());
#[cfg(feature = "builtin.getopts")]
m.insert(
"getopts".into(),
builtins::Registration::of::<getopts::GetOptsCommand>(),
);
#[cfg(feature = "builtin.hash")]
m.insert(
"hash".into(),
builtins::Registration::of::<hash::HashCommand>(),
);
#[cfg(feature = "builtin.help")]
m.insert(
"help".into(),
builtins::Registration::of::<help::HelpCommand>(),
);
#[cfg(feature = "builtin.jobs")]
m.insert(
"jobs".into(),
builtins::Registration::of::<jobs::JobsCommand>(),
);
#[cfg(all(feature = "builtin.kill", unix))]
m.insert(
"kill".into(),
builtins::Registration::of::<kill::KillCommand>(),
);
#[cfg(feature = "builtin.declare")]
m.insert(
"local".into(),
builtins::Registration::of::<declare::DeclareCommand>(),
);
#[cfg(feature = "builtin.pwd")]
m.insert(
"pwd".into(),
builtins::Registration::of::<pwd::PwdCommand>(),
);
#[cfg(feature = "builtin.read")]
m.insert(
"read".into(),
builtins::Registration::of::<read::ReadCommand>(),
);
#[cfg(feature = "builtin.true")]
m.insert(
"true".into(),
builtins::Registration::of::<true_::TrueCommand>(),
);
#[cfg(feature = "builtin.type")]
m.insert(
"type".into(),
builtins::Registration::of::<type_::TypeCommand>(),
);
#[cfg(all(feature = "builtin.ulimit", unix))]
m.insert(
"ulimit".into(),
builtins::Registration::of::<ulimit::ULimitCommand>(),
);
#[cfg(all(feature = "builtin.umask", unix))]
m.insert(
"umask".into(),
builtins::Registration::of::<umask::UmaskCommand>(),
);
#[cfg(feature = "builtin.unalias")]
m.insert(
"unalias".into(),
builtins::Registration::of::<unalias::UnaliasCommand>(),
);
#[cfg(feature = "builtin.wait")]
m.insert(
"wait".into(),
builtins::Registration::of::<wait::WaitCommand>(),
);
#[cfg(feature = "builtin.fc")]
m.insert("fc".into(), builtins::Registration::of::<fc::FcCommand>());
if matches!(set, BuiltinSet::BashMode) {
#[cfg(feature = "builtin.builtin")]
m.insert(
"builtin".into(),
builtins::Registration::of::<builtin_::BuiltinCommand>(),
);
#[cfg(feature = "builtin.declare")]
m.insert(
"declare".into(),
builtins::Registration::of::<declare::DeclareCommand>(),
);
#[cfg(feature = "builtin.echo")]
m.insert(
"echo".into(),
builtins::Registration::of::<echo::EchoCommand>(),
);
#[cfg(feature = "builtin.enable")]
m.insert(
"enable".into(),
builtins::Registration::of::<enable::EnableCommand>(),
);
#[cfg(feature = "builtin.let")]
m.insert(
"let".into(),
builtins::Registration::of::<let_::LetCommand>(),
);
#[cfg(feature = "builtin.mapfile")]
m.insert(
"mapfile".into(),
builtins::Registration::of::<mapfile::MapFileCommand>(),
);
#[cfg(feature = "builtin.mapfile")]
m.insert(
"readarray".into(),
builtins::Registration::of::<mapfile::MapFileCommand>(),
);
#[cfg(all(feature = "builtin.printf", any(unix, windows, target_os = "wasi")))]
m.insert(
"printf".into(),
builtins::Registration::of::<printf::PrintfCommand>(),
);
#[cfg(feature = "builtin.shopt")]
m.insert(
"shopt".into(),
builtins::Registration::of::<shopt::ShoptCommand>(),
);
#[cfg(feature = "builtin.dot")]
m.insert(
"source".into(),
builtins::Registration::of::<dot::DotCommand>().special(),
);
#[cfg(all(feature = "builtin.suspend", unix))]
m.insert(
"suspend".into(),
builtins::Registration::of::<suspend::SuspendCommand>(),
);
#[cfg(feature = "builtin.test")]
m.insert(
"test".into(),
builtins::Registration::of::<test::TestCommand>(),
);
#[cfg(feature = "builtin.test")]
m.insert(
"[".into(),
builtins::Registration::of::<test::TestCommand>(),
);
#[cfg(feature = "builtin.declare")]
m.insert(
"typeset".into(),
builtins::Registration::of::<declare::DeclareCommand>(),
);
// Completion builtins
#[cfg(feature = "builtin.complete")]
m.insert(
"complete".into(),
builtins::Registration::of::<complete::CompleteCommand>(),
);
#[cfg(feature = "builtin.compgen")]
m.insert(
"compgen".into(),
builtins::Registration::of::<complete::CompGenCommand>(),
);
#[cfg(feature = "builtin.compopt")]
m.insert(
"compopt".into(),
builtins::Registration::of::<complete::CompOptCommand>(),
);
// Dir stack builtins
#[cfg(feature = "builtin.dirs")]
m.insert(
"dirs".into(),
builtins::Registration::of::<dirs::DirsCommand>(),
);
#[cfg(feature = "builtin.popd")]
m.insert(
"popd".into(),
builtins::Registration::of::<popd::PopdCommand>(),
);
#[cfg(feature = "builtin.pushd")]
m.insert(
"pushd".into(),
builtins::Registration::of::<pushd::PushdCommand>(),
);
// Input configuration builtins
#[cfg(feature = "builtin.bind")]
m.insert(
"bind".into(),
builtins::Registration::of::<bind::BindCommand>(),
);
// History
#[cfg(feature = "builtin.history")]
m.insert(
"history".into(),
builtins::Registration::of::<history::HistoryCommand>(),
);
#[cfg(feature = "builtin.caller")]
m.insert(
"caller".into(),
builtins::Registration::of::<caller::CallerCommand>(),
);
// TODO(disown): implement disown builtin
m.insert(
"disown".into(),
builtins::Registration::of::<unimp::UnimplementedCommand>(),
);
// TODO(logout): implement logout builtin
m.insert(
"logout".into(),
builtins::Registration::of::<unimp::UnimplementedCommand>(),
);
}
m
}
@@ -0,0 +1,34 @@
use brush_core::{ExecutionResult, builtins, error};
/// Return exit code 1.
pub(crate) struct FalseCommand {}
impl builtins::ParseArgs for FalseCommand {
fn content(
name: &str,
content_type: builtins::ContentType,
_options: &builtins::ContentOptions,
) -> Result<String, brush_core::Error> {
match content_type {
builtins::ContentType::DetailedHelp => Ok("Returns a failure exit status.".into()),
builtins::ContentType::ShortUsage => Ok(format!("{name}: {name}\n")),
builtins::ContentType::ShortDescription => Ok(format!("{name} - fail\n")),
builtins::ContentType::ManPage => error::unimp("man page not yet implemented"),
}
}
fn parse(_invocation: builtins::Invocation<'_>) -> Result<Self, builtins::UsageError> {
Ok(Self {})
}
}
impl<SE: brush_core::ShellExtensions> builtins::Builtin<SE> for FalseCommand {
type Error = brush_core::Error;
async fn execute(
self,
_context: brush_core::ExecutionContext<'_, SE>,
) -> Result<ExecutionResult, Self::Error> {
Ok(ExecutionResult::general_error())
}
}
@@ -0,0 +1,303 @@
use brush_core::{ExecutionResult, builtins, error, history};
use std::io::Write;
/// Process command history list.
#[derive(winnow_args::Args)]
#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)]
pub(crate) struct FcCommand {
/// Editor to use (only relevant when not listing or substituting).
#[arg(short = 'e', value_name = "ENAME", allow_hyphen_values)]
editor: Option<String>,
/// List commands instead of editing them.
#[arg(short = 'l')]
list: bool,
/// Suppress line numbers when listing.
#[arg(short = 'n', requires = "-l")]
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,
/// First command in range (number or string prefix).
#[arg(positional, value_name = "FIRST", allow_negative_numbers)]
first: Option<String>,
/// Last command in range (number or string prefix).
#[arg(positional, value_name = "LAST", allow_negative_numbers)]
last: Option<String>,
}
brush_winnow_args_adapter::impl_parse_args!(FcCommand);
impl<SE: brush_core::ShellExtensions> builtins::Builtin<SE> for FcCommand {
type Error = brush_core::Error;
async fn execute(
self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<ExecutionResult, Self::Error> {
if self.substitute {
return self.do_execute(context).await;
}
if self.list {
return self.do_list(&context);
}
error::unimp("fc editor mode is not yet implemented")
}
}
impl FcCommand {
fn do_list(
&self,
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
) -> Result<ExecutionResult, brush_core::Error> {
let history = context
.shell
.history()
.ok_or_else(|| brush_core::Error::from(brush_core::ErrorKind::HistoryNotEnabled))?;
let (first_idx, last_idx, reverse) = self.resolve_range(history)?;
// Determine the order of iteration
let indices: Vec<usize> = if reverse {
(first_idx..=last_idx).rev().collect()
} else {
(first_idx..=last_idx).collect()
};
for idx in indices {
if let Some(item) = history.get(idx) {
if self.no_line_numbers {
// With -n, bash still outputs a tab before the command
writeln!(context.stdout(), "\t {}", item.command_line)?;
} else {
// Match bash's fc format: number, tab, command
writeln!(context.stdout(), "{}\t {}", idx + 1, item.command_line)?;
}
}
}
Ok(ExecutionResult::success())
}
async fn do_execute(
&self,
context: brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
) -> Result<ExecutionResult, brush_core::Error> {
let history = context
.shell
.history()
.ok_or_else(|| brush_core::Error::from(brush_core::ErrorKind::HistoryNotEnabled))?;
// Parse the first argument for pattern=replacement
let (pattern, replacement) = self
.first
.as_ref()
.and_then(|s| s.split_once('='))
.map_or((None, None), |(p, r)| (Some(p), Some(r)));
// Determine which command to re-execute
let cmd_spec = if pattern.is_some() {
// If we have a pattern, the command spec is in 'last' if present
self.last.as_deref()
} else {
// Otherwise, it's in 'first'
self.first.as_deref()
};
// Find the command
let cmd_line = if let Some(spec) = cmd_spec {
Self::find_command_by_specifier(history, spec)?
} else {
// No spec means use the previous command (excluding the fc command itself)
let effective_count = effective_history_count(history);
history
.get(effective_count.saturating_sub(1))
.map(|item| item.command_line.clone())
.ok_or_else(|| brush_core::Error::from(error::ErrorKind::HistoryItemNotFound))?
};
// Apply substitution if present
let final_cmd = if let (Some(pat), Some(rep)) = (pattern, replacement) {
cmd_line.replace(pat, rep)
} else {
cmd_line
};
// Echo the command to stderr.
writeln!(context.stderr(), "{final_cmd}")?;
// Remove the fc command from history before executing the substituted command
// This matches bash behavior where the fc command is replaced by the executed command
let history_mut = context
.shell
.history_mut()
.ok_or_else(|| brush_core::Error::from(brush_core::ErrorKind::HistoryNotEnabled))?;
history_mut.remove_nth_item(history_mut.count().saturating_sub(1));
let source_info = brush_core::SourceInfo::from("(history)");
// Execute the command
let result = context
.shell
.run_string(final_cmd.clone(), &source_info, &context.params)
.await?;
// Add the executed command to history.
context.shell.add_to_history(&final_cmd)?;
Ok(result)
}
fn resolve_range(
&self,
history: &history::History,
) -> Result<(usize, usize, bool), brush_core::Error> {
let effective_count = effective_history_count(history);
let max_idx = effective_count.saturating_sub(1);
// Resolve first index
let first_idx = self
.first
.as_ref()
.map(|s| Self::resolve_position(history, s))
.transpose()?
.unwrap_or_else(|| {
if self.list {
effective_count.saturating_sub(16) // Default for listing: -16
} else {
max_idx // Default for editing: previous command
}
});
// Resolve last index (default depends on mode and first_idx)
let default_last = if self.list { max_idx } else { first_idx };
let last_idx = self
.last
.as_ref()
.map(|s| Self::resolve_position(history, s))
.transpose()?
.unwrap_or(default_last);
// If first > last, swap them and indicate reversal
let (first_idx, last_idx, force_reverse) = if first_idx > last_idx {
(last_idx, first_idx, true)
} else {
(first_idx, last_idx, false)
};
// Clamp both indices to valid range
Ok((
first_idx.min(max_idx),
last_idx.min(max_idx),
force_reverse || self.reverse,
))
}
/// Resolves a position specifier (number or string prefix) to a history index.
/// NOTE: The returned index may still be out of range if the history is empty.
///
/// # Arguments
///
/// * `history` - The history to resolve against.
/// * `spec` - The position specifier (number or string prefix).
fn resolve_position(
history: &history::History,
spec: &str,
) -> Result<usize, brush_core::Error> {
// Try to parse it as a number. If it's not parseable, then we need to assume
// it's a string prefix we need to search for.
let Ok(num) = spec.parse::<i64>() else {
// Not a number, treat as string prefix
return Self::find_command_by_prefix(history, spec);
};
let effective_count = effective_history_count(history);
#[expect(clippy::cast_sign_loss)]
#[expect(clippy::cast_possible_truncation)]
let result = match num.cmp(&0) {
std::cmp::Ordering::Equal => {
// 0 means -1 for listing (relative to effective count)
effective_count.saturating_sub(1)
}
std::cmp::Ordering::Greater => {
// Positive: 1-based index
let idx = (num - 1) as usize;
if idx < effective_count {
idx
} else {
// Out of range - use 0 (first item)
0
}
}
std::cmp::Ordering::Less => {
// Negative: offset from end (relative to effective count)
let offset = (-num) as usize;
effective_count.saturating_sub(offset)
}
};
Ok(result)
}
/// Finds the command matching the given specifier (number or string prefix). Returns
/// the command line. Returns an error if no such command can be found in the history.
///
/// # Arguments
///
/// * `history` - The history to search.
/// * `spec` - The position spec
fn find_command_by_specifier(
history: &history::History,
spec: &str,
) -> Result<String, brush_core::Error> {
let idx = Self::resolve_position(history, spec)?;
history
.get(idx)
.map(|item| item.command_line.clone())
.ok_or_else(|| brush_core::Error::from(error::ErrorKind::HistoryItemNotFound))
}
/// Finds the most recent command starting with the given prefix. Returns
/// the index of the command in the history. Returns an error if no such
/// command can be found in the history.
///
/// # Arguments
///
/// * `history` - The history to search.
/// * `prefix` - The command prefix to search for.
fn find_command_by_prefix(
history: &history::History,
prefix: &str,
) -> Result<usize, brush_core::Error> {
// Search backwards for a command starting with the prefix (excluding fc command itself)
let effective_count = effective_history_count(history);
for idx in (0..effective_count).rev() {
if let Some(item) = history.get(idx) {
if item.command_line.starts_with(prefix) {
return Ok(idx);
}
}
}
Err(brush_core::Error::from(
error::ErrorKind::HistoryItemNotFound,
))
}
}
/// Returns the effective history count (excluding the fc command itself).
fn effective_history_count(history: &history::History) -> usize {
history.count().saturating_sub(1)
}
@@ -0,0 +1,77 @@
use std::io::Write;
use brush_core::{ExecutionResult, builtins, jobs, sys};
/// Move a specified job to the foreground.
#[derive(winnow_args::Args)]
#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)]
pub(crate) struct FgCommand {
/// Job spec for the job to move to the foreground; if not specified, the current job is moved.
#[arg(positional)]
job_spec: Option<String>,
}
brush_winnow_args_adapter::impl_parse_args!(FgCommand);
impl<SE: brush_core::ShellExtensions> builtins::Builtin<SE> for FgCommand {
type Error = brush_core::Error;
async fn execute(
self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
let mut stdout = 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!(stdout, "{}", 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!(
context.stderr(),
"{}: {}: no such job",
context.command_name,
job_spec
)?;
Ok(ExecutionResult::general_error())
}
} else {
if let Some(job) = context.shell.jobs_mut().current_job_mut() {
job.move_to_foreground()?;
writeln!(stdout, "{}", 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!(context.stderr(), "{}: no current job", context.command_name)?;
Ok(ExecutionResult::general_error())
}
}
}
}
@@ -0,0 +1,373 @@
use std::{collections::HashMap, io::Write};
use brush_core::{ExecutionResult, builtins, env, variables};
/// Parse command options.
#[derive(winnow_args::Args)]
#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)]
pub(crate) struct GetOptsCommand {
/// Specification for options
#[arg(positional, stop_flags)]
options_string: String,
/// Name of variable to receive next option
#[arg(positional)]
variable_name: String,
/// Arguments to parse
#[arg(positional, double_dash = "preserve")]
args: Vec<String>,
}
/// Where `getopts` is in the arguments, between invocations; kept in the
/// shell, so it follows subshell rules as `OPTIND` does.
#[derive(Clone, Debug, Default)]
struct GetOptsState {
/// How far into the current argument's bundled options it is, if partway.
next_char_index: Option<usize>,
/// The `OPTIND` it last set, to notice the script resetting it.
last_optind: Option<i32>,
}
const DEFAULT_NEXT_CHAR_INDEX: usize = 1;
/// The result of processing one option from the argument list.
struct GetOptsResult {
/// The value to assign to the target variable (the option char, `?`, or `:`).
variable_value: String,
/// The value for OPTARG, if any (option's argument or, on error, the offending char).
optarg: Option<String>,
/// The new value for OPTIND after this call.
optind: usize,
/// Exit code: success (0) if an option was found, general error (1) when done.
exit_code: ExecutionResult,
}
/// Parsed representation of the optstring (e.g., `":a:bc"`).
struct OptionSpec {
/// Maps each option character to whether it requires an argument.
defs: HashMap<char, bool>,
/// True when the optstring has a leading `:`, suppressing error messages
/// and reporting errors via `?`/`:` in the variable and OPTARG instead.
silent_errors: bool,
}
/// Parses the optstring into an [`OptionSpec`]. A leading `:` enables silent error
/// mode. Each letter defines an option; a `:` immediately after a letter means it
/// takes an argument. Duplicate letters are ignored (first definition wins).
fn parse_option_spec(spec: &str) -> OptionSpec {
let mut defs = HashMap::<char, bool>::new();
let mut silent_errors = false;
let mut last_char = None;
for c in spec.chars() {
if c == ':' {
if let Some(last_char) = last_char {
defs.insert(last_char, true);
} else if defs.is_empty() {
// First character is ':' — request silent error reporting.
silent_errors = true;
}
continue;
}
if let std::collections::hash_map::Entry::Vacant(e) = defs.entry(c) {
e.insert(false);
last_char = Some(c);
} else {
// Duplicate option char; first definition wins.
// Clear last_char so a trailing colon doesn't modify the first definition.
last_char = None;
}
}
OptionSpec {
defs,
silent_errors,
}
}
brush_winnow_args_adapter::impl_parse_args!(GetOptsCommand, keep_double_dash = args);
impl<SE: brush_core::ShellExtensions> builtins::Builtin<SE> for GetOptsCommand {
type Error = brush_core::Error;
async fn execute(
self,
mut context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
// Validate the target variable name.
if !env::valid_variable_name(&self.variable_name) {
writeln!(
context.stderr(),
"{}: `{}': not a valid identifier",
context.command_name,
self.variable_name
)?;
return Ok(ExecutionResult::new(1));
}
let spec = parse_option_spec(&self.options_string);
// If unset or non-numeric, assume OPTIND is 1.
let next_index_signed = context
.shell
.env_str("OPTIND")
.and_then(|s| brush_core::int_utils::parse::<i32>(s.as_ref(), 10).ok())
.unwrap_or(1);
// Detect external OPTIND modifications (e.g., `OPTIND=1` to restart
// parsing). If the current OPTIND differs from what we last set, clear
// the internal char index so we don't resume mid-arg.
let state = context.state_mut::<GetOptsState>();
if state.last_optind != Some(next_index_signed) {
state.next_char_index = None;
}
#[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
/// builtin's state.
fn parse_next_option<SE: brush_core::ShellExtensions>(
context: &mut brush_core::ExecutionContext<'_, SE>,
spec: &OptionSpec,
args_to_parse: &[String],
mut next_index: usize,
) -> Result<GetOptsResult, brush_core::Error> {
// See if there are any args left to parse.
if next_index > args_to_parse.len() {
return Ok(GetOptsResult {
variable_value: String::from("?"),
optarg: None,
// Normalize OPTIND to one past the last argument.
optind: args_to_parse.len() + 1,
exit_code: ExecutionResult::general_error(),
});
}
let arg = args_to_parse[next_index - 1].as_str();
// See if this is an option.
if !arg.starts_with('-') || arg == "--" || arg == "-" {
// Not an option. If it was "--", skip past it.
return Ok(GetOptsResult {
variable_value: String::from("?"),
optarg: None,
optind: if arg == "--" {
next_index + 1
} else {
next_index
},
exit_code: ExecutionResult::general_error(),
});
}
// Figure out how far into this option we are.
let mut next_char_index = context
.state_mut::<GetOptsState>()
.next_char_index
.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: forget our place in it, and move
// OPTIND on.
context.state_mut::<GetOptsState>().next_char_index = None;
next_index + 1
} else {
// We have more to go in this argument: remember our place, and leave
// OPTIND where it is.
context.state_mut::<GetOptsState>().next_char_index = Some(next_char_index + 1);
next_index
};
Ok(GetOptsResult {
variable_value,
optarg,
optind,
exit_code: ExecutionResult::success(),
})
}
/// Resolves the argument for an option that takes a value. Returns the updated
/// `(variable_value, optarg, is_last_char, next_index)` tuple.
#[allow(clippy::too_many_arguments)]
fn resolve_option_argument<SE: brush_core::ShellExtensions>(
context: &brush_core::ExecutionContext<'_, SE>,
spec: &OptionSpec,
c: char,
arg: &str,
args_to_parse: &[String],
next_char_index: usize,
is_last_char_in_option: bool,
mut next_index: usize,
) -> Result<(String, Option<String>, bool, usize), brush_core::Error> {
// If this is the last character in the token, the argument value comes from
// the next token. Otherwise, the remainder of the current token is the value.
if is_last_char_in_option {
if next_index >= args_to_parse.len() {
// Missing required argument.
let (variable_value, optarg) = if spec.silent_errors {
(String::from(":"), Some(String::from(c)))
} else {
if is_opterr_enabled(context) {
writeln!(
context.stderr(),
"getopts: option requires an argument -- {c}"
)?;
}
(String::from("?"), None)
};
Ok((variable_value, optarg, true, next_index))
} else {
next_index += 1;
Ok((
String::from(c),
Some(args_to_parse[next_index - 1].clone()),
true,
next_index,
))
}
} else {
let optarg: String = arg.chars().skip(next_char_index + 1).collect();
Ok((String::from(c), Some(optarg), true, next_index))
}
}
/// Handles an unknown option character, reporting an error if appropriate.
fn report_unknown_option<SE: brush_core::ShellExtensions>(
context: &brush_core::ExecutionContext<'_, SE>,
spec: &OptionSpec,
c: char,
) -> Result<(String, Option<String>), brush_core::Error> {
if !spec.silent_errors && is_opterr_enabled(context) {
writeln!(context.stderr(), "getopts: illegal option -- {c}")?;
}
let optarg = if spec.silent_errors {
Some(String::from(c))
} else {
None
};
Ok((String::from("?"), optarg))
}
/// Writes the parsing result back into shell variables (the target variable,
/// OPTARG, OPTIND) and remembers the OPTIND set.
fn update_variables<SE: brush_core::ShellExtensions>(
context: &mut brush_core::ExecutionContext<'_, SE>,
variable_name: &str,
result: GetOptsResult,
) -> Result<ExecutionResult, brush_core::Error> {
// Update variable value.
context.shell.env_mut().update_or_add(
variable_name,
variables::ShellValueLiteral::Scalar(result.variable_value),
|_| Ok(()),
brush_core::env::EnvironmentLookup::Anywhere,
brush_core::env::EnvironmentScope::Global,
)?;
// Update OPTARG
if let Some(optarg) = result.optarg {
context.shell.env_mut().update_or_add(
"OPTARG",
variables::ShellValueLiteral::Scalar(optarg),
|_| Ok(()),
brush_core::env::EnvironmentLookup::Anywhere,
brush_core::env::EnvironmentScope::Global,
)?;
} else {
context.shell.env_mut().unset("OPTARG")?;
}
// Update OPTIND and record it so we can detect external modifications.
let optind_str = result.optind.to_string();
context.shell.env_mut().update_or_add(
"OPTIND",
variables::ShellValueLiteral::Scalar(optind_str),
|_| Ok(()),
brush_core::env::EnvironmentLookup::Anywhere,
brush_core::env::EnvironmentScope::Global,
)?;
context.state_mut::<GetOptsState>().last_optind = i32::try_from(result.optind).ok();
Ok(result.exit_code)
}
/// Returns whether OPTERR is enabled (i.e., getopts should print error messages).
/// OPTERR defaults to 1; any nonzero value means errors are enabled.
fn is_opterr_enabled<SE: brush_core::ShellExtensions>(
context: &brush_core::ExecutionContext<'_, SE>,
) -> bool {
context
.shell
.env_str("OPTERR")
.is_none_or(|s| s.parse::<i64>().unwrap_or(1) != 0)
}
@@ -0,0 +1,127 @@
use std::{io::Write, path::PathBuf};
use brush_core::{ExecutionResult, builtins};
#[derive(winnow_args::Args)]
#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)]
pub(crate) struct HashCommand {
/// Display paths in a format usable for input.
#[arg(short = 'l')]
display_as_usable_input: bool,
/// Remove all entries.
#[arg(short = 'r')]
remove_all: bool,
/// The path to associate with the names.
#[arg(short = 'p', value_name = "PATH")]
path_to_use: Option<PathBuf>,
/// Remove entries associated with the given names.
#[arg(short = 'd')]
remove: bool,
/// Display the paths associated with the names.
#[arg(short = 't')]
display_paths: bool,
/// Names to process.
#[arg(positional)]
names: Vec<String>,
}
brush_winnow_args_adapter::impl_parse_args!(HashCommand);
impl<SE: brush_core::ShellExtensions> builtins::Builtin<SE> for HashCommand {
type Error = brush_core::Error;
async fn execute(
self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
let mut result = ExecutionResult::success();
let cmd = &context.command_name;
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(), "{cmd}: {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(), "{cmd}: {name}: not found")?;
result = ExecutionResult::general_error();
}
}
} else if let Some(path) = &self.path_to_use {
// The shell's working directory is its own state, not the process's, so a
// relative path is resolved against it before it's inspected -- but reported as given.
let is_dir = context.shell.absolute_path(path).is_dir();
for name in &self.names {
if is_dir {
writeln!(
context.stderr(),
"{cmd}: {}: Is a directory",
path.display()
)?;
result = ExecutionResult::general_error();
continue;
}
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(), "{cmd}: {name}: not found")?;
result = ExecutionResult::general_error();
}
}
}
Ok(result)
}
}
@@ -0,0 +1,144 @@
use brush_core::{ExecutionResult, builtins};
use std::io::Write;
/// Display command help.
#[derive(winnow_args::Args)]
#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)]
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.
#[arg(positional)]
topic_patterns: Vec<String>,
}
brush_winnow_args_adapter::impl_parse_args!(HelpCommand);
impl<SE: brush_core::ShellExtensions> builtins::Builtin<SE> for HelpCommand {
type Error = brush_core::Error;
async fn execute(
self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
if self.topic_patterns.is_empty() {
Self::display_general_help(&context)?;
return Ok(ExecutionResult::success());
}
// Match bash: succeed if at least one requested topic pattern matched
// something; return a non-zero exit code only when none of them matched.
let mut any_matched = false;
for topic_pattern in &self.topic_patterns {
if self.display_help_for_topic_pattern(&context, topic_pattern)? {
any_matched = true;
}
}
if any_matched {
Ok(ExecutionResult::success())
} else {
Ok(ExecutionResult::general_error())
}
}
}
impl HelpCommand {
fn display_general_help(
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
) -> Result<(), brush_core::Error> {
const COLUMN_COUNT: usize = 3;
if let Some(display_str) = context.shell.product_display_str() {
writeln!(context.stdout(), "{display_str}\n")?;
}
writeln!(
context.stdout(),
"The following commands are implemented as shell built-ins:"
)?;
let builtins: Vec<_> = context.shell.builtins().iter().collect();
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.is_enabled() { "*" } else { " " };
write!(context.stdout(), " {prefix}{name:<20}")?; // adjust 20 to the desired
// column width
}
}
writeln!(context.stdout())?;
}
Ok(())
}
/// Displays help for the builtins matching `topic_pattern`, returning whether
/// at least one topic matched.
fn display_help_for_topic_pattern(
&self,
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
topic_pattern: &str,
) -> Result<bool, brush_core::Error> {
let pattern = brush_core::patterns::Pattern::from(topic_pattern)
.set_extended_globbing(context.shell.options().extended_globbing)
.set_case_insensitive(context.shell.options().case_insensitive_pathname_expansion);
let mut matched = false;
for (builtin_name, builtin_registration) in context.shell.builtins().iter() {
if pattern.exactly_matches(builtin_name)? {
self.display_help_for_builtin(context, builtin_name, builtin_registration)?;
matched = true;
}
}
if !matched {
writeln!(context.stderr(), "No help topics match '{topic_pattern}'")?;
}
Ok(matched)
}
fn display_help_for_builtin<SE: brush_core::ShellExtensions>(
&self,
context: &brush_core::ExecutionContext<'_, SE>,
name: &str,
registration: &builtins::Registration<SE>,
) -> Result<(), brush_core::Error> {
let content_type = if self.short_description {
builtins::ContentType::ShortDescription
} else if self.man_page_style {
builtins::ContentType::ManPage
} else if self.short_usage {
builtins::ContentType::ShortUsage
} else {
builtins::ContentType::DetailedHelp
};
let Some(mut stdout) = context.try_fd(brush_core::openfiles::OpenFiles::STDOUT_FD) else {
// If there's no stdout, nothing to do.
return Ok(());
};
// For now, we assume colorized output if stdout is a terminal.
let options = builtins::ContentOptions {
colorized: stdout.is_terminal(),
};
let content = registration.content(name, content_type, &options)?;
write!(stdout, "{content}")?;
stdout.flush()?;
Ok(())
}
}
@@ -0,0 +1,273 @@
use brush_core::{ExecutionExitCode, ExecutionResult, builtins, error, history};
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(winnow_args::Args)]
#[arg(
group("anrw", message = "cannot use more than one of -anrw"),
disable_help_short,
disable_version_flag,
disable_help_subcommand
)]
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", allow_negative_numbers)]
delete_offset: Option<i64>,
/// Appends the history from the current session to the history file. The
/// first argument names the file, if given.
#[arg(short = 'a', group = "anrw")]
append_session_to_file: bool,
/// Appends any remaining history from the history file to the current session. The
/// first argument names the file, if given.
#[arg(short = 'n', group = "anrw")]
append_rest_of_file_to_session: bool,
/// Appends the history from the history file to the current session. The first
/// argument names the file, if given.
#[arg(short = 'r', group = "anrw")]
append_file_to_session: bool,
/// Replaces the history file with the current session history. The first
/// argument names the file, if given.
#[arg(short = 'w', group = "anrw")]
write_session_to_file: bool,
/// History-expands positional arguments and displays them.
#[arg(short = 'p')]
expand: bool,
/// Appends positional arguments as an entry in the current session.
#[arg(short = 's')]
append: bool,
/// Arguments.
#[arg(positional, double_dash = "automatic", allow_negative_numbers)]
args: Vec<String>,
}
struct HistoryConfig {
default_history_file_path: Option<PathBuf>,
time_format: Option<String>,
}
brush_winnow_args_adapter::impl_parse_args!(HistoryCommand);
impl<SE: brush_core::ShellExtensions> builtins::Builtin<SE> for HistoryCommand {
type Error = brush_core::Error;
async fn execute(
self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<ExecutionResult, Self::Error> {
// Retrieve the shell's history config while we still can.
let config = HistoryConfig {
default_history_file_path: context.shell.history_file_path(),
time_format: context.shell.history_time_format(),
};
let stdout = context.stdout();
let stderr = context.stderr();
if let Some(history) = context.shell.history_mut() {
self.execute_with_history(history, &config, stdout, stderr)
} else {
Err(brush_core::ErrorKind::HistoryNotEnabled.into())
}
}
}
impl HistoryCommand {
#[expect(clippy::cast_possible_wrap)]
#[expect(clippy::cast_possible_truncation)]
#[expect(clippy::cast_sign_loss)]
fn execute_with_history(
&self,
history: &mut history::History,
config: &HistoryConfig,
stdout: impl Write,
mut stderr: impl Write,
) -> Result<ExecutionResult, brush_core::Error> {
if self.clear_history {
history.clear()?;
}
if let Some(offset) = self.delete_offset {
if offset == 0 {
writeln!(stderr, "cannot delete history item at offset 0")?;
return Ok(ExecutionExitCode::InvalidUsage.into());
}
if offset > 0 {
// Convert to 0-based index.
let index = (offset - 1) as usize;
if !history.remove_nth_item(index) {
writeln!(stderr, "index past end of history")?;
return Ok(ExecutionExitCode::InvalidUsage.into());
}
} else {
let count = history.count() as i64;
let index = count + offset;
if index < 0 {
writeln!(stderr, "index before beginning of history")?;
return Ok(ExecutionExitCode::InvalidUsage.into());
}
let _ = history.remove_nth_item(index as usize);
}
return Ok(ExecutionResult::success());
}
if self.append_session_to_file {
if let Some(file_path) = self.history_file_path(config) {
history.flush(
file_path,
true, /* append? */
true, /* unsaved items only */
config.time_format.is_some(), /* write timestamps? */
)?;
}
return Ok(ExecutionResult::success());
}
if self.append_rest_of_file_to_session {
return error::unimp("history -n is not yet implemented");
}
if self.append_file_to_session {
return error::unimp("history -r is not yet implemented");
}
if self.write_session_to_file {
if let Some(file_path) = self.history_file_path(config) {
history.flush(
file_path,
false, /* append? */
false, /* unsaved items only? */
config.time_format.is_some(), /* write timestamps? */
)?;
}
return Ok(ExecutionResult::success());
}
// With -s, -p only expands the entry; history expansion isn't implemented.
if self.append {
history.add(history::Item::new(self.args.join(" ")))?;
return Ok(ExecutionResult::success());
}
if self.expand {
return error::unimp("history -p is not yet implemented");
}
let max_entries: Option<usize> = if let Some(arg) = self.args.first() {
Some(brush_core::int_utils::parse(arg.as_str(), 10)?)
} else {
None
};
display_history(history, config, max_entries, stdout, stderr)?;
Ok(ExecutionResult::success())
}
/// The file named by the first argument, or else the shell's history file.
fn history_file_path<'a>(&'a self, config: &'a HistoryConfig) -> Option<&'a Path> {
self.args
.first()
.map(Path::new)
.or(config.default_history_file_path.as_deref())
}
}
fn display_history(
history: &history::History,
config: &HistoryConfig,
max_entries: Option<usize>,
mut stdout: impl Write,
_stderr: impl Write,
) -> Result<(), brush_core::Error> {
let item_count = history.count();
let skip_count = item_count - max_entries.unwrap_or(item_count);
for (i, item) in history.iter().skip(skip_count).enumerate() {
let mut formatted_timestamp = String::new();
if let Some(timestamp) = item.timestamp {
let local_timestamp = timestamp.with_timezone(&chrono::Local);
if let Some(time_format) = &config.time_format {
let fmt_items = chrono::format::StrftimeItems::new(time_format);
formatted_timestamp = local_timestamp.format_with_items(fmt_items).to_string();
}
}
// Output format is something like:
// 1 echo hello world
std::writeln!(
stdout,
"{:>5} {formatted_timestamp}{}",
skip_count + i + 1,
item.command_line
)?;
}
Ok(())
}
#[cfg(test)]
#[expect(clippy::panic_in_result_fn)]
mod tests {
use super::*;
use anyhow::Result;
use brush_core::builtins::{Invocation, ParseArgs};
use pretty_assertions::assert_eq;
fn parse(words: &[&str]) -> Result<HistoryCommand> {
Ok(<HistoryCommand as ParseArgs>::parse(Invocation::new(
"history",
words.iter().copied(),
))?)
}
#[test]
fn test_parse_dash_a() -> Result<()> {
let config = HistoryConfig {
default_history_file_path: Some(PathBuf::from("default")),
time_format: None,
};
let cmd = parse(&["5"])?;
assert!(!cmd.append_session_to_file);
let cmd = parse(&["-a"])?;
assert!(cmd.append_session_to_file);
assert_eq!(cmd.history_file_path(&config), Some(Path::new("default")));
for words in [
&["-a", "token"][..],
&["-a", "--", "token"],
&["-ac", "token"],
] {
let cmd = parse(words)?;
assert!(cmd.append_session_to_file);
assert_eq!(cmd.history_file_path(&config), Some(Path::new("token")));
}
assert!(parse(&["-atoken"]).is_err());
Ok(())
}
}
@@ -0,0 +1,86 @@
use std::io::Write;
use brush_core::{ExecutionResult, builtins, error, jobs};
/// Manage jobs.
#[derive(winnow_args::Args)]
#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)]
pub(crate) struct JobsCommand {
/// Also show process IDs.
#[arg(short = 'l')]
also_show_pids: bool,
/// List only jobs that have changed status since the last notification.
#[arg(short = 'n')]
list_changed_only: bool,
/// Show only process IDs.
#[arg(short = 'p')]
show_pids_only: bool,
/// Show only running jobs.
#[arg(short = 'r')]
running_jobs_only: bool,
/// Show only stopped jobs.
#[arg(short = 's')]
stopped_jobs_only: bool,
/// Job specs to list.
// TODO(jobs): Add -x option
#[arg(positional)]
job_specs: Vec<String>,
}
brush_winnow_args_adapter::impl_parse_args!(JobsCommand);
impl<SE: brush_core::ShellExtensions> builtins::Builtin<SE> for JobsCommand {
type Error = brush_core::Error;
async fn execute(
self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
if self.also_show_pids {
return error::unimp("jobs -l");
}
if self.list_changed_only {
return error::unimp("jobs -n");
}
if self.job_specs.is_empty() {
for job in &context.shell.jobs().jobs {
self.display_job(&context, job)?;
}
} else {
return error::unimp("jobs with job specs");
}
Ok(ExecutionResult::success())
}
}
impl JobsCommand {
fn display_job(
&self,
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
job: &jobs::Job,
) -> Result<(), brush_core::Error> {
if self.running_jobs_only && !matches!(job.state, jobs::JobState::Running) {
return Ok(());
}
if self.stopped_jobs_only && !matches!(job.state, jobs::JobState::Stopped) {
return Ok(());
}
if self.show_pids_only {
if let Some(pid) = job.representative_pid() {
writeln!(context.stdout(), "{pid}")?;
}
} else {
writeln!(context.stdout(), "{job}")?;
}
Ok(())
}
}
@@ -0,0 +1,200 @@
use std::io::Write;
use brush_core::traps::TrapSignal;
use brush_core::{ExecutionExitCode, ExecutionResult, builtins, sys};
/// Signal a job or process.
#[derive(winnow_args::Args)]
#[arg(
disable_help_short,
disable_version_flag,
disable_help_subcommand,
unknown_flags = "value"
)]
pub(crate) struct KillCommand {
/// Name of the signal to send.
#[arg(short = 's', value_name = "SIG_NAME")]
signal_name: Option<String>,
/// Number of the signal to send.
#[arg(short = 'n', value_name = "SIG_NUM")]
signal_number: Option<usize>,
/// List known signal names.
#[arg(short = 'l', short = 'L')]
list_signals: bool,
#[arg(positional, allow_negative_numbers, stop_flags)]
args: Vec<String>,
}
brush_winnow_args_adapter::impl_parse_args!(KillCommand);
impl<SE: brush_core::ShellExtensions> builtins::Builtin<SE> for KillCommand {
type Error = brush_core::Error;
async fn execute(
self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
let mut signal_zero = false;
// Default signal is SIGKILL.
let mut trap_signal = TrapSignal::Signal(nix::sys::signal::Signal::SIGKILL);
// Try parsing the signal name (if specified).
if let Some(signal_name) = &self.signal_name {
if let Ok(parsed_trap_signal) = TrapSignal::try_from(signal_name.as_str()) {
trap_signal = parsed_trap_signal;
} else {
writeln!(
context.stderr(),
"{}: invalid signal name: {}",
context.command_name,
signal_name
)?;
return Ok(ExecutionExitCode::InvalidUsage.into());
}
}
// Try parsing the signal number (if specified).
if let Some(signal_number) = &self.signal_number {
if *signal_number == 0 {
signal_zero = true;
} else {
#[expect(clippy::cast_possible_truncation)]
#[expect(clippy::cast_possible_wrap)]
if let Ok(parsed_trap_signal) = TrapSignal::try_from(*signal_number as i32) {
trap_signal = parsed_trap_signal;
} else {
writeln!(
context.stderr(),
"{}: invalid signal number: {}",
context.command_name,
signal_number
)?;
return Ok(ExecutionExitCode::InvalidUsage.into());
}
}
}
// Look through the remaining args for a pid/job spec or a -sigspec style option.
let mut pid_or_job_spec = None;
for arg in &self.args {
// See if this is -sigspec syntax. The sigspec may be a signal name
// (e.g., -TERM) or a signal number (e.g., -9, including -0).
if let Some(possible_sigspec) = arg.strip_prefix("-") {
if Ok(0) == possible_sigspec.parse::<i32>() {
signal_zero = true;
} else if let Ok(parsed_trap_signal) = possible_sigspec.parse::<TrapSignal>() {
signal_zero = false;
trap_signal = parsed_trap_signal;
} else {
writeln!(
context.stderr(),
"{}: {}: invalid signal specification",
context.command_name,
possible_sigspec
)?;
return Ok(ExecutionResult::general_error());
}
} else if pid_or_job_spec.is_none() {
pid_or_job_spec = Some(arg);
} else {
writeln!(
context.stderr(),
"{}: too many jobs or processes specified",
context.command_name
)?;
return Ok(ExecutionExitCode::InvalidUsage.into());
}
}
if self.list_signals {
return print_signals(&context, self.args.as_ref());
} else {
let Some(pid_or_job_spec) = pid_or_job_spec else {
writeln!(context.stderr(), "{}: invalid usage", context.command_name)?;
return Ok(ExecutionExitCode::InvalidUsage.into());
};
if pid_or_job_spec.starts_with('%') {
// It's a job spec.
if let Some(job) = context.shell.jobs_mut().resolve_job_spec(pid_or_job_spec) {
if signal_zero {
job.check_signalable()?;
} else {
job.kill(trap_signal)?;
}
} else {
writeln!(
context.stderr(),
"{}: {}: no such job",
context.command_name,
pid_or_job_spec
)?;
return Ok(ExecutionResult::general_error());
}
} else {
let pid = brush_core::int_utils::parse(pid_or_job_spec.as_str(), 10)?;
// It's a pid.
if signal_zero {
sys::signal::check_signalable(pid)?;
} else {
sys::signal::kill_process(pid, trap_signal)?;
}
}
}
Ok(ExecutionResult::success())
}
}
fn print_signals(
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
signals: &[String],
) -> Result<ExecutionResult, brush_core::Error> {
let mut exit_code = ExecutionResult::success();
if !signals.is_empty() {
for s in signals {
// If the user gives us a code, we print the name; if they give a name, we print its
// code.
enum PrintSignal {
Name(&'static str),
Num(i32),
}
let signal = if let Ok(n) = s.parse::<i32>() {
// bash compatibility. `SIGHUP` -> `HUP`
TrapSignal::try_from(n).map(|s| {
PrintSignal::Name(s.as_str().strip_prefix("SIG").unwrap_or(s.as_str()))
})
} else {
TrapSignal::try_from(s.as_str()).map(|sig| {
i32::try_from(sig).map_or(PrintSignal::Name(sig.as_str()), PrintSignal::Num)
})
};
match signal {
Ok(PrintSignal::Num(n)) => {
writeln!(context.stdout(), "{n}")?;
}
Ok(PrintSignal::Name(s)) => {
writeln!(context.stdout(), "{s}")?;
}
Err(e) => {
writeln!(context.stderr(), "{e}")?;
exit_code = ExecutionResult::general_error();
}
}
}
} else {
return brush_core::traps::format_signals(
context.stdout(),
TrapSignal::iterator().filter(|s| !matches!(s, TrapSignal::Exit)),
)
.map(|()| ExecutionResult::success());
}
Ok(exit_code)
}
@@ -0,0 +1,52 @@
use std::io::Write;
use brush_core::{ExecutionResult, arithmetic::Evaluatable, builtins};
/// Evaluate arithmetic expressions.
#[derive(winnow_args::Args)]
#[arg(
disable_help_short,
disable_version_flag,
disable_help_subcommand,
unknown_flags = "value"
)]
pub(crate) struct LetCommand {
/// Arithmetic expressions to evaluate.
#[arg(positional, double_dash = "automatic", allow_negative_numbers)]
exprs: Vec<String>,
}
brush_winnow_args_adapter::impl_parse_args!(LetCommand);
impl<SE: brush_core::ShellExtensions> builtins::Builtin<SE> for LetCommand {
type Error = brush_core::Error;
async fn execute(
self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
let mut result = ExecutionResult::success();
if self.exprs.is_empty() {
writeln!(
context.stderr(),
"{}: expression expected",
context.command_name
)?;
return Ok(ExecutionResult::general_error());
}
for expr in &self.exprs {
let parsed = brush_parser::arithmetic::parse(expr.as_str())?;
let evaluated = parsed.eval(context.shell)?;
if evaluated == 0 {
result = ExecutionResult::general_error();
} else {
result = ExecutionResult::success();
}
}
Ok(result)
}
}
@@ -0,0 +1,140 @@
//! Standard builtins.
// `brush_core::builtins::Builtin::execute` is async by contract: the trait declares a desugared
// `-> impl Future<...> + Send` so that a `brush_core::builtins::Registration` can box and dispatch
// every builtin uniformly. Most builtins in this crate do purely synchronous work, so their
// `execute` bodies contain no `.await` -- that is the trait contract being honored, not a defect.
#![allow(
clippy::unused_async_trait_impl,
reason = "builtins implement a trait whose `execute` is async by contract"
)]
#[cfg(feature = "builtin.alias")]
mod alias;
#[cfg(feature = "builtin.bg")]
mod bg;
#[cfg(feature = "builtin.bind")]
mod bind;
#[cfg(feature = "builtin.break")]
mod break_;
#[cfg(feature = "builtin.builtin")]
mod builtin_;
#[cfg(feature = "builtin.caller")]
mod caller;
#[cfg(feature = "builtin.cd")]
mod cd;
#[cfg(feature = "builtin.colon")]
mod colon;
#[cfg(feature = "builtin.command")]
mod command;
#[cfg(any(
feature = "builtin.complete",
feature = "builtin.compgen",
feature = "builtin.compopt"
))]
mod complete;
#[cfg(feature = "builtin.continue")]
mod continue_;
#[cfg(feature = "builtin.declare")]
mod declare;
#[cfg(feature = "builtin.dirs")]
mod dirs;
#[cfg(feature = "builtin.dot")]
mod dot;
#[cfg(feature = "builtin.echo")]
mod echo;
#[cfg(feature = "builtin.enable")]
mod enable;
#[cfg(feature = "builtin.eval")]
mod eval;
#[cfg(feature = "builtin.exec")]
mod exec;
#[cfg(feature = "builtin.exit")]
mod exit;
#[cfg(feature = "builtin.export")]
mod export;
#[cfg(feature = "builtin.false")]
mod false_;
#[cfg(feature = "builtin.fc")]
mod fc;
#[cfg(feature = "builtin.fg")]
mod fg;
#[cfg(feature = "builtin.getopts")]
mod getopts;
#[cfg(feature = "builtin.hash")]
mod hash;
#[cfg(feature = "builtin.help")]
mod help;
#[cfg(feature = "builtin.history")]
mod history;
#[cfg(feature = "builtin.jobs")]
mod jobs;
#[cfg(all(feature = "builtin.kill", unix))]
mod kill;
#[cfg(feature = "builtin.let")]
mod let_;
#[cfg(feature = "builtin.mapfile")]
mod mapfile;
#[cfg(feature = "builtin.popd")]
mod popd;
#[cfg(all(feature = "builtin.printf", any(unix, windows, target_os = "wasi")))]
mod printf;
#[cfg(feature = "builtin.pushd")]
mod pushd;
#[cfg(feature = "builtin.pwd")]
mod pwd;
#[cfg(feature = "builtin.read")]
mod read;
#[cfg(feature = "builtin.return")]
mod return_;
#[cfg(feature = "builtin.set")]
mod set;
#[cfg(feature = "builtin.shift")]
mod shift;
#[cfg(feature = "builtin.shopt")]
mod shopt;
#[cfg(all(feature = "builtin.suspend", unix))]
mod suspend;
#[cfg(feature = "builtin.test")]
mod test;
#[cfg(feature = "builtin.times")]
mod times;
#[cfg(feature = "builtin.trap")]
mod trap;
#[cfg(feature = "builtin.true")]
mod true_;
#[cfg(feature = "builtin.type")]
mod type_;
#[cfg(all(feature = "builtin.ulimit", unix))]
mod ulimit;
#[cfg(all(feature = "builtin.umask", unix))]
mod umask;
#[cfg(feature = "builtin.unalias")]
mod unalias;
#[cfg(feature = "builtin.unset")]
mod unset;
#[cfg(feature = "builtin.wait")]
mod wait;
mod builder;
mod factory;
#[cfg(any(feature = "builtin.command", feature = "builtin.type"))]
mod lookup;
mod unimp;
pub use builder::ShellBuilderExt;
pub use factory::{BuiltinSet, default_builtins};
/// Writes an alias definition in the reusable form printed by `alias` and `command -v`.
#[cfg(any(feature = "builtin.alias", feature = "builtin.command"))]
fn write_alias_definition(
mut writer: impl std::io::Write,
name: &str,
value: &str,
) -> std::io::Result<()> {
writeln!(
writer,
"alias {name}={}",
brush_core::escape::single_quote(value)
)
}
@@ -0,0 +1,156 @@
//! Shared command-name resolution, used by the `type` and `command` builtins.
use std::io::Write;
use std::path::{Path, PathBuf};
use brush_core::{
Shell, ShellExtensions,
parser::ast,
pathsearch,
sys::{self, fs::PathExt},
};
/// A way in which a name resolved, in the shell's lookup order.
pub(crate) enum Resolved<'a> {
/// An alias, with its target.
Alias(String),
/// A shell keyword.
Keyword,
/// A shell function, with its definition.
Function(&'a ast::FunctionDefinition),
/// A built-in command.
Builtin,
/// An executable file; `hashed` indicates it came from the program location cache.
File { path: PathBuf, hashed: bool },
}
/// Options for [`resolve`]; the defaults match a plain `type NAME` lookup.
#[derive(Default)]
pub(crate) struct Options {
/// Only search the filesystem, even if the name is an alias, keyword, function, or builtin.
pub force_path_search: bool,
/// Don't consider functions when resolving the name.
pub suppress_func_lookup: bool,
/// Report every location the name resolves to, not just the first.
pub all_locations: bool,
/// Directories to search for executables, in lieu of the shell's `PATH`. The shell's
/// hash-based path cache is still consulted first.
pub path_dirs: Option<Vec<PathBuf>>,
}
/// Resolves the given name, returning the ways it resolved (in lookup order). Unless
/// `all_locations` was requested, at most one way is returned.
pub(crate) fn resolve<'a, SE: ShellExtensions>(
shell: &'a Shell<SE>,
name: &str,
options: &Options,
) -> Vec<Resolved<'a>> {
let mut resolved = vec![];
// These are all hash lookups, so there's nothing to save by stopping at the first hit;
// any extras are trimmed off at the end.
if !options.force_path_search {
// Check for aliases. They're only reported when alias expansion is enabled, since
// that's the only case in which the name would actually resolve to the alias.
if shell.options().expand_aliases
&& let Some(target) = shell.aliases().get(name)
{
resolved.push(Resolved::Alias(target.clone()));
}
// Check for keywords.
if shell.is_keyword(name) {
resolved.push(Resolved::Keyword);
}
// Check for functions.
if !options.suppress_func_lookup
&& let Some(registration) = shell.funcs().get(name)
{
resolved.push(Resolved::Function(registration.definition()));
}
// Check for builtins.
if shell.builtins().get(name).is_some_and(|b| b.is_enabled()) {
resolved.push(Resolved::Builtin);
}
}
// Searching the filesystem *does* cost something, so only do it if the results so far
// don't already answer the question.
if options.all_locations || resolved.is_empty() {
resolve_in_filesystem(shell, name, options, &mut resolved);
}
if !options.all_locations {
resolved.truncate(1);
}
resolved
}
/// Appends the files the given name resolves to.
fn resolve_in_filesystem<SE: ShellExtensions>(
shell: &Shell<SE>,
name: &str,
options: &Options,
resolved: &mut Vec<Resolved<'_>>,
) {
let to_file = |path| Resolved::File {
path,
hashed: false,
};
// A name with a separator in it is used as-is; it's never searched for.
if sys::fs::contains_path_separator(name) {
// A directory is never a command, even though it carries the execute bit.
let candidate = shell.absolute_path(Path::new(name));
if !candidate.is_dir() && candidate.executable() {
resolved.push(to_file(PathBuf::from(name)));
}
return;
}
// Reporting every location is a strict search for executables; reporting just the one the
// name resolves to matches what the shell would actually try to run, which can be a
// non-executable file if the search turns up nothing better.
if let Some(path) = shell.program_location_cache().get(name) {
resolved.push(Resolved::File { path, hashed: true });
if !options.all_locations {
return;
}
}
match (&options.path_dirs, options.all_locations) {
(Some(dirs), true) => {
resolved.extend(pathsearch::search_for_executable(dirs.iter(), name).map(to_file));
}
(Some(dirs), false) => {
resolved.extend(pathsearch::resolve_command(dirs.iter(), name).map(to_file));
}
(None, true) => resolved.extend(shell.find_executables_in_path(name).map(to_file)),
(None, false) => resolved.extend(shell.resolve_command_in_path(name).map(to_file)),
}
}
/// Writes the description shown by `type NAME` and `command -V NAME`, newline included.
pub(crate) fn describe(
mut writer: impl Write,
name: &str,
resolved: &Resolved<'_>,
) -> std::io::Result<()> {
match resolved {
Resolved::Alias(target) => writeln!(writer, "{name} is aliased to `{target}'"),
Resolved::Keyword => writeln!(writer, "{name} is a shell keyword"),
Resolved::Function(def) => writeln!(writer, "{name} is a function\n{def}"),
Resolved::Builtin => writeln!(writer, "{name} is a shell builtin"),
Resolved::File { path, hashed } => {
let path = path.to_string_lossy();
if *hashed {
writeln!(writer, "{name} is hashed ({path})")
} else {
writeln!(writer, "{name} is {path}")
}
}
}
}
@@ -0,0 +1,212 @@
use std::io::{Read, Write};
use brush_core::{ErrorKind, ExecutionExitCode, ExecutionResult, builtins, env, error, variables};
/// Read lines from standard input into an indexed array variable.
#[derive(winnow_args::Args)]
#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)]
pub(crate) struct MapFileCommand {
/// Delimiter to use (defaults to newline).
#[arg(short = 'd')]
delimiter: Option<String>,
/// Maximum number of entries to read (0 means no limit).
#[arg(short = 'n', default = "0", parse_with = |s: &str| at_least(s, 0))]
max_count: i64,
/// Index into array at which to start assignment.
#[arg(short = 'O', allow_negative_numbers)]
origin: Vec<i64>,
/// Number of initial entries to skip.
#[arg(short = 's', default = "0", parse_with = |s: &str| at_least(s, 0))]
skip_count: i64,
/// Whether or not to remove the delimiter from each read line.
#[arg(short = 't')]
remove_delimiter: bool,
/// File descriptor to read from (defaults to stdin).
#[arg(short = 'u')]
fd: Vec<brush_core::ShellFd>,
/// Name of function to call for each group of lines.
#[arg(short = 'C')]
callback: Option<String>,
/// Number of lines to pass the callback for each group.
#[arg(short = 'c', default = "5000", parse_with = |s: &str| at_least(s, 1))]
callback_group_size: i64,
/// Name of array to read into.
#[arg(positional, default = "MAPFILE")]
array_var_name: String,
}
/// A count of at least `min`. Each occurrence is checked as it is parsed, so a
/// later valid one doesn't hide an earlier invalid one.
fn at_least(text: &str, min: i64) -> Result<i64, String> {
let count: i64 = text.parse().map_err(|e| format!("{e}"))?;
if count < min {
return Err("out of range".to_owned());
}
Ok(count)
}
brush_winnow_args_adapter::impl_parse_args!(MapFileCommand);
impl<SE: brush_core::ShellExtensions> builtins::Builtin<SE> for MapFileCommand {
type Error = brush_core::Error;
async fn execute(
self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
if self.callback_group_size != 5000 || self.callback.is_some() {
return error::unimp("mapfile -C/-c is not yet implemented");
}
// Every -O and -u given is checked before reading, and the last one is used.
if let Some(origin) = self.origin.iter().find(|origin| **origin < 0) {
writeln!(
context.stderr(),
"{}: {origin}: invalid array origin",
context.command_name
)?;
return Ok(ExecutionExitCode::GeneralError.into());
}
let origin = self.origin.last().copied();
if let Some((_, var)) = context.shell.env().get(&self.array_var_name) {
if var.value().is_associative_array() {
writeln!(
context.stderr(),
"{}: {}: not an indexed array",
context.command_name,
self.array_var_name
)?;
return Ok(ExecutionExitCode::GeneralError.into());
}
}
let mut fd = brush_core::openfiles::OpenFiles::STDIN_FD;
for &given in &self.fd {
fd = given;
context
.try_fd(fd)
.ok_or_else(|| ErrorKind::BadFileDescriptor(fd))?;
}
let input_file = context
.try_fd(fd)
.ok_or_else(|| ErrorKind::BadFileDescriptor(fd))?;
// Read!
let lines = self.read_lines(input_file)?;
if let Some(origin) = origin {
// -O: preserve existing array, assign at offset.
for (elem_idx, value) in lines.into_iter().enumerate() {
// If the user is getting to wraparounds in *bash*, they got bigger problems.
#[allow(clippy::cast_possible_wrap)]
let elem_idx = elem_idx as i64;
context.shell.env_mut().update_or_add_array_element(
&self.array_var_name,
(elem_idx + origin).to_string(),
value,
|_| Ok(()),
env::EnvironmentLookup::Anywhere,
env::EnvironmentScope::Global,
)?;
}
} else {
// No -O: replace the entire variable (clears existing).
context.shell.env_mut().update_or_add(
&self.array_var_name,
variables::ShellValueLiteral::from(lines),
|_| Ok(()),
env::EnvironmentLookup::Anywhere,
env::EnvironmentScope::Global,
)?;
}
Ok(ExecutionResult::success())
}
}
impl MapFileCommand {
fn read_lines(
&self,
mut input_file: brush_core::openfiles::OpenFile,
) -> Result<Vec<String>, brush_core::Error> {
let _term_mode = setup_terminal_settings(&input_file)?;
let mut entries = vec![];
let mut read_count = 0;
let max_count = self.max_count.try_into()?;
let delimiter = match &self.delimiter {
Some(d) if d.is_empty() => b'\0',
Some(d) => d.as_bytes().first().copied().unwrap_or(b'\n'),
None => b'\n',
};
let mut buf = [0u8; 1];
while max_count == 0 || entries.len() < max_count {
let mut line = vec![];
let mut saw_delimiter = false;
loop {
match input_file.read(&mut buf) {
Ok(0) => break, // End of input
Ok(1) if buf[0] == b'\x03' => break, // Ctrl+C
Ok(1) if buf[0] == b'\x04' && line.is_empty() => break, // Ctrl+D
Ok(1) => {
let byte = buf[0];
line.push(byte);
if byte == delimiter {
saw_delimiter = true;
break;
}
}
Ok(_) => unreachable!("input can only be 0, 1, or error"),
Err(e) => return Err(e.into()),
}
}
if line.is_empty() && !saw_delimiter {
break;
}
if read_count < self.skip_count {
read_count += 1;
continue;
}
if self.remove_delimiter && line.ends_with(&[delimiter]) {
line.pop();
}
let line_str = String::from_utf8_lossy(&line).to_string();
entries.push(line_str);
}
Ok(entries)
}
}
fn setup_terminal_settings(
file: &brush_core::openfiles::OpenFile,
) -> Result<Option<brush_core::terminal::AutoModeGuard>, brush_core::Error> {
let mode = brush_core::terminal::AutoModeGuard::new(file.to_owned()).ok();
if let Some(mode) = &mode {
let config = brush_core::terminal::Settings::builder()
.line_input(false)
.interrupt_signals(false)
.build();
mode.apply_settings(&config)?;
}
Ok(mode)
}
@@ -0,0 +1,37 @@
use brush_core::{ExecutionResult, builtins};
/// Pop a path from the current directory stack.
#[derive(winnow_args::Args)]
#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)]
pub(crate) struct PopdCommand {
/// Pop the path without changing the current working directory.
#[arg(short = 'n')]
no_directory_change: bool,
// TODO(popd): implement +N and -N
}
brush_winnow_args_adapter::impl_parse_args!(PopdCommand);
impl<SE: brush_core::ShellExtensions> builtins::Builtin<SE> for PopdCommand {
type Error = crate::dirs::DirError;
async fn execute(
self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
if let Some(popped) = context.shell.directory_stack_mut().pop() {
if !self.no_directory_change {
context.shell.set_working_dir(&popped)?;
}
// Display dirs.
let dirs_cmd = crate::dirs::DirsCommand::default();
// The change to the stack decides the status, not the listing.
let _ = dirs_cmd.execute(context).await?;
Ok(ExecutionResult::success())
} else {
Err(crate::dirs::DirError::DirStackEmpty)
}
}
}
@@ -0,0 +1,252 @@
use std::{ffi::OsString, io::Write, ops::ControlFlow};
use uucore::format;
use brush_core::{Error, ErrorKind, ExecutionResult, builtins, escape, expansion};
/// Format a string.
#[derive(winnow_args::Args)]
#[arg(disable_help_flag, disable_version_flag, disable_help_subcommand)]
pub(crate) struct PrintfCommand {
/// If specified, the output of the command is assigned to this variable.
#[arg(short = 'v', allow_hyphen_values)]
output_variables: Vec<String>,
/// Format string + arguments to the format string.
#[arg(positional, stop_flags, required)]
format_and_args: Vec<String>,
}
brush_winnow_args_adapter::impl_parse_args!(PrintfCommand);
impl<SE: brush_core::ShellExtensions> builtins::Builtin<SE> for PrintfCommand {
type Error = brush_core::Error;
async fn execute(
self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<ExecutionResult, Self::Error> {
// Every -v name must be valid, not just the last one, which is used.
let parser_options = context.shell.parser_options();
if let Some(invalid) = self
.output_variables
.iter()
.find(|name| !valid_output_variable(name, &parser_options))
{
writeln!(
context.stderr(),
"{}: `{invalid}': not a valid identifier",
context.command_name
)?;
return Ok(ExecutionResult::new(2));
}
if let Some(variable_name) = self.output_variables.last() {
// Format to a u8 vector.
let mut result: Vec<u8> = vec![];
format(self.format_and_args.as_slice(), &mut result)?;
// Convert to a string.
let result_str = String::from_utf8(result).map_err(|_| {
brush_core::ErrorKind::PrintfInvalidUsage("invalid UTF-8 output".into())
})?;
// Assign to the selected variable.
expansion::assign_to_named_parameter(
context.shell,
&context.params,
variable_name,
result_str,
)
.await?;
} else {
format(self.format_and_args.as_slice(), context.stdout())?;
context.stdout().flush()?;
}
Ok(ExecutionResult::success())
}
}
/// Whether `name` is a variable name, or one followed by a non-empty `[subscript]`,
/// by the grammar of an assignment's name. The subscript isn't evaluated here.
fn valid_output_variable(name: &str, options: &brush_parser::ParserOptions) -> bool {
use brush_parser::ast::{AssignmentName, AssignmentValue};
match brush_parser::word::parse_scalar_assignment(&format!("{name}="), options) {
Ok(assignment) if !assignment.append => {
matches!(&assignment.value, AssignmentValue::Scalar(word) if word.value.is_empty())
&& match assignment.name {
AssignmentName::VariableName(_) => true,
AssignmentName::ArrayElementName(_, index) => !index.is_empty(),
}
}
_ => false,
}
}
fn format(format_and_args: &[String], writer: impl Write) -> Result<(), brush_core::Error> {
match format_and_args {
// Handle format string with arguments using uucore
[fmt, args @ ..] => format_via_uucore(fmt, args.iter(), writer),
// Handle case with no format string (we shouldn't be able to get here since parsing
// fails when the format string is missing)
[] => Err(ErrorKind::PrintfInvalidUsage("missing operand".into()).into()),
}
}
fn format_via_uucore(
format_string: &str,
args: impl Iterator<Item = impl Into<OsString>>,
mut writer: impl Write,
) -> Result<(), brush_core::Error> {
// Convert string arguments to FormatArgument::Unparsed
let format_args: Vec<_> = args
.map(|s| format::FormatArgument::Unparsed(s.into()))
.collect();
// Parse format string once.
let format_items = parse_format_string(format_string)?;
// Wrap the format arguments.
let mut format_args_wrapper = format::FormatArguments::new(&format_args);
// Determine whether the format string contains any specifiers that consume arguments. If it
// doesn't, then we must only run through it once -- even when extra arguments are provided --
// since otherwise we'd loop forever waiting for arguments that will never be consumed. This
// matches the behavior of other shells, which print such a format string exactly once.
let format_consumes_args = format_items
.iter()
.any(|(item, _)| matches!(item, format::FormatItem::Spec(_)));
// Keep going until we've exhausted all format arguments. Also make sure to run at least once
// even if there's no format arguments.
while format_args.is_empty() || !format_args_wrapper.is_exhausted() {
// Process all format items, in order. We'll bail when we're told to stop.
for (item, backslash_quote) in &format_items {
if let (format::FormatItem::Spec(format::Spec::QuotedString { position }), true) =
(item, *backslash_quote)
{
let arg = format_args_wrapper.next_string(*position).to_string_lossy();
let quoted = quote_printf_q(&arg);
write!(writer, "{quoted}")?;
continue;
}
let control_flow = item
.write(&mut writer, &mut format_args_wrapper)
.map_err(|e| match e {
// Propagate I/O errors directly so they can be handled appropriately
format::FormatError::IoError(io_err) => Error::from(io_err),
// Wrap other format errors
other => Error::from(ErrorKind::PrintfInvalidUsage(std::format!(
"printf formatting error: {other}"
))),
})?;
if control_flow == ControlFlow::Break(()) {
break;
}
}
// If the format string doesn't consume any arguments, stop now; otherwise we'd reprocess
// it forever since no arguments will ever be consumed.
if !format_consumes_args {
break;
}
// Start next batch if not exhausted
if !format_args_wrapper.is_exhausted() {
format_args_wrapper.start_next_batch();
}
if format_args.is_empty() {
break;
}
}
Ok(())
}
fn quote_printf_q(s: &str) -> String {
// Like bash's, this escapes commas too, so the result is safe even inside a brace
// expansion.
let options = escape::QuoteOptions::builder()
.preferred_mode(escape::QuoteMode::BackslashEscape)
.quote_commas(true)
.build();
escape::quote(s, &options).into_owned()
}
type ParsedFormatItem = (format::FormatItem<format::EscapedChar>, bool);
fn parse_format_string(format_string: &str) -> Result<Vec<ParsedFormatItem>, brush_core::Error> {
let format_items: Result<Vec<_>, _> = format::parse_spec_and_escape(format_string.as_bytes())
.map(|result| match result {
Ok(item @ format::FormatItem::Spec(format::Spec::QuotedString { .. })) => {
Ok((item, true))
}
Ok(item) => Ok((item, false)),
// Fixed q/Q modifiers are deliberately ignored; dynamic modifiers remain unsupported
// until uucore exposes quoted-string metadata.
Err(format::FormatError::SpecError(spec, span))
if matches!(spec.last(), Some(b'q' | b'Q'))
&& !spec.contains(&b'*')
&& !spec.contains(&b'$') =>
{
let mut bare_q: &[u8] = b"q";
let item = format::Spec::parse(&mut bare_q)
.map(format::FormatItem::Spec)
.map_err(|spec| format::FormatError::SpecError(spec.to_vec(), span))?;
Ok((item, !spec.contains(&b'#')))
}
Err(error) => Err(error),
})
.collect();
// Observe any errors we encountered along the way.
let format_items = format_items
.map_err(|e| ErrorKind::PrintfInvalidUsage(format!("printf parsing error: {e}")))?;
Ok(format_items)
}
#[cfg(test)]
#[expect(clippy::panic_in_result_fn)]
mod tests {
use super::*;
use anyhow::Result;
fn sprintf_via_uucore(
format_string: &str,
args: impl Iterator<Item = impl Into<OsString>>,
) -> Result<String> {
let mut result = vec![];
format_via_uucore(format_string, args, &mut result)?;
Ok(String::from_utf8(result)?)
}
#[test]
fn test_basic_sprintf() -> Result<()> {
assert_eq!(sprintf_via_uucore("%s", std::iter::once(&"xyz"))?, "xyz");
assert_eq!(sprintf_via_uucore(r"%d\n", std::iter::once(&"1"))?, "1\n");
Ok(())
}
#[test]
fn test_sprintf_without_args() -> Result<()> {
let empty: [&str; 0] = [];
assert_eq!(sprintf_via_uucore("xyz", empty.iter())?, "xyz");
assert_eq!(sprintf_via_uucore("%s|", empty.iter())?, "|");
Ok(())
}
#[test]
fn test_sprintf_with_cycles() -> Result<()> {
assert_eq!(sprintf_via_uucore("%s|", ["x", "y"].iter())?, "x|y|");
Ok(())
}
}
@@ -0,0 +1,68 @@
use brush_core::{ExecutionResult, builtins};
/// Push a path onto the current directory stack.
#[derive(winnow_args::Args)]
#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)]
pub(crate) struct PushdCommand {
/// Push the path without changing the current working directory.
#[arg(short = 'n')]
no_directory_change: bool,
/// Directory to push on the directory stack. Without one, the top two
/// directories on the stack are exchanged.
#[arg(positional)]
dir: Option<String>,
// TODO(pushd): implement +N and -N
}
brush_winnow_args_adapter::impl_parse_args!(PushdCommand);
impl<SE: brush_core::ShellExtensions> builtins::Builtin<SE> for PushdCommand {
type Error = crate::dirs::DirError;
async fn execute(
self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
if let Some(dir) = &self.dir {
if self.no_directory_change {
context
.shell
.directory_stack_mut()
.push(std::path::PathBuf::from(dir));
} else {
let prev_working_dir = context.shell.working_dir().to_path_buf();
let dir = std::path::Path::new(dir);
context.shell.set_working_dir(dir)?;
context.shell.directory_stack_mut().push(prev_working_dir);
}
} else {
// With no directory, exchange the current directory with the one on top of
// the stack.
let Some(top) = context.shell.directory_stack().last().cloned() else {
return Err(crate::dirs::DirError::NoOtherDirectory);
};
// As in bash, -n leaves both the stack and the current directory alone here.
if self.no_directory_change {
return Ok(ExecutionResult::success());
}
let prev_working_dir = context.shell.working_dir().to_path_buf();
context.shell.set_working_dir(&top)?;
let stack = context.shell.directory_stack_mut();
stack.pop();
stack.push(prev_working_dir);
}
// Display dirs.
let dirs_cmd = crate::dirs::DirsCommand::default();
// The change to the stack decides the status, not the listing.
let _ = dirs_cmd.execute(context).await?;
Ok(ExecutionResult::success())
}
}
@@ -0,0 +1,47 @@
use brush_core::{ExecutionResult, builtins};
use std::{borrow::Cow, io::Write, path::Path};
/// Display the current working directory.
#[derive(winnow_args::Args)]
#[arg(disable_help_short, disable_version_flag, disable_help_subcommand)]
pub(crate) struct PwdCommand {
/// Print $PWD if it names the current working directory.
#[arg(short = 'L', overrides = "-P")]
allow_symlinks: bool,
/// Print the physical directory without any symlinks.
#[arg(short = 'P', overrides = "-L")]
physical: bool,
/// Operands, which are ignored. Options before the first operand are
/// still misuse; everything after it is an operand.
#[arg(positional, stop_flags, hide)]
_operands: Vec<String>,
}
brush_winnow_args_adapter::impl_parse_args!(PwdCommand);
impl<SE: brush_core::ShellExtensions> builtins::Builtin<SE> for PwdCommand {
type Error = brush_core::Error;
async fn execute(
self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
let mut cwd: Cow<'_, Path> = context.shell.working_dir().into();
let should_canonicalize = self.physical
|| context
.shell
.options()
.do_not_resolve_symlinks_when_changing_dir;
if should_canonicalize {
cwd = cwd.canonicalize()?.into();
}
writeln!(context.stdout(), "{}", cwd.to_string_lossy())?;
Ok(ExecutionResult::success())
}
}

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