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:
@@ -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
@@ -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/
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+158
-7
@@ -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"
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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.'
|
||||
}
|
||||
@@ -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"]
|
||||
Generated
-7040
File diff suppressed because it is too large
Load Diff
@@ -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 +0,0 @@
|
||||
/source/
|
||||
Generated
-6739
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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'} }
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,19 @@
|
||||
name: Other Issue
|
||||
description: "Report an issue that doesn't fit the other templates"
|
||||
labels: ["state: triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
If your issue is a **feature request**, please use the [Feature Request](https://github.com/reubeno/brush/issues/new?template=feature-request.yml) template instead. If it's about **brush behaving differently from bash** or another compatibility issue, please use the [Compatibility Bug Report](https://github.com/reubeno/brush/issues/new?template=compatibility-bug.yml) template instead.
|
||||
|
||||
For general questions or discussion, consider using [GitHub Discussions](https://github.com/reubeno/brush/discussions).
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: Please describe the issue in detail.
|
||||
placeholder: Describe the issue...
|
||||
validations:
|
||||
required: true
|
||||
@@ -0,0 +1,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
@@ -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
@@ -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
|
||||
@@ -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
@@ -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}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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/
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
}
|
||||
@@ -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
@@ -0,0 +1,131 @@
|
||||
|
||||
# Contributor Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, caste, color, religion, or sexual
|
||||
identity and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the overall
|
||||
community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or advances of
|
||||
any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email address,
|
||||
without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official email address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the project administrators. All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series of
|
||||
actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or permanent
|
||||
ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within the
|
||||
community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.1, available at
|
||||
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
||||
|
||||
Community Impact Guidelines were inspired by
|
||||
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
|
||||
[https://www.contributor-covenant.org/translations][translations].
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||
[Mozilla CoC]: https://github.com/mozilla/diversity
|
||||
[FAQ]: https://www.contributor-covenant.org/faq
|
||||
[translations]: https://www.contributor-covenant.org/translations
|
||||
@@ -0,0 +1,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)!
|
||||
@@ -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.
|
||||
@@ -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__/
|
||||
Executable
+974
@@ -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"] }
|
||||
@@ -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
Reference in New Issue
Block a user