From 49a24cf27b53167fdd31bec3f878a94a8bcab324 Mon Sep 17 00:00:00 2001
From: can1357
Date: Sun, 1 Feb 2026 19:04:16 +0100
Subject: [PATCH] build(deps): vendored brush-core crate locally for
development and patching
- Vendored brush-core crate locally to enable local development and patching.
- Added patch.crates-io section in Cargo.toml to redirect brush-core dependency to vendored version at crates/brush-core-vendored.
- Updated pi-natives Cargo.toml to use local path dependency for brush-core instead of external crate version.
---
Cargo.lock | 9 +-
Cargo.toml | 3 +
crates/brush-core-vendored/Cargo.lock | 1658 ++++++++++++++
crates/brush-core-vendored/Cargo.toml | 258 +++
crates/brush-core-vendored/LICENSE | 21 +
crates/brush-core-vendored/README.md | 187 ++
crates/brush-core-vendored/src/arithmetic.rs | 351 +++
.../brush-core-vendored/src/braceexpansion.rs | 80 +
crates/brush-core-vendored/src/builtins.rs | 522 +++++
crates/brush-core-vendored/src/commands.rs | 662 ++++++
crates/brush-core-vendored/src/completion.rs | 1254 +++++++++++
crates/brush-core-vendored/src/env.rs | 611 ++++++
crates/brush-core-vendored/src/error.rs | 355 +++
crates/brush-core-vendored/src/escape.rs | 449 ++++
crates/brush-core-vendored/src/expansion.rs | 1910 +++++++++++++++++
.../brush-core-vendored/src/extendedtests.rs | 609 ++++++
crates/brush-core-vendored/src/functions.rs | 177 ++
crates/brush-core-vendored/src/history.rs | 490 +++++
crates/brush-core-vendored/src/interfaces.rs | 5 +
.../src/interfaces/keybindings.rs | 337 +++
crates/brush-core-vendored/src/interp.rs | 1682 +++++++++++++++
crates/brush-core-vendored/src/jobs.rs | 444 ++++
crates/brush-core-vendored/src/keywords.rs | 37 +
crates/brush-core-vendored/src/lib.rs | 46 +
.../brush-core-vendored/src/namedoptions.rs | 829 +++++++
crates/brush-core-vendored/src/openfiles.rs | 329 +++
crates/brush-core-vendored/src/options.rs | 325 +++
crates/brush-core-vendored/src/pathcache.rs | 58 +
crates/brush-core-vendored/src/pathsearch.rs | 131 ++
crates/brush-core-vendored/src/patterns.rs | 875 ++++++++
crates/brush-core-vendored/src/processes.rs | 78 +
crates/brush-core-vendored/src/prompt.rs | 250 +++
crates/brush-core-vendored/src/regex.rs | 197 ++
crates/brush-core-vendored/src/results.rs | 260 +++
crates/brush-core-vendored/src/scripts.rs | 246 +++
crates/brush-core-vendored/src/shell.rs | 1693 +++++++++++++++
crates/brush-core-vendored/src/sys.rs | 40 +
crates/brush-core-vendored/src/sys/fs.rs | 31 +
.../brush-core-vendored/src/sys/hostname.rs | 3 +
crates/brush-core-vendored/src/sys/stubs.rs | 19 +
.../src/sys/stubs/commands.rs | 89 +
.../brush-core-vendored/src/sys/stubs/fd.rs | 13 +
.../brush-core-vendored/src/sys/stubs/fs.rs | 78 +
.../src/sys/stubs/input.rs | 11 +
.../src/sys/stubs/network.rs | 3 +
.../src/sys/stubs/pipes.rs | 55 +
.../src/sys/stubs/process.rs | 34 +
.../src/sys/stubs/resource.rs | 19 +
.../src/sys/stubs/signal.rs | 84 +
.../src/sys/stubs/terminal.rs | 72 +
.../src/sys/stubs/users.rs | 46 +
.../src/sys/tokio_process.rs | 9 +
crates/brush-core-vendored/src/sys/unix.rs | 25 +
.../src/sys/unix/commands.rs | 66 +
crates/brush-core-vendored/src/sys/unix/fd.rs | 75 +
crates/brush-core-vendored/src/sys/unix/fs.rs | 191 ++
.../brush-core-vendored/src/sys/unix/input.rs | 84 +
.../src/sys/unix/network.rs | 3 +
.../src/sys/unix/resource.rs | 31 +
.../src/sys/unix/signal.rs | 162 ++
.../src/sys/unix/terminal.rs | 110 +
.../brush-core-vendored/src/sys/unix/users.rs | 72 +
crates/brush-core-vendored/src/sys/wasm.rs | 15 +
crates/brush-core-vendored/src/sys/windows.rs | 20 +
.../src/sys/windows/network.rs | 3 +
.../src/sys/windows/users.rs | 60 +
crates/brush-core-vendored/src/terminal.rs | 92 +
crates/brush-core-vendored/src/tests.rs | 37 +
crates/brush-core-vendored/src/timing.rs | 137 ++
.../src/trace_categories.rs | 20 +
crates/brush-core-vendored/src/traps.rs | 188 ++
crates/brush-core-vendored/src/variables.rs | 1033 +++++++++
.../brush-core-vendored/src/wellknownvars.rs | 514 +++++
crates/pi-natives/Cargo.toml | 2 +-
.../coding-agent/test/bash-executor.test.ts | 101 +-
75 files changed, 21070 insertions(+), 5 deletions(-)
create mode 100644 crates/brush-core-vendored/Cargo.lock
create mode 100644 crates/brush-core-vendored/Cargo.toml
create mode 100644 crates/brush-core-vendored/LICENSE
create mode 100644 crates/brush-core-vendored/README.md
create mode 100644 crates/brush-core-vendored/src/arithmetic.rs
create mode 100644 crates/brush-core-vendored/src/braceexpansion.rs
create mode 100644 crates/brush-core-vendored/src/builtins.rs
create mode 100644 crates/brush-core-vendored/src/commands.rs
create mode 100644 crates/brush-core-vendored/src/completion.rs
create mode 100644 crates/brush-core-vendored/src/env.rs
create mode 100644 crates/brush-core-vendored/src/error.rs
create mode 100644 crates/brush-core-vendored/src/escape.rs
create mode 100644 crates/brush-core-vendored/src/expansion.rs
create mode 100644 crates/brush-core-vendored/src/extendedtests.rs
create mode 100644 crates/brush-core-vendored/src/functions.rs
create mode 100644 crates/brush-core-vendored/src/history.rs
create mode 100644 crates/brush-core-vendored/src/interfaces.rs
create mode 100644 crates/brush-core-vendored/src/interfaces/keybindings.rs
create mode 100644 crates/brush-core-vendored/src/interp.rs
create mode 100644 crates/brush-core-vendored/src/jobs.rs
create mode 100644 crates/brush-core-vendored/src/keywords.rs
create mode 100644 crates/brush-core-vendored/src/lib.rs
create mode 100644 crates/brush-core-vendored/src/namedoptions.rs
create mode 100644 crates/brush-core-vendored/src/openfiles.rs
create mode 100644 crates/brush-core-vendored/src/options.rs
create mode 100644 crates/brush-core-vendored/src/pathcache.rs
create mode 100644 crates/brush-core-vendored/src/pathsearch.rs
create mode 100644 crates/brush-core-vendored/src/patterns.rs
create mode 100644 crates/brush-core-vendored/src/processes.rs
create mode 100644 crates/brush-core-vendored/src/prompt.rs
create mode 100644 crates/brush-core-vendored/src/regex.rs
create mode 100644 crates/brush-core-vendored/src/results.rs
create mode 100644 crates/brush-core-vendored/src/scripts.rs
create mode 100644 crates/brush-core-vendored/src/shell.rs
create mode 100644 crates/brush-core-vendored/src/sys.rs
create mode 100644 crates/brush-core-vendored/src/sys/fs.rs
create mode 100644 crates/brush-core-vendored/src/sys/hostname.rs
create mode 100644 crates/brush-core-vendored/src/sys/stubs.rs
create mode 100644 crates/brush-core-vendored/src/sys/stubs/commands.rs
create mode 100644 crates/brush-core-vendored/src/sys/stubs/fd.rs
create mode 100644 crates/brush-core-vendored/src/sys/stubs/fs.rs
create mode 100644 crates/brush-core-vendored/src/sys/stubs/input.rs
create mode 100644 crates/brush-core-vendored/src/sys/stubs/network.rs
create mode 100644 crates/brush-core-vendored/src/sys/stubs/pipes.rs
create mode 100644 crates/brush-core-vendored/src/sys/stubs/process.rs
create mode 100644 crates/brush-core-vendored/src/sys/stubs/resource.rs
create mode 100644 crates/brush-core-vendored/src/sys/stubs/signal.rs
create mode 100644 crates/brush-core-vendored/src/sys/stubs/terminal.rs
create mode 100644 crates/brush-core-vendored/src/sys/stubs/users.rs
create mode 100644 crates/brush-core-vendored/src/sys/tokio_process.rs
create mode 100644 crates/brush-core-vendored/src/sys/unix.rs
create mode 100644 crates/brush-core-vendored/src/sys/unix/commands.rs
create mode 100644 crates/brush-core-vendored/src/sys/unix/fd.rs
create mode 100644 crates/brush-core-vendored/src/sys/unix/fs.rs
create mode 100644 crates/brush-core-vendored/src/sys/unix/input.rs
create mode 100644 crates/brush-core-vendored/src/sys/unix/network.rs
create mode 100644 crates/brush-core-vendored/src/sys/unix/resource.rs
create mode 100644 crates/brush-core-vendored/src/sys/unix/signal.rs
create mode 100644 crates/brush-core-vendored/src/sys/unix/terminal.rs
create mode 100644 crates/brush-core-vendored/src/sys/unix/users.rs
create mode 100644 crates/brush-core-vendored/src/sys/wasm.rs
create mode 100644 crates/brush-core-vendored/src/sys/windows.rs
create mode 100644 crates/brush-core-vendored/src/sys/windows/network.rs
create mode 100644 crates/brush-core-vendored/src/sys/windows/users.rs
create mode 100644 crates/brush-core-vendored/src/terminal.rs
create mode 100644 crates/brush-core-vendored/src/tests.rs
create mode 100644 crates/brush-core-vendored/src/timing.rs
create mode 100644 crates/brush-core-vendored/src/trace_categories.rs
create mode 100644 crates/brush-core-vendored/src/traps.rs
create mode 100644 crates/brush-core-vendored/src/variables.rs
create mode 100644 crates/brush-core-vendored/src/wellknownvars.rs
diff --git a/Cargo.lock b/Cargo.lock
index 12938983e..8a0c00fd3 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -95,6 +95,12 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "anyhow"
+version = "1.0.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
+
[[package]]
name = "arboard"
version = "3.6.1"
@@ -270,9 +276,8 @@ dependencies = [
[[package]]
name = "brush-core"
version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2a3ad2f2d4eb45ef7f11e74bc1a0816c8f3dce298072352c6b3b41a2c4bfdb74"
dependencies = [
+ "anyhow",
"async-recursion",
"async-trait",
"bon",
diff --git a/Cargo.toml b/Cargo.toml
index e17c8c940..c6dfd72b2 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -9,6 +9,9 @@ license = "MIT"
authors = ["Can Boluk"]
repository = "https://github.com/can1357/oh-my-pi"
+[patch.crates-io]
+brush-core = { path = "crates/brush-core-vendored" }
+
[profile.release]
opt-level = 3
lto = "fat"
diff --git a/crates/brush-core-vendored/Cargo.lock b/crates/brush-core-vendored/Cargo.lock
new file mode 100644
index 000000000..b6b5501cd
--- /dev/null
+++ b/crates/brush-core-vendored/Cargo.lock
@@ -0,0 +1,1658 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "ahash"
+version = "0.8.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "version_check",
+ "zerocopy",
+]
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "allocator-api2"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
+
+[[package]]
+name = "android_system_properties"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "anstream"
+version = "0.6.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
+
+[[package]]
+name = "anstyle-parse"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2"
+dependencies = [
+ "windows-sys 0.60.2",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys 0.60.2",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
+
+[[package]]
+name = "archery"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70e0a5f99dfebb87bb342d0f53bb92c81842e100bbb915223e38349580e5441d"
+dependencies = [
+ "triomphe",
+]
+
+[[package]]
+name = "arrayvec"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
+
+[[package]]
+name = "async-recursion"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "async-trait"
+version = "0.1.89"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "autocfg"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
+
+[[package]]
+name = "bit-set"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
+dependencies = [
+ "bit-vec",
+]
+
+[[package]]
+name = "bit-vec"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
+
+[[package]]
+name = "bitflags"
+version = "2.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
+
+[[package]]
+name = "bon"
+version = "3.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebeb9aaf9329dff6ceb65c689ca3db33dbf15f324909c60e4e5eef5701ce31b1"
+dependencies = [
+ "bon-macros",
+ "rustversion",
+]
+
+[[package]]
+name = "bon-macros"
+version = "3.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77e9d642a7e3a318e37c2c9427b5a6a48aa1ad55dcd986f3034ab2239045a645"
+dependencies = [
+ "darling 0.21.3",
+ "ident_case",
+ "prettyplease",
+ "proc-macro2",
+ "quote",
+ "rustversion",
+ "syn",
+]
+
+[[package]]
+name = "brush-core"
+version = "0.4.0"
+dependencies = [
+ "anyhow",
+ "async-recursion",
+ "async-trait",
+ "bon",
+ "brush-parser",
+ "cached",
+ "cfg-if",
+ "chrono",
+ "clap",
+ "command-fds",
+ "fancy-regex",
+ "futures",
+ "getrandom",
+ "homedir",
+ "hostname",
+ "indexmap",
+ "itertools",
+ "nix 0.30.1",
+ "normalize-path",
+ "rand 0.9.2",
+ "rpds",
+ "strum",
+ "strum_macros",
+ "terminfo",
+ "thiserror",
+ "tokio",
+ "tracing",
+ "uuid",
+ "uzers",
+ "whoami",
+]
+
+[[package]]
+name = "brush-parser"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7367124d4f38fdcd65f4b815bda7caeb3de377b9cd95ffa1b23627989c93718"
+dependencies = [
+ "bon",
+ "cached",
+ "indenter",
+ "peg",
+ "thiserror",
+ "tracing",
+ "utf8-chars",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
+
+[[package]]
+name = "bytes"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
+
+[[package]]
+name = "cached"
+version = "0.56.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "801927ee168e17809ab8901d9f01f700cd7d8d6a6527997fee44e4b0327a253c"
+dependencies = [
+ "ahash",
+ "cached_proc_macro",
+ "cached_proc_macro_types",
+ "hashbrown 0.15.5",
+ "once_cell",
+ "thiserror",
+ "web-time",
+]
+
+[[package]]
+name = "cached_proc_macro"
+version = "0.25.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9225bdcf4e4a9a4c08bf16607908eb2fbf746828d5e0b5e019726dbf6571f201"
+dependencies = [
+ "darling 0.20.11",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "cached_proc_macro_types"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ade8366b8bd5ba243f0a58f036cc0ca8a2f069cff1a2351ef1cac6b083e16fc0"
+
+[[package]]
+name = "cc"
+version = "1.2.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "35900b6c8d709fb1d854671ae27aeaa9eec2f8b01b364e1619a40da3e6fe2afe"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "cfg_aliases"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
+
+[[package]]
+name = "chrono"
+version = "0.4.42"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2"
+dependencies = [
+ "iana-time-zone",
+ "js-sys",
+ "num-traits",
+ "wasm-bindgen",
+ "windows-link 0.2.1",
+]
+
+[[package]]
+name = "clap"
+version = "4.5.51"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c26d721170e0295f191a69bd9a1f93efcdb0aff38684b61ab5750468972e5f5"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.5.51"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75835f0c7bf681bfd05abe44e965760fea999a5286c6eb2d59883634fd02011a"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+ "terminal_size",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.5.49"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "clap_lex"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
+
+[[package]]
+name = "command-fds"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f849b92c694fe237ecd8fafd1ba0df7ae0d45c1df6daeb7f68ed4220d51640bd"
+dependencies = [
+ "nix 0.30.1",
+ "thiserror",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
+[[package]]
+name = "darling"
+version = "0.20.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
+dependencies = [
+ "darling_core 0.20.11",
+ "darling_macro 0.20.11",
+]
+
+[[package]]
+name = "darling"
+version = "0.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0"
+dependencies = [
+ "darling_core 0.21.3",
+ "darling_macro 0.21.3",
+]
+
+[[package]]
+name = "darling_core"
+version = "0.20.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
+dependencies = [
+ "fnv",
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "strsim",
+ "syn",
+]
+
+[[package]]
+name = "darling_core"
+version = "0.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4"
+dependencies = [
+ "fnv",
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "strsim",
+ "syn",
+]
+
+[[package]]
+name = "darling_macro"
+version = "0.20.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
+dependencies = [
+ "darling_core 0.20.11",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "darling_macro"
+version = "0.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81"
+dependencies = [
+ "darling_core 0.21.3",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "either"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "fancy-regex"
+version = "0.16.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f"
+dependencies = [
+ "bit-set",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127"
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
+[[package]]
+name = "foldhash"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+
+[[package]]
+name = "futures"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-io",
+ "futures-sink",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
+
+[[package]]
+name = "futures-executor"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-io"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
+
+[[package]]
+name = "futures-macro"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
+
+[[package]]
+name = "futures-task"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
+
+[[package]]
+name = "futures-util"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "futures-macro",
+ "futures-sink",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "pin-utils",
+ "slab",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "r-efi",
+ "wasip2",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d"
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "homedir"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5bdbbd5bc8c5749697ccaa352fa45aff8730cf21c68029c0eef1ffed7c3d6ba2"
+dependencies = [
+ "cfg-if",
+ "nix 0.29.0",
+ "widestring",
+ "windows",
+]
+
+[[package]]
+name = "hostname"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a56f203cd1c76362b69e3863fd987520ac36cf70a8c92627449b2f64a8cf7d65"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "windows-link 0.1.3",
+]
+
+[[package]]
+name = "iana-time-zone"
+version = "0.1.64"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb"
+dependencies = [
+ "android_system_properties",
+ "core-foundation-sys",
+ "iana-time-zone-haiku",
+ "js-sys",
+ "log",
+ "wasm-bindgen",
+ "windows-core 0.62.2",
+]
+
+[[package]]
+name = "iana-time-zone-haiku"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "ident_case"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
+
+[[package]]
+name = "indenter"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5"
+
+[[package]]
+name = "indexmap"
+version = "2.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.16.0",
+]
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+
+[[package]]
+name = "itertools"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "js-sys"
+version = "0.3.82"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65"
+dependencies = [
+ "once_cell",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.177"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976"
+
+[[package]]
+name = "libredox"
+version = "0.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb"
+dependencies = [
+ "bitflags",
+ "libc",
+ "redox_syscall",
+]
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
+
+[[package]]
+name = "log"
+version = "0.4.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
+
+[[package]]
+name = "memchr"
+version = "2.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
+
+[[package]]
+name = "minimal-lexical"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+
+[[package]]
+name = "mio"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "nix"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
+dependencies = [
+ "bitflags",
+ "cfg-if",
+ "cfg_aliases",
+ "libc",
+]
+
+[[package]]
+name = "nix"
+version = "0.30.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
+dependencies = [
+ "bitflags",
+ "cfg-if",
+ "cfg_aliases",
+ "libc",
+]
+
+[[package]]
+name = "nom"
+version = "7.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
+dependencies = [
+ "memchr",
+ "minimal-lexical",
+]
+
+[[package]]
+name = "normalize-path"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f5438dd2b2ff4c6df6e1ce22d825ed2fa93ee2922235cc45186991717f0a892d"
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
+
+[[package]]
+name = "once_cell_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+
+[[package]]
+name = "peg"
+version = "0.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9928cfca101b36ec5163e70049ee5368a8a1c3c6efc9ca9c5f9cc2f816152477"
+dependencies = [
+ "peg-macros",
+ "peg-runtime",
+]
+
+[[package]]
+name = "peg-macros"
+version = "0.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6298ab04c202fa5b5d52ba03269fb7b74550b150323038878fe6c372d8280f71"
+dependencies = [
+ "peg-runtime",
+ "proc-macro2",
+ "quote",
+]
+
+[[package]]
+name = "peg-runtime"
+version = "0.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "132dca9b868d927b35b5dd728167b2dee150eb1ad686008fc71ccb298b776fca"
+
+[[package]]
+name = "phf"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
+dependencies = [
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_codegen"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_generator"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
+dependencies = [
+ "phf_shared",
+ "rand 0.8.5",
+]
+
+[[package]]
+name = "phf_shared"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
+dependencies = [
+ "siphasher",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
+
+[[package]]
+name = "pin-utils"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
+[[package]]
+name = "prettyplease"
+version = "0.2.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
+dependencies = [
+ "proc-macro2",
+ "syn",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.103"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.42"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "rand"
+version = "0.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
+dependencies = [
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "rand"
+version = "0.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
+dependencies = [
+ "rand_chacha",
+ "rand_core 0.9.3",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.9.3",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+
+[[package]]
+name = "rand_core"
+version = "0.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38"
+dependencies = [
+ "getrandom",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
+
+[[package]]
+name = "rpds"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e75f485e819d4d3015e6c0d55d02a4fd3db47c1993d9e603e0361fba2bffb34"
+dependencies = [
+ "archery",
+]
+
+[[package]]
+name = "rustix"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+
+[[package]]
+name = "shlex"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "siphasher"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d"
+
+[[package]]
+name = "slab"
+version = "0.4.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589"
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "strum"
+version = "0.27.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
+
+[[package]]
+name = "strum_macros"
+version = "0.27.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.110"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "terminal_size"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0"
+dependencies = [
+ "rustix",
+ "windows-sys 0.60.2",
+]
+
+[[package]]
+name = "terminfo"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662"
+dependencies = [
+ "fnv",
+ "nom",
+ "phf",
+ "phf_codegen",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tokio"
+version = "1.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "tokio-macros",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tracing"
+version = "0.1.41"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
+dependencies = [
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "triomphe"
+version = "0.1.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd69c5aa8f924c7519d6372789a74eac5b94fb0f8fcf0d4a97eb0bfc3e785f39"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
+
+[[package]]
+name = "utf8-chars"
+version = "3.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f598f797138b219a4560b4e9c53c255e872e267c9e3fdcc75aa59a2a90953bcd"
+dependencies = [
+ "arrayvec",
+]
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "uuid"
+version = "1.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "uzers"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4df81ff504e7d82ad53e95ed1ad5b72103c11253f39238bcc0235b90768a97dd"
+dependencies = [
+ "libc",
+ "log",
+]
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasip2"
+version = "1.0.1+wasi-0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
+name = "wasite"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.105"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.105"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.105"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.105"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.82"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "web-time"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "whoami"
+version = "1.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d"
+dependencies = [
+ "libredox",
+ "wasite",
+ "web-sys",
+]
+
+[[package]]
+name = "widestring"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
+
+[[package]]
+name = "windows"
+version = "0.57.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143"
+dependencies = [
+ "windows-core 0.57.0",
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.57.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d"
+dependencies = [
+ "windows-implement 0.57.0",
+ "windows-interface 0.57.0",
+ "windows-result 0.1.2",
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
+dependencies = [
+ "windows-implement 0.60.2",
+ "windows-interface 0.59.3",
+ "windows-link 0.2.1",
+ "windows-result 0.4.1",
+ "windows-strings",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.57.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.57.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.59.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-result"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-result"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
+dependencies = [
+ "windows-link 0.2.1",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
+dependencies = [
+ "windows-link 0.2.1",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
+dependencies = [
+ "windows-targets 0.53.5",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link 0.2.1",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm 0.52.6",
+ "windows_aarch64_msvc 0.52.6",
+ "windows_i686_gnu 0.52.6",
+ "windows_i686_gnullvm 0.52.6",
+ "windows_i686_msvc 0.52.6",
+ "windows_x86_64_gnu 0.52.6",
+ "windows_x86_64_gnullvm 0.52.6",
+ "windows_x86_64_msvc 0.52.6",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.53.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
+dependencies = [
+ "windows-link 0.2.1",
+ "windows_aarch64_gnullvm 0.53.1",
+ "windows_aarch64_msvc 0.53.1",
+ "windows_i686_gnu 0.53.1",
+ "windows_i686_gnullvm 0.53.1",
+ "windows_i686_msvc 0.53.1",
+ "windows_x86_64_gnu 0.53.1",
+ "windows_x86_64_gnullvm 0.53.1",
+ "windows_x86_64_msvc 0.53.1",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
+
+[[package]]
+name = "wit-bindgen"
+version = "0.46.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
+
+[[package]]
+name = "zerocopy"
+version = "0.8.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
diff --git a/crates/brush-core-vendored/Cargo.toml b/crates/brush-core-vendored/Cargo.toml
new file mode 100644
index 000000000..1059722c7
--- /dev/null
+++ b/crates/brush-core-vendored/Cargo.toml
@@ -0,0 +1,258 @@
+# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
+#
+# When uploading crates to the registry Cargo will automatically
+# "normalize" Cargo.toml files for maximal compatibility
+# with all versions of Cargo and also rewrite `path` dependencies
+# to registry (e.g., crates.io) dependencies.
+#
+# If you are reading this file be aware that the original Cargo.toml
+# will likely look very different (and much more reasonable).
+# See Cargo.toml.orig for the original contents.
+
+[package]
+edition = "2024"
+rust-version = "1.87.0"
+name = "brush-core"
+version = "0.4.0"
+build = false
+autolib = false
+autobins = false
+autoexamples = false
+autotests = false
+autobenches = false
+description = "Reusable core of a POSIX/bash shell (used by brush-shell)"
+readme = "README.md"
+keywords = [
+ "cli",
+ "shell",
+ "sh",
+ "bash",
+ "script",
+]
+categories = [
+ "command-line-utilities",
+ "development-tools",
+]
+license = "MIT"
+repository = "https://github.com/reubeno/brush"
+
+[lib]
+name = "brush_core"
+path = "src/lib.rs"
+bench = false
+
+[[example]]
+name = "call-func"
+path = "examples/call-func.rs"
+
+[[example]]
+name = "custom-builtin"
+path = "examples/custom-builtin.rs"
+
+[dependencies.async-recursion]
+version = "1.1.1"
+
+[dependencies.async-trait]
+version = "0.1.89"
+
+[dependencies.bon]
+version = "3.8.1"
+
+[dependencies.brush-parser]
+version = "^0.3.0"
+
+[dependencies.cached]
+version = "0.56.0"
+
+[dependencies.cfg-if]
+version = "1.0.4"
+
+[dependencies.chrono]
+version = "0.4.42"
+
+[dependencies.clap]
+version = "4.5.51"
+features = [
+ "derive",
+ "wrap_help",
+]
+
+[dependencies.fancy-regex]
+version = "0.16.2"
+
+[dependencies.futures]
+version = "0.3.31"
+
+[dependencies.indexmap]
+version = "2.12.0"
+
+[dependencies.itertools]
+version = "0.14.0"
+
+[dependencies.normalize-path]
+version = "0.2.1"
+
+[dependencies.rand]
+version = "0.9.2"
+
+[dependencies.rpds]
+version = "1.2.0"
+
+[dependencies.strum]
+version = "0.27.2"
+
+[dependencies.strum_macros]
+version = "0.27.2"
+
+[dependencies.thiserror]
+version = "2.0.17"
+
+[dependencies.tracing]
+version = "0.1.41"
+
+[dependencies.tokio-util]
+version = "0.7.16"
+
+[dev-dependencies.anyhow]
+version = "1.0.100"
+
+[target."cfg(any(unix, windows))".dependencies.hostname]
+version = "0.4.1"
+
+[target."cfg(any(unix, windows))".dependencies.tokio]
+version = "1.48.0"
+features = [
+ "io-util",
+ "macros",
+ "process",
+ "rt",
+ "rt-multi-thread",
+ "signal",
+ "sync",
+]
+
+[target.'cfg(target_family = "wasm")'.dependencies.tokio]
+version = "1.48.0"
+features = [
+ "io-util",
+ "macros",
+ "rt",
+ "sync",
+]
+
+[target."cfg(unix)".dependencies.command-fds]
+version = "0.3.2"
+
+[target."cfg(unix)".dependencies.nix]
+version = "0.30.1"
+features = [
+ "fs",
+ "process",
+ "resource",
+ "signal",
+ "term",
+ "user",
+]
+
+[target."cfg(unix)".dependencies.terminfo]
+version = "0.9.0"
+
+[target."cfg(unix)".dependencies.uzers]
+version = "0.12.1"
+
+[target."cfg(windows)".dependencies.homedir]
+version = "=0.3.4"
+
+[target."cfg(windows)".dependencies.whoami]
+version = "1.6.1"
+
+[target.wasm32-unknown-unknown.dependencies.getrandom]
+version = "0.3.4"
+features = ["wasm_js"]
+
+[target.wasm32-unknown-unknown.dependencies.uuid]
+version = "1.18.1"
+features = ["js"]
+
+[lints.clippy]
+bool_to_int_with_if = "allow"
+cognitive_complexity = "allow"
+collapsible_else_if = "allow"
+collapsible_if = "allow"
+expect_used = "deny"
+format_push_string = "deny"
+if_not_else = "allow"
+if_same_then_else = "allow"
+match_same_arms = "allow"
+missing_errors_doc = "allow"
+missing_panics_doc = "allow"
+multiple_crate_versions = "allow"
+must_use_candidate = "allow"
+option_if_let_else = "allow"
+panic = "deny"
+panic_in_result_fn = "deny"
+redundant_closure_for_method_calls = "allow"
+redundant_else = "allow"
+redundant_pub_crate = "allow"
+result_large_err = "allow"
+similar_names = "allow"
+string_slice = "deny"
+struct_excessive_bools = "allow"
+todo = "deny"
+undocumented_unsafe_blocks = "deny"
+unwrap_in_result = "deny"
+
+[lints.clippy.all]
+level = "deny"
+priority = -1
+
+[lints.clippy.cargo]
+level = "deny"
+priority = -1
+
+[lints.clippy.nursery]
+level = "deny"
+priority = -1
+
+[lints.clippy.pedantic]
+level = "deny"
+priority = -1
+
+[lints.clippy.perf]
+level = "deny"
+priority = -1
+
+[lints.rust]
+unnameable_types = "deny"
+unsafe_op_in_unsafe_fn = "deny"
+unused_attributes = "deny"
+unused_lifetimes = "deny"
+unused_macro_rules = "deny"
+
+[lints.rust.future_incompatible]
+level = "deny"
+priority = 0
+
+[lints.rust.missing_docs]
+level = "deny"
+priority = 0
+
+[lints.rust.nonstandard_style]
+level = "deny"
+priority = 0
+
+[lints.rust.rust_2018_idioms]
+level = "deny"
+priority = -1
+
+[lints.rust.unknown_lints]
+level = "allow"
+priority = -100
+
+[lints.rust.warnings]
+level = "deny"
+priority = 0
+
+[lints.rustdoc.all]
+level = "deny"
+priority = -1
diff --git a/crates/brush-core-vendored/LICENSE b/crates/brush-core-vendored/LICENSE
new file mode 100644
index 000000000..3d3cb7866
--- /dev/null
+++ b/crates/brush-core-vendored/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2024 reuben olinsky
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/crates/brush-core-vendored/README.md b/crates/brush-core-vendored/README.md
new file mode 100644
index 000000000..c8881e4af
--- /dev/null
+++ b/crates/brush-core-vendored/README.md
@@ -0,0 +1,187 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`brush` (**B**o(u)rn(e) **RU**sty **SH**ell) is a [POSIX-](https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html) and [bash-](https://www.gnu.org/software/bash/)compatible shell,
+implemented in Rust. At its core is an embeddable shell interpreter published for reuse
+in other Rust projects. It's built and tested on Linux, macOS, and WSL. Native Windows
+support is experimental.
+
+
+
+
+
+`brush` is functional for interactive use as a daily driver! It executes most `sh` and `bash` scripts we've
+encountered. Known limitations are tracked with filed issues. Out of an abundance of caution,
+we wouldn't recommend using it yet in _production_ scenarios in case it doesn't behave identically
+to your existing stable shell. (If you do find any behavioral differences, though, please report them with an
+issue!)
+
+Contributions and feedback of all kinds are welcome! For more guidance, please consult our
+[contribution guidelines](CONTRIBUTING.md). For more technical details, please consult the
+[documentation](docs/README.md) in this repo.
+
+This project was originally borne out of curiosity and a desire to learn. We're doing our best to keep that
+attitude :).
+
+
+
+## 📝 License
+
+Available for use and distribution under the [MIT license](LICENSE).
+
+## ⌨️ Installation
+
+_When you run `brush`, it should look exactly as `bash` does on your system: it processes your `.bashrc` and
+other standard configuration. If you'd like to distinguish the look of `brush` from the other shells
+on your system, you may author a `~/.brushrc` file._
+
+
+🚀 Installing prebuilt binaries from GitHub
+
+We publish prebuilt binaries of `brush` for Linux (x86_64, aarch64) and macOS (aarch64) to GitHub for official [releases](https://github.com/reubeno/brush/releases). You can manually download and extract the `brush` binary from one of the archives published there, or otherwise use the GitHub CLI to download it, e.g.:
+
+```bash
+gh release download --repo reubeno/brush --pattern "brush-x86_64-unknown-linux-gnu.*"
+```
+
+After downloading the archive for your platform, you may verify its authenticity using the [GitHub CLI](https://cli.github.com/), e.g.:
+
+```bash
+gh attestation verify brush-x86_64-unknown-linux-gnu.tar.gz --repo reubeno/brush
+```
+
+
+
+🚀 Installing prebuilt binaries via `cargo binstall`
+
+You may use [cargo binstall](https://github.com/cargo-bins/cargo-binstall) to install pre-built `brush` binaries. Once you've installed `cargo-binstall` you can run:
+
+```bash
+cargo binstall brush-shell
+```
+
+
+
+
+ 🔨 Installing from sources
+
+To build from sources, first install a working (and recent) `rust` toolchain; we recommend installing it via [`rustup`](https://rustup.rs/). Then run:
+
+```bash
+cargo install --locked brush-shell
+```
+
+
+
+🐧 Installing using Nix
+
+If you are a Nix user, you can use the registered version:
+
+```bash
+nix run 'github:NixOS/nixpkgs/nixpkgs-unstable#brush' -- --version
+```
+
+
+
+🐧 Installing on Arch Linux
+
+Arch Linux users can install `brush` from the official [extra repository](https://archlinux.org/packages/extra/x86_64/brush/):
+
+```bash
+pacman -S brush
+```
+
+
+
+🍺 Installing using Homebrew
+
+Homebrew users can install using [the `brush` formula](https://formulae.brew.sh/formula/brush):
+
+```bash
+brew install brush
+```
+
+
+## 👥 Community
+
+`brush` has a community Discord server, available [here](https://discord.gg/kPRgC9j3Tj).
+
+## 🔍 Known limitations
+
+There are some known gaps in compatibility. Most notably:
+
+* **Some `set` and `shopt` options.**
+ The `set` builtin is implemented, as is `set -x` and many frequently used `set`/`shopt` options, but a number aren't fully implemented. For example, `set -e` will execute but its semantics aren't applied across execution.
+
+If you're interested, we'd love contributions to improve compatibility, broaden test coverage, or really any other opportunities you can find to help us make this project better.
+
+## 🧪 Testing strategy
+
+This project is primarily tested by comparing its behavior with other existing shells, leveraging the latter as test oracles. The integration tests implemented in this repo include [850+ test cases](brush-shell/tests/cases) run on both this shell and an oracle, comparing standard output and exit codes.
+
+For more details, please consult the [reference documentation on integration testing](docs/reference/integration-testing.md).
+
+## 🙏 Credits
+
+There's a long list of OSS crates whose shoulders this project rests on. Notably, the following crates are directly relied on for major portions of shell functionality:
+
+* [`reedline`](https://github.com/nushell/reedline) - for readline-like input and interactive usage
+* [`clap`](https://github.com/clap-rs/clap) - command-line parsing, used both by the top-level brush CLI as well as built-in commands
+* [`fancy-regex`](https://github.com/fancy-regex/fancy-regex) - relied on for everything regex
+* [`tokio`](https://github.com/tokio-rs/tokio) - async, well, everything
+* [`nix` rust crate](https://github.com/nix-rust/nix) - higher-level APIs for Unix/POSIX system APIs
+
+For testing, performance benchmarking, and other important engineering support, we use and love:
+
+* [`pprof-rs`](https://github.com/tikv/pprof-rs) - for sampling-based CPU profiling
+* [`criterion.rs`](https://github.com/bheisler/criterion.rs) - for statistics-based benchmarking
+* [`bash-completion`](https://github.com/scop/bash-completion) - for its completion test suite and general completion support!
+
+## 🔗 Links: other shell implementations
+
+There are a number of other POSIX-ish shells implemented in a non-C/C++ implementation language. Some inspirational examples include:
+
+* [`nushell`](https://www.nushell.sh/) - modern Rust-implemented shell (which also provides the `reedline` crate we use!)
+* [`rusty_bash`](https://github.com/shellgei/rusty_bash)
+* [`mvdan/sh`](https://github.com/mvdan/sh)
+* [`Oils`](https://github.com/oils-for-unix/oils)
+* [`fish`](https://fishshell.com) ([as of 4.0](https://fishshell.com/blog/rustport/))
+
+We're sure there are plenty more; we're happy to include links to them as well.
diff --git a/crates/brush-core-vendored/src/arithmetic.rs b/crates/brush-core-vendored/src/arithmetic.rs
new file mode 100644
index 000000000..6d9f02108
--- /dev/null
+++ b/crates/brush-core-vendored/src/arithmetic.rs
@@ -0,0 +1,351 @@
+//! Arithmetic evaluation
+
+use std::borrow::Cow;
+
+use crate::{ExecutionParameters, Shell, env, expansion, variables};
+use brush_parser::ast;
+
+/// Represents an error that occurs during evaluation of an arithmetic expression.
+#[derive(Debug, thiserror::Error)]
+pub enum EvalError {
+ /// Division by zero.
+ #[error("division by zero")]
+ DivideByZero,
+
+ /// Negative exponent.
+ #[error("exponent less than 0")]
+ NegativeExponent,
+
+ /// Failed to tokenize an arithmetic expression.
+ #[error("failed to tokenize expression")]
+ FailedToTokenizeExpression,
+
+ /// Failed to expand an arithmetic expression.
+ #[error("failed to expand expression: '{0}'")]
+ FailedToExpandExpression(String),
+
+ /// Failed to access an element of an array.
+ #[error("failed to access array")]
+ FailedToAccessArray,
+
+ /// Failed to update the shell environment in an assignment operator.
+ #[error("failed to update environment")]
+ FailedToUpdateEnvironment,
+
+ /// Failed to parse an arithmetic expression.
+ #[error("failed to parse expression: '{0}'")]
+ ParseError(String),
+
+ /// Failed to trace an arithmetic expression.
+ #[error("failed tracing expression")]
+ TraceError,
+}
+
+/// Trait implemented by arithmetic expressions that can be evaluated.
+pub(crate) trait ExpandAndEvaluate {
+ /// Evaluate the given expression, returning the resulting numeric value.
+ ///
+ /// # Arguments
+ ///
+ /// * `shell` - The shell to use for evaluation.
+ /// * `trace_if_needed` - Whether to trace the evaluation.
+ async fn eval(
+ &self,
+ shell: &mut Shell,
+ params: &ExecutionParameters,
+ trace_if_needed: bool,
+ ) -> Result;
+}
+
+impl ExpandAndEvaluate for ast::UnexpandedArithmeticExpr {
+ async fn eval(
+ &self,
+ shell: &mut Shell,
+ params: &ExecutionParameters,
+ trace_if_needed: bool,
+ ) -> Result {
+ expand_and_eval(shell, params, self.value.as_str(), trace_if_needed).await
+ }
+}
+
+/// Evaluate the given arithmetic expression, returning the resulting numeric value.
+///
+/// # Arguments
+///
+/// * `shell` - The shell to use for evaluation.
+/// * `expr` - The unexpanded arithmetic expression to evaluate.
+/// * `trace_if_needed` - Whether to trace the evaluation.
+pub(crate) async fn expand_and_eval(
+ shell: &mut Shell,
+ params: &ExecutionParameters,
+ expr: &str,
+ trace_if_needed: bool,
+) -> Result {
+ // Per documentation, first shell-expand it.
+ let expanded_self = expansion::basic_expand_str_without_tilde(shell, params, expr)
+ .await
+ .map_err(|_e| EvalError::FailedToExpandExpression(expr.to_owned()))?;
+
+ // Now parse.
+ let expr = brush_parser::arithmetic::parse(&expanded_self)
+ .map_err(|_e| EvalError::ParseError(expanded_self))?;
+
+ // Trace if applicable.
+ if trace_if_needed && shell.options.print_commands_and_arguments {
+ shell
+ .trace_command(params, std::format!("(( {expr} ))"))
+ .await
+ .map_err(|_err| EvalError::TraceError)?;
+ }
+
+ // Now evaluate.
+ expr.eval(shell)
+}
+
+/// Trait implemented by evaluatable arithmetic expressions.
+pub trait Evaluatable {
+ /// Evaluate the given arithmetic expression, returning the resulting numeric value.
+ ///
+ /// # Arguments
+ ///
+ /// * `shell` - The shell to use for evaluation.
+ fn eval(&self, shell: &mut Shell) -> Result;
+}
+
+impl Evaluatable for ast::ArithmeticExpr {
+ fn eval(&self, shell: &mut Shell) -> Result {
+ let value = match self {
+ Self::Literal(l) => *l,
+ Self::Reference(lvalue) => deref_lvalue(shell, lvalue)?,
+ Self::UnaryOp(op, operand) => apply_unary_op(shell, *op, operand)?,
+ Self::BinaryOp(op, left, right) => apply_binary_op(shell, *op, left, right)?,
+ Self::Conditional(condition, then_expr, else_expr) => {
+ let conditional_eval = condition.eval(shell)?;
+
+ // Ensure we only evaluate the branch indicated by the condition.
+ if conditional_eval != 0 {
+ then_expr.eval(shell)?
+ } else {
+ else_expr.eval(shell)?
+ }
+ }
+ Self::Assignment(lvalue, expr) => {
+ let expr_eval = expr.eval(shell)?;
+ assign(shell, lvalue, expr_eval)?
+ }
+ Self::UnaryAssignment(op, lvalue) => apply_unary_assignment_op(shell, lvalue, *op)?,
+ Self::BinaryAssignment(op, lvalue, operand) => {
+ let value = apply_binary_op(shell, *op, &Self::Reference(lvalue.clone()), operand)?;
+ assign(shell, lvalue, value)?
+ }
+ };
+
+ Ok(value)
+ }
+}
+
+fn deref_lvalue(shell: &mut Shell, lvalue: &ast::ArithmeticTarget) -> Result {
+ let value_str: Cow<'_, str> = match lvalue {
+ ast::ArithmeticTarget::Variable(name) => shell.env_str(name).unwrap_or(Cow::Borrowed("")),
+ ast::ArithmeticTarget::ArrayElement(name, index_expr) => {
+ let index_str = index_expr.eval(shell)?.to_string();
+
+ shell
+ .env
+ .get(name)
+ .map_or_else(
+ || Ok(None),
+ |(_, v)| v.value().get_at(index_str.as_str(), shell),
+ )
+ .map_err(|_err| EvalError::FailedToAccessArray)?
+ .unwrap_or(Cow::Borrowed(""))
+ }
+ };
+
+ let parsed_value = brush_parser::arithmetic::parse(value_str.as_ref())
+ .map_err(|_err| EvalError::ParseError(value_str.to_string()))?;
+
+ parsed_value.eval(shell)
+}
+
+fn apply_unary_op(
+ shell: &mut Shell,
+ op: ast::UnaryOperator,
+ operand: &ast::ArithmeticExpr,
+) -> Result {
+ let operand_eval = operand.eval(shell)?;
+
+ match op {
+ ast::UnaryOperator::UnaryPlus => Ok(operand_eval),
+ ast::UnaryOperator::UnaryMinus => Ok(-operand_eval),
+ ast::UnaryOperator::BitwiseNot => Ok(!operand_eval),
+ ast::UnaryOperator::LogicalNot => Ok(bool_to_i64(operand_eval == 0)),
+ }
+}
+
+fn apply_binary_op(
+ shell: &mut Shell,
+ op: ast::BinaryOperator,
+ left: &ast::ArithmeticExpr,
+ right: &ast::ArithmeticExpr,
+) -> Result {
+ // First, special-case short-circuiting operators. For those, we need
+ // to ensure we don't eagerly evaluate both operands. After we
+ // get these out of the way, we can easily just evaluate operands
+ // for the other operators.
+ match op {
+ ast::BinaryOperator::LogicalAnd => {
+ let left = left.eval(shell)?;
+ if left == 0 {
+ return Ok(bool_to_i64(false));
+ }
+
+ let right = right.eval(shell)?;
+ return Ok(bool_to_i64(right != 0));
+ }
+ ast::BinaryOperator::LogicalOr => {
+ let left = left.eval(shell)?;
+ if left != 0 {
+ return Ok(bool_to_i64(true));
+ }
+
+ let right = right.eval(shell)?;
+ return Ok(bool_to_i64(right != 0));
+ }
+ _ => (),
+ }
+
+ // The remaining operators unconditionally operate both operands.
+ let left = left.eval(shell)?;
+ let right = right.eval(shell)?;
+
+ #[expect(clippy::cast_possible_truncation)]
+ #[expect(clippy::cast_sign_loss)]
+ match op {
+ ast::BinaryOperator::Power => {
+ if right >= 0 {
+ Ok(wrapping_pow_u64(left, right as u64))
+ } else {
+ Err(EvalError::NegativeExponent)
+ }
+ }
+ ast::BinaryOperator::Multiply => Ok(left.wrapping_mul(right)),
+ ast::BinaryOperator::Divide => {
+ if right == 0 {
+ Err(EvalError::DivideByZero)
+ } else {
+ Ok(left.wrapping_div(right))
+ }
+ }
+ ast::BinaryOperator::Modulo => {
+ if right == 0 {
+ Err(EvalError::DivideByZero)
+ } else {
+ Ok(left % right)
+ }
+ }
+ ast::BinaryOperator::Comma => Ok(right),
+ ast::BinaryOperator::Add => Ok(left.wrapping_add(right)),
+ ast::BinaryOperator::Subtract => Ok(left.wrapping_sub(right)),
+ ast::BinaryOperator::ShiftLeft => Ok(left.wrapping_shl(right as u32)),
+ ast::BinaryOperator::ShiftRight => Ok(left.wrapping_shr(right as u32)),
+ ast::BinaryOperator::LessThan => Ok(bool_to_i64(left < right)),
+ ast::BinaryOperator::LessThanOrEqualTo => Ok(bool_to_i64(left <= right)),
+ ast::BinaryOperator::GreaterThan => Ok(bool_to_i64(left > right)),
+ ast::BinaryOperator::GreaterThanOrEqualTo => Ok(bool_to_i64(left >= right)),
+ ast::BinaryOperator::Equals => Ok(bool_to_i64(left == right)),
+ ast::BinaryOperator::NotEquals => Ok(bool_to_i64(left != right)),
+ ast::BinaryOperator::BitwiseAnd => Ok(left & right),
+ ast::BinaryOperator::BitwiseXor => Ok(left ^ right),
+ ast::BinaryOperator::BitwiseOr => Ok(left | right),
+ ast::BinaryOperator::LogicalAnd => unreachable!("LogicalAnd covered above"),
+ ast::BinaryOperator::LogicalOr => unreachable!("LogicalOr covered above"),
+ }
+}
+
+fn apply_unary_assignment_op(
+ shell: &mut Shell,
+ lvalue: &ast::ArithmeticTarget,
+ op: ast::UnaryAssignmentOperator,
+) -> Result {
+ let value = deref_lvalue(shell, lvalue)?;
+
+ match op {
+ ast::UnaryAssignmentOperator::PrefixIncrement => {
+ let new_value = value + 1;
+ assign(shell, lvalue, new_value)?;
+ Ok(new_value)
+ }
+ ast::UnaryAssignmentOperator::PrefixDecrement => {
+ let new_value = value - 1;
+ assign(shell, lvalue, new_value)?;
+ Ok(new_value)
+ }
+ ast::UnaryAssignmentOperator::PostfixIncrement => {
+ let new_value = value + 1;
+ assign(shell, lvalue, new_value)?;
+ Ok(value)
+ }
+ ast::UnaryAssignmentOperator::PostfixDecrement => {
+ let new_value = value - 1;
+ assign(shell, lvalue, new_value)?;
+ Ok(value)
+ }
+ }
+}
+
+fn assign(shell: &mut Shell, lvalue: &ast::ArithmeticTarget, value: i64) -> Result {
+ match lvalue {
+ ast::ArithmeticTarget::Variable(name) => {
+ shell
+ .env
+ .update_or_add(
+ name.as_str(),
+ variables::ShellValueLiteral::Scalar(value.to_string()),
+ |_| Ok(()),
+ env::EnvironmentLookup::Anywhere,
+ env::EnvironmentScope::Global,
+ )
+ .map_err(|_err| EvalError::FailedToUpdateEnvironment)?;
+ }
+ ast::ArithmeticTarget::ArrayElement(name, index_expr) => {
+ let index_str = index_expr.eval(shell)?.to_string();
+
+ shell
+ .env
+ .update_or_add_array_element(
+ name.as_str(),
+ index_str,
+ value.to_string(),
+ |_| Ok(()),
+ env::EnvironmentLookup::Anywhere,
+ env::EnvironmentScope::Global,
+ )
+ .map_err(|_err| EvalError::FailedToUpdateEnvironment)?;
+ }
+ }
+
+ Ok(value)
+}
+
+const fn bool_to_i64(value: bool) -> i64 {
+ if value { 1 } else { 0 }
+}
+
+// N.B. We implement our own version of wrapping_pow that takes a 64-bit exponent.
+// This seems to be the best way to guarantee that we handle overflow cases
+// with exponents correctly.
+const fn wrapping_pow_u64(mut base: i64, mut exponent: u64) -> i64 {
+ let mut result: i64 = 1;
+
+ while exponent > 0 {
+ if exponent % 2 == 1 {
+ result = result.wrapping_mul(base);
+ }
+
+ base = base.wrapping_mul(base);
+ exponent /= 2;
+ }
+
+ result
+}
diff --git a/crates/brush-core-vendored/src/braceexpansion.rs b/crates/brush-core-vendored/src/braceexpansion.rs
new file mode 100644
index 000000000..7ef345229
--- /dev/null
+++ b/crates/brush-core-vendored/src/braceexpansion.rs
@@ -0,0 +1,80 @@
+use brush_parser::word;
+use itertools::Itertools;
+
+pub(crate) fn generate_and_combine_brace_expansions(
+ pieces: Vec,
+) -> impl IntoIterator- {
+ let expansions: Vec
> = pieces
+ .into_iter()
+ .map(|piece| expand_brace_expr_or_text(piece).collect())
+ .collect();
+
+ expansions
+ .into_iter()
+ .multi_cartesian_product()
+ .map(|v| v.join(""))
+}
+
+fn expand_brace_expr_or_text(
+ beot: word::BraceExpressionOrText,
+) -> Box> {
+ match beot {
+ word::BraceExpressionOrText::Expr(members) => {
+ // Chain all member iterators together
+ Box::new(members.into_iter().flat_map(expand_brace_expr_member))
+ }
+ word::BraceExpressionOrText::Text(text) => Box::new(std::iter::once(text)),
+ }
+}
+
+#[expect(clippy::cast_possible_truncation)]
+fn expand_brace_expr_member(bem: word::BraceExpressionMember) -> Box> {
+ match bem {
+ word::BraceExpressionMember::NumberSequence {
+ start,
+ end,
+ increment,
+ } => {
+ let increment = increment.unsigned_abs() as usize;
+
+ if start <= end {
+ Box::new((start..=end).step_by(increment).map(|n| n.to_string()))
+ } else {
+ Box::new(
+ (end..=start)
+ .step_by(increment)
+ .map(|n| n.to_string())
+ .collect::>()
+ .into_iter()
+ .rev(),
+ )
+ }
+ }
+
+ word::BraceExpressionMember::CharSequence {
+ start,
+ end,
+ increment,
+ } => {
+ let increment = increment.unsigned_abs() as usize;
+
+ if start <= end {
+ Box::new((start..=end).step_by(increment).map(|c| c.to_string()))
+ } else {
+ Box::new(
+ (end..=start)
+ .step_by(increment)
+ .map(|c| c.to_string())
+ .collect::>()
+ .into_iter()
+ .rev(),
+ )
+ }
+ }
+
+ word::BraceExpressionMember::Child(elements) => {
+ // Chain all element iterators together
+ Box::new(generate_and_combine_brace_expansions(elements).into_iter())
+ }
+ }
+}
diff --git a/crates/brush-core-vendored/src/builtins.rs b/crates/brush-core-vendored/src/builtins.rs
new file mode 100644
index 000000000..cd4ae4f4e
--- /dev/null
+++ b/crates/brush-core-vendored/src/builtins.rs
@@ -0,0 +1,522 @@
+//! Facilities for implementing and managing builtins
+
+use clap::builder::styling;
+use futures::future::BoxFuture;
+use std::io::Write;
+
+use crate::{BuiltinError, CommandArg, commands, error, results};
+
+/// Type of a function implementing a built-in command.
+///
+/// # Arguments
+///
+/// * The context in which the command is being executed.
+/// * The arguments to the command.
+pub type CommandExecuteFunc = fn(
+ commands::ExecutionContext<'_>,
+ Vec,
+) -> BoxFuture<'_, Result>;
+
+/// Type of a function to retrieve help content for a built-in command.
+///
+/// # Arguments
+///
+/// * `name` - The name of the command.
+/// * `content_type` - The type of content to retrieve.
+pub type CommandContentFunc = fn(&str, ContentType) -> Result;
+
+/// Trait implemented by built-in shell commands.
+pub trait Command: clap::Parser {
+ /// The error type returned by the command.
+ type Error: BuiltinError + 'static;
+
+ /// Instantiates the built-in command with the given arguments.
+ ///
+ /// # Arguments
+ ///
+ /// * `args` - The arguments to the command.
+ fn new(args: I) -> Result
+ where
+ I: IntoIterator- ,
+ {
+ if !Self::takes_plus_options() {
+ Self::try_parse_from(args)
+ } else {
+ // N.B. clap doesn't support named options like '+x'. To work around this, we
+ // establish a pattern of renaming them.
+ let mut updated_args = vec![];
+ for arg in args {
+ if let Some(plus_options) = arg.strip_prefix("+") {
+ for c in plus_options.chars() {
+ updated_args.push(format!("--+{c}"));
+ }
+ } else {
+ updated_args.push(arg);
+ }
+ }
+
+ Self::try_parse_from(updated_args)
+ }
+ }
+
+ /// Returns whether or not the command takes options with a leading '+' or '-' character.
+ fn takes_plus_options() -> bool {
+ false
+ }
+
+ /// Executes the built-in command in the provided context.
+ ///
+ /// # Arguments
+ ///
+ /// * `context` - The context in which the command is being executed.
+ // NOTE: we use desugared async here because we need a Send marker
+ fn execute(
+ &self,
+ context: commands::ExecutionContext<'_>,
+ ) -> impl std::future::Future
>
+ + std::marker::Send;
+
+ /// Returns the textual help content associated with the command.
+ ///
+ /// # Arguments
+ ///
+ /// * `name` - The name of the command.
+ /// * `content_type` - The type of content to retrieve.
+ fn get_content(name: &str, content_type: ContentType) -> Result {
+ let mut clap_command = Self::command()
+ .styles(brush_help_styles())
+ .next_line_help(false);
+ clap_command.set_bin_name(name);
+
+ let s = match content_type {
+ ContentType::DetailedHelp => clap_command.render_help().ansi().to_string(),
+ ContentType::ShortUsage => get_builtin_short_usage(name, &clap_command),
+ ContentType::ShortDescription => get_builtin_short_description(name, &clap_command),
+ ContentType::ManPage => get_builtin_man_page(name, &clap_command)?,
+ };
+
+ Ok(s)
+ }
+}
+
+/// Trait implemented by built-in shell commands that take specially handled declarations
+/// as arguments.
+pub trait DeclarationCommand: Command {
+ /// Stores the declarations within the command instance.
+ ///
+ /// # Arguments
+ ///
+ /// * `declarations` - The declarations to store.
+ fn set_declarations(&mut self, declarations: Vec);
+}
+
+/// Type of help content, typically associated with a built-in command.
+pub enum ContentType {
+ /// Detailed help content for the command.
+ DetailedHelp,
+ /// Short usage information for the command.
+ ShortUsage,
+ /// Short description for the command.
+ ShortDescription,
+ /// man-style help page.
+ ManPage,
+}
+
+/// Encapsulates a registration for a built-in command.
+#[derive(Clone)]
+pub struct Registration {
+ /// Function to execute the builtin.
+ pub execute_func: CommandExecuteFunc,
+
+ /// Function to retrieve the builtin's content/help text.
+ pub content_func: CommandContentFunc,
+
+ /// Has this registration been disabled?
+ pub disabled: bool,
+
+ /// Is the builtin classified as "special" by specification?
+ pub special_builtin: bool,
+
+ /// Is this builtin one that takes specially handled declarations?
+ pub declaration_builtin: bool,
+}
+
+impl Registration {
+ /// Updates the given registration to mark it for a special builtin.
+ #[must_use]
+ pub const fn special(self) -> Self {
+ Self {
+ special_builtin: true,
+ ..self
+ }
+ }
+}
+
+fn get_builtin_man_page(_name: &str, _command: &clap::Command) -> Result {
+ error::unimp("man page rendering is not yet implemented")
+}
+
+fn get_builtin_short_description(name: &str, command: &clap::Command) -> String {
+ let about = command
+ .get_about()
+ .map_or_else(String::new, |s| s.to_string());
+
+ std::format!("{name} - {about}\n")
+}
+
+fn get_builtin_short_usage(name: &str, command: &clap::Command) -> String {
+ let mut usage = String::new();
+
+ let mut needs_space = false;
+
+ let mut optional_short_opts = vec![];
+ let mut required_short_opts = vec![];
+ for opt in command.get_opts() {
+ if opt.is_hide_set() {
+ continue;
+ }
+
+ if let Some(c) = opt.get_short() {
+ if !opt.is_required_set() {
+ optional_short_opts.push(c);
+ } else {
+ required_short_opts.push(c);
+ }
+ }
+ }
+
+ if !optional_short_opts.is_empty() {
+ if needs_space {
+ usage.push(' ');
+ }
+
+ usage.push('[');
+ usage.push('-');
+ for c in optional_short_opts {
+ usage.push(c);
+ }
+
+ usage.push(']');
+ needs_space = true;
+ }
+
+ if !required_short_opts.is_empty() {
+ if needs_space {
+ usage.push(' ');
+ }
+
+ usage.push('-');
+ for c in required_short_opts {
+ usage.push(c);
+ }
+
+ needs_space = true;
+ }
+
+ for pos in command.get_positionals() {
+ if pos.is_hide_set() {
+ continue;
+ }
+
+ if !pos.is_required_set() {
+ if needs_space {
+ usage.push(' ');
+ }
+
+ usage.push('[');
+ needs_space = false;
+ }
+
+ if let Some(names) = pos.get_value_names() {
+ for name in names {
+ if needs_space {
+ usage.push(' ');
+ }
+
+ usage.push_str(name);
+ needs_space = true;
+ }
+ }
+
+ if !pos.is_required_set() {
+ usage.push(']');
+ needs_space = true;
+ }
+ }
+
+ std::format!("{name}: {name} {usage}\n")
+}
+
+fn brush_help_styles() -> clap::builder::Styles {
+ styling::Styles::styled()
+ .header(
+ styling::AnsiColor::Yellow.on_default()
+ | styling::Effects::BOLD
+ | styling::Effects::UNDERLINE,
+ )
+ .usage(styling::AnsiColor::Green.on_default() | styling::Effects::BOLD)
+ .literal(styling::AnsiColor::Magenta.on_default() | styling::Effects::BOLD)
+ .placeholder(styling::AnsiColor::Cyan.on_default())
+}
+
+/// This function and the [`try_parse_known`] exists to deal with
+/// the Clap's limitation of treating `--` like a regular value
+/// `https://github.com/clap-rs/clap/issues/5055`
+///
+/// # Arguments
+///
+/// * `args` - An Iterator from [`std::env::args`]
+///
+/// # Returns
+///
+/// * a parsed struct T from [`clap::Parser::parse_from`]
+/// * the remain iterator `args` with `--` and the rest arguments if they present otherwise None
+///
+/// # Examples
+/// ```
+/// use clap::{builder::styling, Parser};
+/// #[derive(Parser)]
+/// struct CommandLineArgs {
+/// #[clap(allow_hyphen_values = true, num_args=1..)]
+/// script_args: Vec,
+/// }
+///
+/// let (mut parsed_args, raw_args) =
+/// brush_core::parse_known::(std::env::args());
+/// if raw_args.is_some() {
+/// parsed_args.script_args = raw_args.unwrap().collect();
+/// }
+/// ```
+pub fn parse_known(
+ args: impl IntoIterator- ,
+) -> (T, Option
>)
+where
+ S: Into + Clone + PartialEq<&'static str>,
+{
+ let mut args = args.into_iter();
+ // the best way to save `--` is to get it out with a side effect while `clap` iterates over the
+ // args this way we can be 100% sure that we have '--' and the remaining args
+ // and we will iterate only once
+ let mut hyphen = None;
+ let args_before_hyphen = args.by_ref().take_while(|a| {
+ let is_hyphen = *a == "--";
+ if is_hyphen {
+ hyphen = Some(a.clone());
+ }
+ !is_hyphen
+ });
+ let parsed_args = T::parse_from(args_before_hyphen);
+ let raw_args = hyphen.map(|hyphen| std::iter::once(hyphen).chain(args));
+ (parsed_args, raw_args)
+}
+
+/// Similar to [`parse_known`] but with [`clap::Parser::try_parse_from`]
+/// This function is used to parse arguments in builtins such as
+/// `crate::echo::EchoCommand`
+pub fn try_parse_known(
+ args: impl IntoIterator- ,
+) -> Result<(T, Option
>), clap::Error> {
+ let mut args = args.into_iter();
+ let mut hyphen = None;
+ let args_before_hyphen = args.by_ref().take_while(|a| {
+ let is_hyphen = a == "--";
+ if is_hyphen {
+ hyphen = Some(a.clone());
+ }
+ !is_hyphen
+ });
+ let parsed_args = T::try_parse_from(args_before_hyphen)?;
+
+ let raw_args = hyphen.map(|hyphen| std::iter::once(hyphen).chain(args));
+ Ok((parsed_args, raw_args))
+}
+
+/// A simple command that can be registered as a built-in.
+pub trait SimpleCommand {
+ /// Returns the content of the built-in command.
+ fn get_content(name: &str, content_type: ContentType) -> Result;
+
+ /// Executes the built-in command.
+ fn execute, S: AsRef>(
+ context: commands::ExecutionContext<'_>,
+ args: I,
+ ) -> Result;
+}
+
+/// Returns a built-in command registration, given an implementation of the
+/// `SimpleCommand` trait.
+pub fn simple_builtin() -> Registration {
+ Registration {
+ execute_func: exec_simple_builtin::,
+ content_func: B::get_content,
+ disabled: false,
+ special_builtin: false,
+ declaration_builtin: false,
+ }
+}
+
+/// Returns a built-in command registration, given an implementation of the
+/// `Command` trait.
+pub fn builtin() -> Registration {
+ Registration {
+ execute_func: exec_builtin::,
+ content_func: get_builtin_content::,
+ disabled: false,
+ special_builtin: false,
+ declaration_builtin: false,
+ }
+}
+
+/// Returns a built-in command registration, given an implementation of the
+/// `DeclarationCommand` trait. Used for select commands that can take parsed
+/// declarations as arguments.
+pub fn decl_builtin() -> Registration {
+ Registration {
+ execute_func: exec_declaration_builtin::,
+ content_func: get_builtin_content::,
+ disabled: false,
+ special_builtin: false,
+ declaration_builtin: true,
+ }
+}
+
+#[allow(clippy::too_long_first_doc_paragraph)]
+/// Returns a built-in command registration, given an implementation of the
+/// `DeclarationCommand` trait that can be default-constructed. The command
+/// implementation is expected to implement clap's `Parser` trait solely
+/// for help/usage information. Arguments are passed directly to the command
+/// via `set_declarations`. This is primarily only expected to be used with
+/// select builtin commands that wrap other builtins (e.g., "builtin").
+pub fn raw_arg_builtin() -> Registration {
+ Registration {
+ execute_func: exec_raw_arg_builtin::,
+ content_func: get_builtin_content::,
+ disabled: false,
+ special_builtin: false,
+ declaration_builtin: true,
+ }
+}
+
+fn get_builtin_content(
+ name: &str,
+ content_type: ContentType,
+) -> Result {
+ T::get_content(name, content_type)
+}
+
+fn exec_simple_builtin(
+ context: commands::ExecutionContext<'_>,
+ args: Vec,
+) -> BoxFuture<'_, Result> {
+ Box::pin(async move { exec_simple_builtin_impl::(context, args).await })
+}
+
+#[expect(clippy::unused_async)]
+async fn exec_simple_builtin_impl(
+ context: commands::ExecutionContext<'_>,
+ args: Vec,
+) -> Result {
+ let plain_args = args.into_iter().map(|arg| match arg {
+ CommandArg::String(s) => s,
+ CommandArg::Assignment(a) => a.to_string(),
+ });
+
+ T::execute(context, plain_args)
+}
+
+fn exec_builtin(
+ context: commands::ExecutionContext<'_>,
+ args: Vec,
+) -> BoxFuture<'_, Result> {
+ Box::pin(async move { exec_builtin_impl::(context, args).await })
+}
+
+async fn exec_builtin_impl(
+ context: commands::ExecutionContext<'_>,
+ args: Vec,
+) -> Result {
+ let plain_args = args.into_iter().map(|arg| match arg {
+ CommandArg::String(s) => s,
+ CommandArg::Assignment(a) => a.to_string(),
+ });
+
+ let result = T::new(plain_args);
+ let command = match result {
+ Ok(command) => command,
+ Err(e) => {
+ writeln!(context.stderr(), "{e}")?;
+ return Ok(results::ExecutionExitCode::InvalidUsage.into());
+ }
+ };
+
+ call_builtin(command, context).await
+}
+
+fn exec_declaration_builtin(
+ context: commands::ExecutionContext<'_>,
+ args: Vec,
+) -> BoxFuture<'_, Result> {
+ Box::pin(async move { exec_declaration_builtin_impl::(context, args).await })
+}
+
+async fn exec_declaration_builtin_impl(
+ context: commands::ExecutionContext<'_>,
+ args: Vec,
+) -> Result {
+ let mut options = vec![];
+ let mut declarations = vec![];
+
+ for (i, arg) in args.into_iter().enumerate() {
+ match arg {
+ CommandArg::String(s)
+ if i == 0 || (s.len() > 1 && (s.starts_with('-') || s.starts_with('+'))) =>
+ {
+ options.push(s);
+ }
+ _ => declarations.push(arg),
+ }
+ }
+
+ let result = T::new(options);
+ let mut command = match result {
+ Ok(command) => command,
+ Err(e) => {
+ writeln!(context.stderr(), "{e}")?;
+ return Ok(results::ExecutionExitCode::InvalidUsage.into());
+ }
+ };
+
+ command.set_declarations(declarations);
+
+ call_builtin(command, context).await
+}
+
+fn exec_raw_arg_builtin(
+ context: commands::ExecutionContext<'_>,
+ args: Vec,
+) -> BoxFuture<'_, Result> {
+ Box::pin(async move { exec_raw_arg_builtin_impl::(context, args).await })
+}
+
+async fn exec_raw_arg_builtin_impl(
+ context: commands::ExecutionContext<'_>,
+ args: Vec,
+) -> Result {
+ let mut command = T::default();
+ command.set_declarations(args);
+
+ call_builtin(command, context).await
+}
+
+async fn call_builtin(
+ command: impl Command,
+ context: commands::ExecutionContext<'_>,
+) -> Result {
+ let builtin_name = context.command_name.clone();
+ let result = command
+ .execute(context)
+ .await
+ .map_err(|e| error::ErrorKind::BuiltinError(Box::new(e), builtin_name))?;
+
+ Ok(result)
+}
diff --git a/crates/brush-core-vendored/src/commands.rs b/crates/brush-core-vendored/src/commands.rs
new file mode 100644
index 000000000..d508bed63
--- /dev/null
+++ b/crates/brush-core-vendored/src/commands.rs
@@ -0,0 +1,662 @@
+//! Command execution
+
+use std::{borrow::Cow, ffi::OsStr, fmt::Display, process::Stdio, sync::Arc};
+
+use brush_parser::ast;
+use itertools::Itertools;
+use sys::commands::{CommandExt, CommandFdInjectionExt, CommandFgControlExt};
+
+use crate::{
+ ErrorKind, ExecutionControlFlow, ExecutionParameters, ExecutionResult, Shell, ShellFd,
+ builtins, env, error, escape,
+ interp::{self, Execute, ProcessGroupPolicy},
+ openfiles::{self, OpenFile, OpenFiles},
+ pathsearch, processes,
+ results::ExecutionSpawnResult,
+ sys, trace_categories, traps, variables,
+};
+
+/// Encapsulates the result of waiting for a command to complete.
+pub enum CommandWaitResult {
+ /// The command completed.
+ CommandCompleted(ExecutionResult),
+ /// The command was stopped before it completed.
+ CommandStopped(ExecutionResult, processes::ChildProcess),
+}
+
+/// Represents the context for executing a command.
+pub struct ExecutionContext<'a> {
+ /// The shell in which the command is being executed.
+ pub shell: &'a mut Shell,
+ /// The name of the command being executed.
+ pub command_name: String,
+ /// The parameters for the execution.
+ pub params: ExecutionParameters,
+}
+
+impl ExecutionContext<'_> {
+ /// Returns the standard input file; usable with `write!` et al.
+ pub fn stdin(&self) -> impl std::io::Read + 'static {
+ self.params.stdin(self.shell)
+ }
+
+ /// Returns the standard output file; usable with `write!` et al.
+ pub fn stdout(&self) -> impl std::io::Write + 'static {
+ self.params.stdout(self.shell)
+ }
+
+ /// Returns the standard error file; usable with `write!` et al.
+ pub fn stderr(&self) -> impl std::io::Write + 'static {
+ self.params.stderr(self.shell)
+ }
+
+ /// Returns the file descriptor with the given number. Returns `None`
+ /// if the file descriptor is not open.
+ ///
+ /// # Arguments
+ ///
+ /// * `fd` - The file descriptor number to retrieve.
+ pub fn try_fd(&self, fd: ShellFd) -> Option {
+ self.params.try_fd(self.shell, fd)
+ }
+
+ /// Iterates over all open file descriptors.
+ pub fn iter_fds(&self) -> impl Iterator- {
+ self.params.iter_fds(self.shell)
+ }
+
+ pub(crate) const fn should_cmd_lead_own_process_group(&self) -> bool {
+ self.shell.options.interactive
+ && matches!(
+ self.params.process_group_policy,
+ ProcessGroupPolicy::NewProcessGroup
+ )
+ }
+}
+
+/// An argument to a command.
+#[derive(Clone, Debug)]
+pub enum CommandArg {
+ /// A simple string argument.
+ String(String),
+ /// An assignment/declaration; typically treated as a string, but will
+ /// be specially handled by a limited set of built-in commands.
+ Assignment(ast::Assignment),
+}
+
+impl Display for CommandArg {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::String(s) => f.write_str(s),
+ Self::Assignment(a) => write!(f, "{a}"),
+ }
+ }
+}
+
+impl From
for CommandArg {
+ fn from(s: String) -> Self {
+ Self::String(s)
+ }
+}
+
+impl From<&String> for CommandArg {
+ fn from(value: &String) -> Self {
+ Self::String(value.clone())
+ }
+}
+
+impl CommandArg {
+ pub(crate) fn quote_for_tracing(&self) -> Cow<'_, str> {
+ match self {
+ Self::String(s) => escape::quote_if_needed(s, escape::QuoteMode::SingleQuote),
+ Self::Assignment(a) => {
+ let mut s = a.name.to_string();
+ let op = if a.append { "+=" } else { "=" };
+ s.push_str(op);
+ s.push_str(&escape::quote_if_needed(
+ a.value.to_string().as_str(),
+ escape::QuoteMode::SingleQuote,
+ ));
+ s.into()
+ }
+ }
+ }
+}
+
+/// Composes a `std::process::Command` to execute the given command. Appropriately
+/// configures the command name and arguments, redirections, injected file
+/// descriptors, environment variables, etc.
+///
+/// # Arguments
+///
+/// * `context` - The execution context in which the command is being composed.
+/// * `command_name` - The name of the command to execute.
+/// * `argv0` - The value to use for `argv[0]` (may be different from the command).
+/// * `args` - The arguments to pass to the command.
+/// * `empty_env` - If true, the command will be executed with an empty
+/// environment; if false, the command will inherit environment variables
+/// marked as exported in the provided `Shell`.
+#[allow(unused_variables, reason = "argv0 is only used on unix platforms")]
+pub fn compose_std_command>(
+ context: &ExecutionContext<'_>,
+ command_name: &str,
+ argv0: &str,
+ args: &[S],
+ empty_env: bool,
+) -> Result {
+ let mut cmd = std::process::Command::new(command_name);
+
+ // Override argv[0].
+ // NOTE: Not supported on all platforms.
+ cmd.arg0(argv0);
+
+ // Pass through args.
+ cmd.args(args);
+
+ // Use the shell's current working dir.
+ cmd.current_dir(context.shell.working_dir());
+
+ // Start with a clear environment.
+ cmd.env_clear();
+
+ // Add in exported variables.
+ if !empty_env {
+ for (k, v) in context.shell.env.iter_exported() {
+ // NOTE: To match bash behavior, we only include exported variables
+ // that are set (i.e., have a value). This means a variable that
+ // shows up in `declare -p` but has no *set* value will be omitted.
+ if v.value().is_set() {
+ cmd.env(k.as_str(), v.value().to_cow_str(context.shell).as_ref());
+ }
+ }
+ }
+
+ // Add in exported functions.
+ if !empty_env {
+ for (func_name, registration) in context.shell.funcs().iter() {
+ if registration.is_exported() {
+ let var_name = std::format!("BASH_FUNC_{func_name}%%");
+ let value = std::format!("() {}", registration.definition.body);
+ cmd.env(var_name, value);
+ }
+ }
+ }
+
+ // Redirect stdin, if applicable.
+ match context.try_fd(OpenFiles::STDIN_FD) {
+ Some(OpenFile::Stdin(_)) | None => (),
+ Some(stdin_file) => {
+ let as_stdio: Stdio = stdin_file.into();
+ cmd.stdin(as_stdio);
+ }
+ }
+
+ // Redirect stdout, if applicable.
+ match context.try_fd(OpenFiles::STDOUT_FD) {
+ Some(OpenFile::Stdout(_)) | None => (),
+ Some(stdout_file) => {
+ let as_stdio: Stdio = stdout_file.into();
+ cmd.stdout(as_stdio);
+ }
+ }
+
+ // Redirect stderr, if applicable.
+ match context.try_fd(OpenFiles::STDERR_FD) {
+ Some(OpenFile::Stderr(_)) | None => {}
+ Some(stderr_file) => {
+ let as_stdio: Stdio = stderr_file.into();
+ cmd.stderr(as_stdio);
+ }
+ }
+
+ // Inject any other fds.
+ let other_files = context.iter_fds().filter(|(fd, _)| {
+ *fd != OpenFiles::STDIN_FD && *fd != OpenFiles::STDOUT_FD && *fd != OpenFiles::STDERR_FD
+ });
+ cmd.inject_fds(other_files)?;
+
+ Ok(cmd)
+}
+
+pub(crate) async fn on_preexecute(
+ context: &mut ExecutionContext<'_>,
+ args: &[CommandArg],
+) -> Result<(), error::Error> {
+ // See if we have a DEBUG trap handler registered; call it if we do.
+ invoke_debug_trap_handler_if_registered(context, args).await?;
+
+ Ok(())
+}
+
+async fn invoke_debug_trap_handler_if_registered(
+ context: &mut ExecutionContext<'_>,
+ args: &[CommandArg],
+) -> Result<(), error::Error> {
+ if context.shell.traps.handler_depth == 0 {
+ let debug_trap_handler = context
+ .shell
+ .traps
+ .handlers
+ .get(&traps::TrapSignal::Debug)
+ .cloned();
+ if let Some(debug_trap_handler) = debug_trap_handler {
+ // TODO: Confirm whether trap handlers should be executed in the same process group.
+ let mut handler_params = context.params.clone();
+ handler_params.process_group_policy = ProcessGroupPolicy::SameProcessGroup;
+
+ let full_cmd = args.iter().map(|arg| arg.to_string()).join(" ");
+
+ // TODO: This shouldn't *just* be set in a trap situation.
+ context.shell.env.update_or_add(
+ "BASH_COMMAND",
+ variables::ShellValueLiteral::Scalar(full_cmd),
+ |_| Ok(()),
+ env::EnvironmentLookup::Anywhere,
+ env::EnvironmentScope::Global,
+ )?;
+
+ context.shell.traps.handler_depth += 1;
+
+ // TODO: Discard result?
+ let _ = context
+ .shell
+ .run_string(debug_trap_handler, &handler_params)
+ .await;
+
+ context.shell.traps.handler_depth -= 1;
+ }
+ }
+
+ Ok(())
+}
+
+/// Executes a simple command.
+///
+/// The command may be a builtin, a shell function, or an externally
+/// executed command. This function's implementation is responsible for
+/// dispatching it appropriately according to the context provided.
+///
+/// # Arguments
+///
+/// * `cmd_context` - The context in which the command is being executed.
+/// * `process_group_id` - The process group ID to use for externally
+/// executed commands. This may be modified if a new process group is
+/// created.
+/// * `args` - The arguments to the command.
+/// * `use_functions` - If true, the command name will be checked against
+/// shell functions; if not, shell functions will not be consulted.
+/// * `path_dirs` - If provided, these directories will be searched for
+/// external commands; if not provided, the default search logic will
+/// be used.
+pub async fn execute(
+ cmd_context: ExecutionContext<'_>,
+ process_group_id: &mut Option,
+ args: Vec,
+ use_functions: bool,
+ path_dirs: Option>,
+) -> Result {
+ // First see if it's the name of a builtin.
+ let builtin = cmd_context
+ .shell
+ .builtins()
+ .get(&cmd_context.command_name)
+ .cloned();
+
+ // If we found a special builtin (that's not disabled), then invoke it.
+ if builtin
+ .as_ref()
+ .is_some_and(|r| !r.disabled && r.special_builtin)
+ {
+ return execute_builtin_command(&builtin.unwrap(), cmd_context, args).await;
+ }
+
+ // Assuming we weren't requested not to do so, check if it's the name of
+ // a shell function.
+ if use_functions {
+ if let Some(func_reg) = cmd_context
+ .shell
+ .funcs()
+ .get(cmd_context.command_name.as_str())
+ {
+ // Strip the function name off args.
+ return invoke_shell_function(func_reg.definition.clone(), cmd_context, &args[1..])
+ .await;
+ }
+ }
+
+ // If we found a (non-special) builtin and it's not disabled, then invoke it.
+ if let Some(builtin) = builtin {
+ if !builtin.disabled {
+ return execute_builtin_command(&builtin, cmd_context, args).await;
+ }
+ }
+
+ // We still haven't found a command to invoke. We'll need to look for an external command.
+ if !cmd_context.command_name.contains(std::path::MAIN_SEPARATOR) {
+ // All else failed; if we were given path directories to search, try to look through them
+ // for a matching executable. Otherwise, use our default search logic.
+ let path = if let Some(path_dirs) = path_dirs {
+ pathsearch::search_for_executable(
+ path_dirs.iter().map(String::as_str),
+ cmd_context.command_name.as_str(),
+ )
+ .next()
+ } else {
+ cmd_context
+ .shell
+ .find_first_executable_in_path_using_cache(&cmd_context.command_name)
+ };
+
+ if let Some(path) = path {
+ let resolved_path = path.to_string_lossy();
+ execute_external_command(
+ cmd_context,
+ resolved_path.as_ref(),
+ process_group_id,
+ &args[1..],
+ )
+ } else {
+ Err(ErrorKind::CommandNotFound(cmd_context.command_name).into())
+ }
+ } else {
+ let resolved_path = cmd_context.command_name.clone();
+
+ // Strip the command name off args.
+ execute_external_command(
+ cmd_context,
+ resolved_path.as_str(),
+ process_group_id,
+ &args[1..],
+ )
+ }
+}
+
+pub(crate) fn execute_external_command(
+ context: ExecutionContext<'_>,
+ executable_path: &str,
+ process_group_id: &mut Option,
+ args: &[CommandArg],
+) -> Result {
+ // Filter out the args; we only want strings.
+ let mut cmd_args = vec![];
+ for arg in args {
+ if let CommandArg::String(s) = arg {
+ cmd_args.push(s);
+ }
+ }
+
+ // Before we lose ownership of the open files, figure out if stdin will be a terminal.
+ let child_stdin_is_terminal = context
+ .try_fd(openfiles::OpenFiles::STDIN_FD)
+ .is_some_and(|f| f.is_term());
+
+ // Figure out if we should be setting up a new process group.
+ let new_pg = context.should_cmd_lead_own_process_group();
+
+ // Compose the std::process::Command that encapsulates what we want to launch.
+ #[allow(unused_mut, reason = "only mutated on unix platforms")]
+ let mut cmd = compose_std_command(
+ &context,
+ executable_path,
+ context.command_name.as_str(),
+ cmd_args.as_slice(),
+ false, /* empty environment? */
+ )?;
+
+ // Set up process group state.
+ if new_pg {
+ // We need to set up a new process group.
+ cmd.process_group(0);
+ } else {
+ // We need to join an established process group.
+ if let Some(pgid) = process_group_id {
+ cmd.process_group(*pgid);
+ }
+ }
+
+ // If we're to lead our own process group and stdin is a terminal,
+ // then we need to arrange for the new process to move itself
+ // to the foreground.
+ if new_pg && child_stdin_is_terminal {
+ cmd.take_foreground();
+ }
+
+ // When tracing is enabled, report.
+ tracing::debug!(
+ target: trace_categories::COMMANDS,
+ "Spawning: cmd='{} {}'",
+ cmd.get_program().to_string_lossy().to_string(),
+ cmd.get_args()
+ .map(|a| a.to_string_lossy().to_string())
+ .join(" ")
+ );
+
+ match sys::process::spawn(cmd) {
+ Ok(child) => {
+ // Retrieve the pid.
+ #[expect(clippy::cast_possible_wrap)]
+ let pid = child.id().map(|id| id as i32);
+ if let Some(pid) = &pid {
+ if new_pg {
+ *process_group_id = Some(*pid);
+ }
+ } else {
+ tracing::warn!("could not retrieve pid for child process");
+ }
+
+ Ok(ExecutionSpawnResult::StartedProcess(
+ processes::ChildProcess::new(pid, child),
+ ))
+ }
+ Err(spawn_err) => {
+ if context.shell.options.interactive {
+ sys::terminal::move_self_to_foreground()?;
+ }
+
+ if spawn_err.kind() == std::io::ErrorKind::NotFound {
+ if !context.shell.working_dir().exists() {
+ Err(
+ error::ErrorKind::WorkingDirMissing(context.shell.working_dir().to_owned())
+ .into(),
+ )
+ } else {
+ Err(error::ErrorKind::CommandNotFound(context.command_name).into())
+ }
+ } else {
+ Err(
+ error::ErrorKind::FailedToExecuteCommand(context.command_name, spawn_err)
+ .into(),
+ )
+ }
+ }
+ }
+}
+
+async fn execute_builtin_command(
+ builtin: &builtins::Registration,
+ context: ExecutionContext<'_>,
+ args: Vec,
+) -> Result {
+ let result = (builtin.execute_func)(context, args).await?;
+ Ok(result.into())
+}
+
+pub(crate) async fn invoke_shell_function(
+ function_definition: Arc,
+ mut context: ExecutionContext<'_>,
+ args: &[CommandArg],
+) -> Result {
+ let ast::FunctionBody(body, redirects) = &function_definition.body;
+
+ // Apply any redirects specified at function definition-time.
+ if let Some(redirects) = redirects {
+ for redirect in &redirects.0 {
+ interp::setup_redirect(context.shell, &mut context.params, redirect).await?;
+ }
+ }
+
+ // Temporarily replace positional parameters.
+ let prior_positional_params = std::mem::take(&mut context.shell.positional_parameters);
+ context.shell.positional_parameters = args.iter().map(|a| a.to_string()).collect();
+
+ // Pass through open files.
+ let params = context.params.clone();
+
+ // Note that we're going deeper. Once we do this, we need to make sure we don't bail early
+ // before "exiting" the function.
+ context
+ .shell
+ .enter_function(context.command_name.as_str(), &function_definition)?;
+
+ // Invoke the function.
+ let result = body.execute(context.shell, ¶ms).await;
+
+ // Clean up parameters so any owned files are closed.
+ drop(params);
+
+ // We've come back out, reflect it.
+ context.shell.leave_function()?;
+
+ // Restore positional parameters.
+ context.shell.positional_parameters = prior_positional_params;
+
+ // Get the actual execution result from the body of the function.
+ let mut result = result?;
+
+ // Handle control-flow.
+ match result.next_control_flow {
+ ExecutionControlFlow::BreakLoop { .. } | ExecutionControlFlow::ContinueLoop { .. } => {
+ return error::unimp("break or continue returned from function invocation");
+ }
+ ExecutionControlFlow::ReturnFromFunctionOrScript => {
+ // It's now been handled.
+ result.next_control_flow = ExecutionControlFlow::Normal;
+ }
+ _ => {}
+ }
+
+ Ok(result.into())
+}
+
+pub(crate) async fn invoke_command_in_subshell_and_get_output(
+ shell: &mut Shell,
+ params: &ExecutionParameters,
+ s: String,
+) -> Result {
+ // Instantiate a subshell to run the command in.
+ let subshell = shell.clone();
+
+ // Get our own set of parameters we can customize and use.
+ let mut params = params.clone();
+ params.process_group_policy = ProcessGroupPolicy::SameProcessGroup;
+
+ // Set up pipe so we can read the output.
+ let (reader, writer) = std::io::pipe()?;
+ params.set_fd(OpenFiles::STDOUT_FD, writer.into());
+
+ // Start the execution of the command, but don't wait for it to
+ // complete. In case the command generates lots of output, we
+ // need to start reading in parallel so the command doesn't block
+ // when the pipe's buffer fills up. We pass ownership of the
+ // subshell and params to run_substitution_command; we must
+ // ensure that they're both dropped by the time this call
+ // returns (so they're not holding onto the write end of the pipe).
+ let cmd_join_handle = tokio::task::spawn_blocking(move || {
+ let rt = tokio::runtime::Handle::current();
+ rt.block_on(run_substitution_command(subshell, params, s))
+ });
+
+ // Extract output.
+ let output_str = std::io::read_to_string(reader)?;
+
+ // Now observe the command's completion.
+ let run_result = cmd_join_handle.await?;
+ let cmd_result = run_result?;
+
+ // Store the status.
+ *shell.last_exit_status_mut() = cmd_result.exit_code.into();
+
+ Ok(output_str)
+}
+
+async fn run_substitution_command(
+ mut shell: Shell,
+ mut params: ExecutionParameters,
+ command: String,
+) -> Result {
+ // Parse the string into a whole shell program.
+ let parse_result = shell.parse_string(command);
+
+ // Check for a command that is only an input redirection ("< file").
+ // If detected, emulate `cat file` to stdout and return immediately.
+ // If we failed to parse, then we'll fall below and handle it there.
+ if let Ok(program) = &parse_result {
+ if let Some(redir) = try_unwrap_bare_input_redir_program(program) {
+ interp::setup_redirect(&mut shell, &mut params, redir).await?;
+ std::io::copy(&mut params.stdin(&shell), &mut params.stdout(&shell))?;
+ return Ok(ExecutionResult::new(0));
+ }
+ }
+
+ let source_info = brush_parser::SourceInfo {
+ source: String::from("main"),
+ };
+
+ // Handle the parse result using default shell behavior.
+ shell
+ .run_parsed_result(parse_result, &source_info, ¶ms)
+ .await
+}
+
+// Detects a subshell command that consists solely of a single input redirection
+// (e.g., "< file"), returning the IoRedirect when present.
+fn try_unwrap_bare_input_redir_program(program: &ast::Program) -> Option<&ast::IoRedirect> {
+ // We're looking for exactly one complete command...
+ let [complete] = program.complete_commands.as_slice() else {
+ return None;
+ };
+
+ // ...a single list item...
+ let ast::CompoundList(items) = complete;
+ let [item] = items.as_slice() else {
+ return None;
+ };
+
+ // ...with a single pipeline (no && or || chaining)...
+ let and_or = &item.0;
+ if !and_or.additional.is_empty() {
+ return None;
+ }
+
+ // ...not negated...
+ let pipeline = &and_or.first;
+ if pipeline.bang {
+ return None;
+ }
+
+ // ...with a single command in the pipeline...
+ let [ast::Command::Simple(simple_cmd)] = pipeline.seq.as_slice() else {
+ return None;
+ };
+
+ // ...with no program word/name and no suffix...
+ if simple_cmd.word_or_name.is_some() || simple_cmd.suffix.is_some() {
+ return None;
+ }
+
+ // ...and exactly one prefix containing an I/O redirect...
+ let prefix = simple_cmd.prefix.as_ref()?;
+ let [ast::CommandPrefixOrSuffixItem::IoRedirect(redir)] = prefix.0.as_slice() else {
+ return None;
+ };
+
+ // ...that is a file input redirection to a filename, targeting stdin.
+ match redir {
+ ast::IoRedirect::File(
+ fd,
+ ast::IoFileRedirectKind::Read,
+ ast::IoFileRedirectTarget::Filename(..),
+ ) if fd.is_none_or(|fd| fd == openfiles::OpenFiles::STDIN_FD) => Some(redir),
+ _ => None,
+ }
+}
diff --git a/crates/brush-core-vendored/src/completion.rs b/crates/brush-core-vendored/src/completion.rs
new file mode 100644
index 000000000..7ccd11ec6
--- /dev/null
+++ b/crates/brush-core-vendored/src/completion.rs
@@ -0,0 +1,1254 @@
+//! Implements programmable command completion support.
+
+use clap::ValueEnum;
+use indexmap::IndexSet;
+use std::{
+ borrow::Cow,
+ collections::HashMap,
+ path::{Path, PathBuf},
+};
+
+use crate::{
+ Shell, commands, env, error, escape, jobs, namedoptions, patterns,
+ sys::{self, users},
+ trace_categories, traps,
+ variables::{self, ShellValueLiteral},
+};
+
+/// Type of action to take to generate completion candidates.
+#[derive(Clone, Debug, ValueEnum)]
+pub enum CompleteAction {
+ /// Complete with valid aliases.
+ #[clap(name = "alias")]
+ Alias,
+ /// Complete with names of array shell variables.
+ #[clap(name = "arrayvar")]
+ ArrayVar,
+ /// Complete with names of key bindings.
+ #[clap(name = "binding")]
+ Binding,
+ /// Complete with names of shell builtins.
+ #[clap(name = "builtin")]
+ Builtin,
+ /// Complete with names of executable commands.
+ #[clap(name = "command")]
+ Command,
+ /// Complete with directory names.
+ #[clap(name = "directory")]
+ Directory,
+ /// Complete with names of disabled shell builtins.
+ #[clap(name = "disabled")]
+ Disabled,
+ /// Complete with names of enabled shell builtins.
+ #[clap(name = "enabled")]
+ Enabled,
+ /// Complete with names of exported shell variables.
+ #[clap(name = "export")]
+ Export,
+ /// Complete with filenames.
+ #[clap(name = "file")]
+ File,
+ /// Complete with names of shell functions.
+ #[clap(name = "function")]
+ Function,
+ /// Complete with valid user groups.
+ #[clap(name = "group")]
+ Group,
+ /// Complete with names of valid shell help topics.
+ #[clap(name = "helptopic")]
+ HelpTopic,
+ /// Complete with the system's hostname(s).
+ #[clap(name = "hostname")]
+ HostName,
+ /// Complete with the command names of shell-managed jobs.
+ #[clap(name = "job")]
+ Job,
+ /// Complete with valid shell keywords.
+ #[clap(name = "keyword")]
+ Keyword,
+ /// Complete with the command names of running shell-managed jobs.
+ #[clap(name = "running")]
+ Running,
+ /// Complete with names of system services.
+ #[clap(name = "service")]
+ Service,
+ /// Complete with the names of options settable via shopt.
+ #[clap(name = "setopt")]
+ SetOpt,
+ /// Complete with the names of options settable via set -o.
+ #[clap(name = "shopt")]
+ ShOpt,
+ /// Complete with the names of trappable signals.
+ #[clap(name = "signal")]
+ Signal,
+ /// Complete with the command names of stopped shell-managed jobs.
+ #[clap(name = "stopped")]
+ Stopped,
+ /// Complete with valid usernames.
+ #[clap(name = "user")]
+ User,
+ /// Complete with names of shell variables.
+ #[clap(name = "variable")]
+ Variable,
+}
+
+/// Options influencing how command completions are generated.
+#[derive(Clone, Debug, Eq, Hash, PartialEq, ValueEnum)]
+pub enum CompleteOption {
+ /// Perform rest of default completions if no completions are generated.
+ #[clap(name = "bashdefault")]
+ BashDefault,
+ /// Use default filename completion if no completions are generated.
+ #[clap(name = "default")]
+ Default,
+ /// Treat completions as directory names.
+ #[clap(name = "dirnames")]
+ DirNames,
+ /// Treat completions as filenames.
+ #[clap(name = "filenames")]
+ FileNames,
+ /// Suppress default auto-quotation of completions.
+ #[clap(name = "noquote")]
+ NoQuote,
+ /// Do not sort completions.
+ #[clap(name = "nosort")]
+ NoSort,
+ /// Do not append a trailing space to completions at the end of the input line.
+ #[clap(name = "nospace")]
+ NoSpace,
+ /// Also generate directory completions.
+ #[clap(name = "plusdirs")]
+ PlusDirs,
+}
+
+/// Encapsulates the shell's programmable command completion configuration.
+#[derive(Clone, Default)]
+pub struct Config {
+ commands: HashMap,
+
+ /// Optionally, a completion spec to be used as a default, when earlier
+ /// matches yield no candidates.
+ pub default: Option,
+ /// Optionally, a completion spec to be used when the command line is empty.
+ pub empty_line: Option,
+ /// Optionally, a completion spec to be used for the initial word of a command line.
+ pub initial_word: Option,
+
+ /// Optionally, stores the current completion options in effect. May be mutated
+ /// while a completion generation is in-flight.
+ pub current_completion_options: Option,
+}
+
+/// Options for generating completions.
+#[derive(Clone, Debug, Default)]
+pub struct GenerationOptions {
+ //
+ // Options
+ /// Perform rest of default completions if no completions are generated.
+ pub bash_default: bool,
+ /// Use default readline-style filename completion if no completions are generated.
+ pub default: bool,
+ /// Treat completions as directory names.
+ pub dir_names: bool,
+ /// Treat completions as filenames.
+ pub file_names: bool,
+ /// Do not add usual quoting for completions.
+ pub no_quote: bool,
+ /// Do not sort completions.
+ pub no_sort: bool,
+ /// Do not append typical space to a completion at the end of the input line.
+ pub no_space: bool,
+ /// Also complete with directory names.
+ pub plus_dirs: bool,
+}
+
+/// Encapsulates a command completion specification; provides policy for how to
+/// generate completions for a given input.
+#[derive(Clone, Debug, Default)]
+pub struct Spec {
+ //
+ // Options
+ /// Options to use for completion.
+ pub options: GenerationOptions,
+
+ //
+ // Generators
+ /// Actions to take to generate completions.
+ pub actions: Vec,
+ /// Optionally, a glob pattern whose expansion will be used as completions.
+ pub glob_pattern: Option,
+ /// Optionally, a list of words to use as completions.
+ pub word_list: Option,
+ /// Optionally, the name of a shell function to invoke to generate completions.
+ pub function_name: Option,
+ /// Optionally, the name of a command to execute to generate completions.
+ pub command: Option,
+
+ //
+ // Filters
+ /// Optionally, a pattern to filter completions.
+ pub filter_pattern: Option,
+ /// If true, completion candidates matching `filter_pattern` are removed;
+ /// otherwise, those not matching it are removed.
+ pub filter_pattern_excludes: bool,
+
+ //
+ // Transformers
+ /// Optionally, provides a prefix to be prepended to all completion candidates.
+ pub prefix: Option,
+ /// Optionally, provides a suffix to be prepended to all completion candidates.
+ pub suffix: Option,
+}
+
+/// Encapsulates context used during completion generation.
+#[derive(Debug)]
+pub struct Context<'a> {
+ /// The token to complete.
+ pub token_to_complete: &'a str,
+
+ /// If available, the name of the command being invoked.
+ pub command_name: Option<&'a str>,
+ /// If there was one, the token preceding the one being completed.
+ pub preceding_token: Option<&'a str>,
+
+ /// The 0-based index of the token to complete.
+ pub token_index: usize,
+
+ /// The input line.
+ pub input_line: &'a str,
+ /// The 0-based index of the cursor in the input line.
+ pub cursor_index: usize,
+ /// The tokens in the input line.
+ pub tokens: &'a [&'a brush_parser::Token],
+}
+
+impl Spec {
+ /// Generates completion candidates using this specification.
+ ///
+ /// # Arguments
+ ///
+ /// * `shell` - The shell instance to use for completion generation.
+ /// * `context` - The context in which completion is being generated.
+ #[expect(clippy::too_many_lines)]
+ pub async fn get_completions(
+ &self,
+ shell: &mut Shell,
+ context: &Context<'_>,
+ ) -> Result {
+ // Store the current options in the shell; this is needed since the compopt
+ // built-in has the ability of modifying the options for an in-flight
+ // completion process.
+ shell.completion_config.current_completion_options = Some(self.options.clone());
+
+ // Generate completions based on any provided actions (and on words).
+ let mut candidates = self.generate_action_completions(shell, context).await?;
+ if let Some(word_list) = &self.word_list {
+ let params = shell.default_exec_params();
+ let words =
+ crate::expansion::full_expand_and_split_str(shell, ¶ms, word_list).await?;
+ for word in words {
+ if word.starts_with(context.token_to_complete) {
+ candidates.insert(word);
+ }
+ }
+ }
+
+ if let Some(glob_pattern) = &self.glob_pattern {
+ let pattern = patterns::Pattern::from(glob_pattern.as_str())
+ .set_extended_globbing(shell.options.extended_globbing)
+ .set_case_insensitive(shell.options.case_insensitive_pathname_expansion);
+
+ let expansions = pattern.expand(
+ shell.working_dir(),
+ Some(&patterns::Pattern::accept_all_expand_filter),
+ &patterns::FilenameExpansionOptions::default(),
+ )?;
+
+ for expansion in expansions {
+ candidates.insert(expansion);
+ }
+ }
+ if let Some(function_name) = &self.function_name {
+ let call_result = self
+ .call_completion_function(shell, function_name.as_str(), context)
+ .await?;
+
+ match call_result {
+ Answer::RestartCompletionProcess => return Ok(call_result),
+ Answer::Candidates(mut new_candidates, _options) => {
+ candidates.append(&mut new_candidates);
+ }
+ }
+ }
+ if let Some(command) = &self.command {
+ let mut new_candidates = self
+ .call_completion_command(shell, command.as_str(), context)
+ .await?;
+ candidates.append(&mut new_candidates);
+ }
+
+ // Apply filter pattern, if present. Anything the filter selects gets removed.
+ if let Some(filter_pattern) = &self.filter_pattern {
+ if !filter_pattern.is_empty() {
+ let mut updated = IndexSet::new();
+
+ for candidate in candidates {
+ let matches = completion_filter_pattern_matches(
+ filter_pattern.as_str(),
+ candidate.as_str(),
+ context.token_to_complete,
+ shell,
+ )?;
+
+ if self.filter_pattern_excludes != matches {
+ updated.insert(candidate);
+ }
+ }
+
+ candidates = updated;
+ }
+ }
+
+ // Add prefix and/or suffix, if present.
+ if self.prefix.is_some() || self.suffix.is_some() {
+ let empty = String::new();
+ let prefix = self.prefix.as_ref().unwrap_or(&empty);
+ let suffix = self.suffix.as_ref().unwrap_or(&empty);
+
+ let mut updated = IndexSet::new();
+ for candidate in candidates {
+ updated.insert(std::format!("{prefix}{candidate}{suffix}"));
+ }
+
+ candidates = updated;
+ }
+
+ //
+ // Now apply options
+ //
+
+ let options = if let Some(options) = &shell.completion_config.current_completion_options {
+ options
+ } else {
+ &self.options
+ };
+
+ let processing_options = ProcessingOptions {
+ treat_as_filenames: options.file_names,
+ no_autoquote_filenames: options.no_quote,
+ no_trailing_space_at_end_of_line: options.no_space,
+ };
+
+ if options.plus_dirs {
+ // Also add dir name completion.
+ let mut dir_candidates = get_file_completions(
+ shell,
+ context.token_to_complete,
+ /* must_be_dir */ true,
+ )
+ .await;
+ candidates.append(&mut dir_candidates);
+ }
+
+ // If we still haven't found any completion candidates by now, then consider whether any
+ // requests were made for fallbacks.
+ if candidates.is_empty() {
+ if options.bash_default {
+ //
+ // TODO: if we have no completions, then fall back to default "bash" completions.
+ // It's not clear what exactly this means, though. From basic testing, it doesn't
+ // seem to include basic file and directory name completion.
+ //
+ tracing::debug!(target: trace_categories::COMPLETION, "unimplemented: complete -o bashdefault");
+ }
+ if options.default || options.dir_names {
+ // N.B. We approximate "default" readline completion behavior by getting file and
+ // dir completions.
+ let must_be_dir = options.dir_names;
+
+ let mut default_candidates =
+ get_file_completions(shell, context.token_to_complete, must_be_dir).await;
+ candidates.append(&mut default_candidates);
+ }
+ }
+
+ // Sort, unless blocked by options.
+ if !self.options.no_sort {
+ candidates.sort();
+ }
+
+ Ok(Answer::Candidates(candidates, processing_options))
+ }
+
+ #[expect(clippy::too_many_lines)]
+ async fn generate_action_completions(
+ &self,
+ shell: &Shell,
+ context: &Context<'_>,
+ ) -> Result, error::Error> {
+ let mut candidates = IndexSet::new();
+
+ let token = context.token_to_complete;
+
+ for action in &self.actions {
+ match action {
+ CompleteAction::Alias => {
+ for name in shell.aliases.keys() {
+ if name.starts_with(token) {
+ candidates.insert(name.clone());
+ }
+ }
+ }
+ CompleteAction::ArrayVar => {
+ for (name, var) in shell.env.iter() {
+ if var.value().is_array() && name.starts_with(token) {
+ candidates.insert(name.to_owned());
+ }
+ }
+ }
+ CompleteAction::Binding => {
+ tracing::debug!(target: trace_categories::COMPLETION, "unimplemented: complete -A binding");
+ }
+ CompleteAction::Builtin => {
+ for name in shell.builtins().keys() {
+ if name.starts_with(token) {
+ candidates.insert(name.to_owned());
+ }
+ }
+ }
+ CompleteAction::Command => {
+ let mut command_completions = get_command_completions(shell, context);
+ candidates.append(&mut command_completions);
+ }
+ CompleteAction::Directory => {
+ let mut file_completions =
+ get_file_completions(shell, context.token_to_complete, true).await;
+ candidates.append(&mut file_completions);
+ }
+ CompleteAction::Disabled => {
+ for (name, registration) in shell.builtins() {
+ if registration.disabled && name.starts_with(token) {
+ candidates.insert(name.to_owned());
+ }
+ }
+ }
+ CompleteAction::Enabled => {
+ for (name, registration) in shell.builtins() {
+ if !registration.disabled && name.starts_with(token) {
+ candidates.insert(name.to_owned());
+ }
+ }
+ }
+ CompleteAction::Export => {
+ for (key, value) in shell.env.iter() {
+ if value.is_exported() && key.starts_with(token) {
+ candidates.insert(key.to_owned());
+ }
+ }
+ }
+ CompleteAction::File => {
+ let mut file_completions =
+ get_file_completions(shell, context.token_to_complete, false).await;
+ candidates.append(&mut file_completions);
+ }
+ CompleteAction::Function => {
+ for (name, _) in shell.funcs().iter() {
+ candidates.insert(name.to_owned());
+ }
+ }
+ CompleteAction::Group => {
+ for group_name in users::get_all_groups()? {
+ if group_name.starts_with(token) {
+ candidates.insert(group_name);
+ }
+ }
+ }
+ CompleteAction::HelpTopic => {
+ // For now, we only have help topics for built-in commands.
+ for name in shell.builtins().keys() {
+ if name.starts_with(token) {
+ candidates.insert(name.to_owned());
+ }
+ }
+ }
+ CompleteAction::HostName => {
+ // N.B. We only retrieve one hostname.
+ if let Ok(name) = sys::network::get_hostname() {
+ let name = name.to_string_lossy();
+ if name.starts_with(token) {
+ candidates.insert(name.to_string());
+ }
+ }
+ }
+ CompleteAction::Job => {
+ for job in &shell.jobs.jobs {
+ let command_name = job.command_name();
+ if command_name.starts_with(token) {
+ candidates.insert(command_name.to_owned());
+ }
+ }
+ }
+ CompleteAction::Keyword => {
+ for keyword in shell.get_keywords() {
+ if keyword.starts_with(token) {
+ candidates.insert(keyword.clone());
+ }
+ }
+ }
+ CompleteAction::Running => {
+ for job in &shell.jobs.jobs {
+ if matches!(job.state, jobs::JobState::Running) {
+ let command_name = job.command_name();
+ if command_name.starts_with(token) {
+ candidates.insert(command_name.to_owned());
+ }
+ }
+ }
+ }
+ CompleteAction::Service => {
+ tracing::debug!(target: trace_categories::COMPLETION, "unimplemented: complete -A service");
+ }
+ CompleteAction::SetOpt => {
+ for option in namedoptions::options(namedoptions::ShellOptionKind::SetO).iter()
+ {
+ if option.name.starts_with(token) {
+ candidates.insert(option.name.to_owned());
+ }
+ }
+ }
+ CompleteAction::ShOpt => {
+ for option in namedoptions::options(namedoptions::ShellOptionKind::Shopt).iter()
+ {
+ if option.name.starts_with(token) {
+ candidates.insert(option.name.to_owned());
+ }
+ }
+ }
+ CompleteAction::Signal => {
+ for signal in traps::TrapSignal::iterator() {
+ if signal.as_str().starts_with(token) {
+ candidates.insert(signal.as_str().to_string());
+ }
+ }
+ }
+ CompleteAction::Stopped => {
+ for job in &shell.jobs.jobs {
+ if matches!(job.state, jobs::JobState::Stopped) {
+ let command_name = job.command_name();
+ if command_name.starts_with(token) {
+ candidates.insert(job.command_name().to_owned());
+ }
+ }
+ }
+ }
+ CompleteAction::User => {
+ for user_name in users::get_all_users()? {
+ if user_name.starts_with(token) {
+ candidates.insert(user_name);
+ }
+ }
+ }
+ CompleteAction::Variable => {
+ for (key, _) in shell.env.iter() {
+ if key.starts_with(token) {
+ candidates.insert(key.to_owned());
+ }
+ }
+ }
+ }
+ }
+
+ Ok(candidates)
+ }
+
+ async fn call_completion_command(
+ &self,
+ shell: &Shell,
+ command_name: &str,
+ context: &Context<'_>,
+ ) -> Result, error::Error> {
+ // Move to a subshell so we can start filling out variables.
+ let mut shell = shell.clone();
+
+ let vars_and_values: Vec<(&str, ShellValueLiteral)> = vec![
+ ("COMP_LINE", context.input_line.into()),
+ ("COMP_POINT", context.cursor_index.to_string().into()),
+ // TODO: add COMP_KEY
+ // TODO: add COMP_TYPE
+ ];
+
+ // Fill out variables.
+ for (var, value) in vars_and_values {
+ shell.env.update_or_add(
+ var,
+ value,
+ |v| {
+ v.export();
+ Ok(())
+ },
+ env::EnvironmentLookup::Anywhere,
+ env::EnvironmentScope::Global,
+ )?;
+ }
+
+ // Compute args.
+ let mut args = vec![
+ context.command_name.unwrap_or(""),
+ context.token_to_complete,
+ ];
+ if let Some(preceding_token) = context.preceding_token {
+ args.push(preceding_token);
+ }
+
+ // Compose the full command line.
+ let mut command_line = command_name.to_owned();
+ for arg in args {
+ command_line.push(' ');
+
+ let escaped_arg = escape::quote_if_needed(arg, escape::QuoteMode::SingleQuote);
+ command_line.push_str(escaped_arg.as_ref());
+ }
+
+ // Run the command.
+ let params = shell.default_exec_params();
+ let output =
+ commands::invoke_command_in_subshell_and_get_output(&mut shell, ¶ms, command_line)
+ .await?;
+
+ // Split results.
+ let mut candidates = IndexSet::new();
+ for line in output.lines() {
+ candidates.insert(line.to_owned());
+ }
+
+ Ok(candidates)
+ }
+
+ async fn call_completion_function(
+ &self,
+ shell: &mut Shell,
+ function_name: &str,
+ context: &Context<'_>,
+ ) -> Result {
+ // TODO: Don't pollute the persistent environment with these?
+ let vars_and_values: Vec<(&str, ShellValueLiteral)> = vec![
+ ("COMP_LINE", context.input_line.into()),
+ ("COMP_POINT", context.cursor_index.to_string().into()),
+ // TODO: add COMP_KEY
+ // TODO: add COMP_TYPE
+ (
+ "COMP_WORDS",
+ context
+ .tokens
+ .iter()
+ .map(|t| t.to_str())
+ .collect::>()
+ .into(),
+ ),
+ ("COMP_CWORD", context.token_index.to_string().into()),
+ ];
+
+ tracing::debug!(target: trace_categories::COMPLETION, "[calling completion func '{function_name}']: {}",
+ vars_and_values.iter().map(|(k, v)| std::format!("{k}={v}")).collect::>().join(" "));
+
+ let mut vars_to_remove = vec![];
+ for (var, value) in vars_and_values {
+ shell.env.update_or_add(
+ var,
+ value,
+ |_| Ok(()),
+ env::EnvironmentLookup::Anywhere,
+ env::EnvironmentScope::Global,
+ )?;
+
+ vars_to_remove.push(var);
+ }
+
+ let mut args = vec![
+ context.command_name.unwrap_or(""),
+ context.token_to_complete,
+ ];
+ if let Some(preceding_token) = context.preceding_token {
+ args.push(preceding_token);
+ }
+
+ // TODO: Find a more appropriate interlock here. For now we use the existing
+ // handler depth count to suppress any debug traps.
+ shell.traps.handler_depth += 1;
+
+ let params = shell.default_exec_params();
+ let invoke_result = shell
+ .invoke_function(function_name, args.iter(), ¶ms)
+ .await;
+ tracing::debug!(target: trace_categories::COMPLETION, "[completion function '{function_name}' returned: {invoke_result:?}]");
+
+ shell.traps.handler_depth -= 1;
+
+ // Make a best-effort attempt to unset the temporary variables.
+ for var_name in vars_to_remove {
+ let _ = shell.env.unset(var_name);
+ }
+
+ let result = invoke_result.unwrap_or_else(|e| {
+ tracing::warn!(target: trace_categories::COMPLETION, "error while running completion function '{function_name}': {e}");
+ 1 // Report back a non-zero exit code.
+ });
+
+ // When the function returns the special value 124, then it's a request
+ // for us to restart the completion process.
+ if result == 124 {
+ Ok(Answer::RestartCompletionProcess)
+ } else {
+ if let Some(reply) = shell.env.unset("COMPREPLY")? {
+ tracing::debug!(target: trace_categories::COMPLETION, "[completion function yielded: {reply:?}]");
+
+ match reply.value() {
+ variables::ShellValue::IndexedArray(values) => {
+ return Ok(Answer::Candidates(
+ values.values().map(|v| v.to_owned()).collect(),
+ ProcessingOptions::default(),
+ ));
+ }
+ variables::ShellValue::String(s) => {
+ let mut candidates = IndexSet::new();
+ candidates.insert(s.to_owned());
+
+ return Ok(Answer::Candidates(candidates, ProcessingOptions::default()));
+ }
+ _ => (),
+ }
+ }
+
+ Ok(Answer::Candidates(
+ IndexSet::new(),
+ ProcessingOptions::default(),
+ ))
+ }
+ }
+}
+
+/// Represents a set of generated command completions.
+#[derive(Debug, Default)]
+pub struct Completions {
+ /// The index in the input line where the completions should be inserted. Represented
+ /// as a byte offset into the input line; must be at a clean character boundary.
+ pub insertion_index: usize,
+ /// The number of elements in the input line that should be removed before insertion.
+ /// Represented as a byte count; must capture an exact character boundary.
+ pub delete_count: usize,
+ /// The ordered set of completions.
+ pub candidates: IndexSet,
+ /// Options for processing the candidates.
+ pub options: ProcessingOptions,
+}
+
+/// Options governing how command completion candidates are processed after being generated.
+#[derive(Debug)]
+pub struct ProcessingOptions {
+ /// Treat completions as file names.
+ pub treat_as_filenames: bool,
+ /// Don't auto-quote completions that are file names.
+ pub no_autoquote_filenames: bool,
+ /// Don't append a trailing space to completions at the end of the input line.
+ pub no_trailing_space_at_end_of_line: bool,
+}
+
+impl Default for ProcessingOptions {
+ fn default() -> Self {
+ Self {
+ treat_as_filenames: true,
+ no_autoquote_filenames: false,
+ no_trailing_space_at_end_of_line: false,
+ }
+ }
+}
+
+/// Encapsulates a completion answer.
+pub enum Answer {
+ /// The completion process generated a set of candidates along with options
+ /// controlling how to process them.
+ Candidates(IndexSet, ProcessingOptions),
+ /// The completion process needs to be restarted.
+ RestartCompletionProcess,
+}
+
+const EMPTY_COMMAND: &str = "_EmptycmD_";
+const DEFAULT_COMMAND: &str = "_DefaultCmD_";
+const INITIAL_WORD: &str = "_InitialWorD_";
+
+impl Config {
+ /// Removes all registered completion specs.
+ pub fn clear(&mut self) {
+ self.commands.clear();
+ self.empty_line = None;
+ self.default = None;
+ self.initial_word = None;
+ }
+
+ /// Ensures the named completion spec is no longer registered; returns whether a
+ /// removal operation was required.
+ ///
+ /// # Arguments
+ ///
+ /// * `name` - The name of the completion spec to remove.
+ pub fn remove(&mut self, name: &str) -> bool {
+ match name {
+ EMPTY_COMMAND => {
+ let result = self.empty_line.is_some();
+ self.empty_line = None;
+ result
+ }
+ DEFAULT_COMMAND => {
+ let result = self.default.is_some();
+ self.default = None;
+ result
+ }
+ INITIAL_WORD => {
+ let result = self.initial_word.is_some();
+ self.initial_word = None;
+ result
+ }
+ _ => self.commands.remove(name).is_some(),
+ }
+ }
+
+ /// Returns an iterator over the completion specs.
+ pub fn iter(&self) -> impl Iterator- {
+ self.commands.iter()
+ }
+
+ /// If present, returns the completion spec for the command of the given name.
+ ///
+ /// # Arguments
+ ///
+ /// * `name` - The name of the command.
+ pub fn get(&self, name: &str) -> Option<&Spec> {
+ match name {
+ EMPTY_COMMAND => self.empty_line.as_ref(),
+ DEFAULT_COMMAND => self.default.as_ref(),
+ INITIAL_WORD => self.initial_word.as_ref(),
+ _ => self.commands.get(name),
+ }
+ }
+
+ /// If present, sets the provided completion spec to be associated with the
+ /// command of the given name.
+ ///
+ /// # Arguments
+ ///
+ /// * `name` - The name of the command.
+ /// * `spec` - The completion spec to associate with the command.
+ pub fn set(&mut self, name: &str, spec: Spec) {
+ match name {
+ EMPTY_COMMAND => {
+ self.empty_line = Some(spec);
+ }
+ DEFAULT_COMMAND => {
+ self.default = Some(spec);
+ }
+ INITIAL_WORD => {
+ self.initial_word = Some(spec);
+ }
+ _ => {
+ self.commands.insert(name.to_owned(), spec);
+ }
+ }
+ }
+
+ /// Returns a mutable reference to the completion spec for the command of the
+ /// given name; if the command already was associated with a spec, returns
+ /// a reference to that existing spec. Otherwise registers a new default
+ /// spec and returns a mutable reference to it.
+ ///
+ /// # Arguments
+ ///
+ /// * `name` - The name of the command.
+ pub fn get_or_add_mut(&mut self, name: &str) -> &mut Spec {
+ match name {
+ EMPTY_COMMAND => {
+ if self.empty_line.is_none() {
+ self.empty_line = Some(Spec::default());
+ }
+ self.empty_line.as_mut().unwrap()
+ }
+ DEFAULT_COMMAND => {
+ if self.default.is_none() {
+ self.default = Some(Spec::default());
+ }
+ self.default.as_mut().unwrap()
+ }
+ INITIAL_WORD => {
+ if self.initial_word.is_none() {
+ self.initial_word = Some(Spec::default());
+ }
+ self.initial_word.as_mut().unwrap()
+ }
+ _ => self.commands.entry(name.to_owned()).or_default(),
+ }
+ }
+
+ /// Generates completions for the given input line and cursor position.
+ ///
+ /// # Arguments
+ ///
+ /// * `shell` - The shell instance to use for completion generation.
+ /// * `input` - The input line for which completions are being generated.
+ /// * `position` - The 0-based index of the cursor in the input line.
+ #[expect(clippy::string_slice)]
+ pub async fn get_completions(
+ &self,
+ shell: &mut Shell,
+ input: &str,
+ position: usize,
+ ) -> Result
{
+ const MAX_RESTARTS: u32 = 10;
+
+ // Make a best-effort attempt to tokenize.
+ let tokens = Self::tokenize_input_for_completion(shell, input);
+
+ let cursor = position;
+ let mut preceding_token = None;
+ let mut completion_prefix = "";
+ let mut insertion_index = cursor;
+ let mut completion_token_index = tokens.len();
+
+ // Copy a set of references to the tokens; we will adjust this list as
+ // we find we need to insert an empty token.
+ let mut adjusted_tokens: Vec<&brush_parser::Token> = tokens.iter().collect();
+
+ // Try to find which token we are in.
+ for (i, token) in tokens.iter().enumerate() {
+ // If the cursor is before the start of the token, then it's between
+ // this token and the one that preceded it (or it's before the first
+ // token if this is the first token).
+ if cursor < token.location().start.index {
+ // TODO: Should insert an empty token here; the position looks to have
+ // been between this token and the preceding one.
+ completion_token_index = i;
+ break;
+ }
+ // If the cursor is anywhere from the first char of the token up to
+ // (and including) the first char after the token, then this we need
+ // to generate completions to replace/update this token. We'll pay
+ // attention to the position to figure out the prefix that we should
+ // be completing.
+ else if cursor >= token.location().start.index && cursor <= token.location().end.index
+ {
+ // Update insertion index.
+ insertion_index = token.location().start.index;
+
+ // Update prefix.
+ let offset_into_token = cursor - insertion_index;
+ let token_str = token.to_str();
+ completion_prefix = &token_str[..offset_into_token];
+
+ // Update token index.
+ completion_token_index = i;
+
+ break;
+ }
+
+ // Otherwise, we need to keep looking. Update what we think the
+ // preceding token may be.
+ preceding_token = Some(token);
+ }
+
+ // If the position is after the last token, then we need to insert an empty
+ // token for the new token to be generated.
+ let empty_token =
+ brush_parser::Token::Word(String::new(), brush_parser::TokenLocation::default());
+ if completion_token_index == tokens.len() {
+ adjusted_tokens.push(&empty_token);
+ }
+
+ // Get the completions.
+ let mut result = Answer::RestartCompletionProcess;
+ let mut restart_count = 0;
+ while matches!(result, Answer::RestartCompletionProcess) {
+ if restart_count > MAX_RESTARTS {
+ tracing::warn!("possible infinite loop detected in completion process");
+ break;
+ }
+
+ let completion_context = Context {
+ token_to_complete: completion_prefix,
+ preceding_token: preceding_token.map(|t| t.to_str()),
+ command_name: adjusted_tokens.first().map(|token| token.to_str()),
+ input_line: input,
+ token_index: completion_token_index,
+ tokens: adjusted_tokens.as_slice(),
+ cursor_index: position,
+ };
+
+ result = self
+ .get_completions_for_token(shell, completion_context)
+ .await;
+
+ restart_count += 1;
+ }
+
+ match result {
+ Answer::Candidates(candidates, options) => Ok(Completions {
+ insertion_index,
+ delete_count: completion_prefix.len(),
+ candidates,
+ options,
+ }),
+ Answer::RestartCompletionProcess => Ok(Completions {
+ insertion_index,
+ delete_count: 0,
+ candidates: IndexSet::new(),
+ options: ProcessingOptions::default(),
+ }),
+ }
+ }
+
+ fn tokenize_input_for_completion(shell: &Shell, input: &str) -> Vec {
+ const FALLBACK: &str = " \t\n\"\'@><=;|&(:";
+
+ let delimiter_str = shell
+ .env_str("COMP_WORDBREAKS")
+ .unwrap_or_else(|| FALLBACK.into());
+
+ let delimiters: Vec<_> = delimiter_str.chars().collect();
+
+ simple_tokenize_by_delimiters(input, delimiters.as_slice())
+ }
+
+ async fn get_completions_for_token(&self, shell: &mut Shell, context: Context<'_>) -> Answer {
+ // See if we can find a completion spec matching the current command.
+ let mut found_spec: Option<&Spec> = None;
+
+ if let Some(command_name) = context.command_name {
+ if context.token_index == 0 {
+ if let Some(spec) = &self.initial_word {
+ found_spec = Some(spec);
+ }
+ } else {
+ if let Some(spec) = shell.completion_config.commands.get(command_name) {
+ found_spec = Some(spec);
+ } else if let Some(file_name) = PathBuf::from(command_name).file_name() {
+ if let Some(spec) = shell
+ .completion_config
+ .commands
+ .get(&file_name.to_string_lossy().to_string())
+ {
+ found_spec = Some(spec);
+ }
+ }
+
+ if found_spec.is_none() {
+ if let Some(spec) = &self.default {
+ found_spec = Some(spec);
+ }
+ }
+ }
+ } else {
+ if let Some(spec) = &self.empty_line {
+ found_spec = Some(spec);
+ }
+ }
+
+ // Try to generate completions.
+ if let Some(spec) = found_spec {
+ spec.to_owned()
+ .get_completions(shell, &context)
+ .await
+ .unwrap_or_else(|_err| {
+ Answer::Candidates(IndexSet::new(), ProcessingOptions::default())
+ })
+ } else {
+ // If we didn't find a spec, then fall back to basic completion.
+ get_completions_using_basic_lookup(shell, &context).await
+ }
+ }
+}
+
+async fn get_file_completions(
+ shell: &Shell,
+ token_to_complete: &str,
+ must_be_dir: bool,
+) -> IndexSet {
+ // Basic-expand the token-to-be-completed; it won't have been expanded to this point.
+ let mut throwaway_shell = shell.clone();
+ let params = throwaway_shell.default_exec_params();
+ let expanded_token = throwaway_shell
+ .basic_expand_string(¶ms, token_to_complete)
+ .await
+ .unwrap_or_else(|_err| token_to_complete.to_owned());
+
+ let glob = std::format!("{expanded_token}*");
+
+ let path_filter = |path: &Path| !must_be_dir || shell.absolute_path(path).is_dir();
+
+ let pattern = patterns::Pattern::from(glob)
+ .set_extended_globbing(shell.options.extended_globbing)
+ .set_case_insensitive(shell.options.case_insensitive_pathname_expansion);
+
+ pattern
+ .expand(
+ shell.working_dir(),
+ Some(&path_filter),
+ &patterns::FilenameExpansionOptions::default(),
+ )
+ .unwrap_or_default()
+ .into_iter()
+ .collect()
+}
+
+fn get_command_completions(shell: &Shell, context: &Context<'_>) -> IndexSet {
+ let mut candidates = IndexSet::new();
+
+ // Look for external commands.
+ for path in shell.find_executables_in_path_with_prefix(
+ context.token_to_complete,
+ shell.options.case_insensitive_pathname_expansion,
+ ) {
+ if let Some(file_name) = path.file_name() {
+ candidates.insert(file_name.to_string_lossy().to_string());
+ }
+ }
+
+ candidates.into_iter().collect()
+}
+
+async fn get_completions_using_basic_lookup(shell: &Shell, context: &Context<'_>) -> Answer {
+ let mut candidates = get_file_completions(shell, context.token_to_complete, false).await;
+
+ // If this appears to be the command token (and if there's *some* prefix without
+ // a path separator) then also consider whether we should search the path for
+ // completions too.
+ // TODO: Do a better job than just checking if index == 0.
+ if context.token_index == 0
+ && !context.token_to_complete.is_empty()
+ && !context
+ .token_to_complete
+ .contains(std::path::MAIN_SEPARATOR)
+ {
+ // Add external commands.
+ let mut command_completions = get_command_completions(shell, context);
+ candidates.append(&mut command_completions);
+
+ // Add built-in commands.
+ for (name, registration) in shell.builtins() {
+ if !registration.disabled && name.starts_with(context.token_to_complete) {
+ candidates.insert(name.to_owned());
+ }
+ }
+
+ // Add shell functions.
+ for (name, _) in shell.funcs().iter() {
+ if name.starts_with(context.token_to_complete) {
+ candidates.insert(name.to_owned());
+ }
+ }
+
+ // Add aliases.
+ for name in shell.aliases.keys() {
+ if name.starts_with(context.token_to_complete) {
+ candidates.insert(name.to_owned());
+ }
+ }
+
+ // Add keywords.
+ for keyword in shell.get_keywords() {
+ if keyword.starts_with(context.token_to_complete) {
+ candidates.insert(keyword.clone());
+ }
+ }
+
+ // Sort.
+ candidates.sort();
+ }
+
+ #[cfg(windows)]
+ {
+ candidates = candidates
+ .into_iter()
+ .map(|c| c.replace('\\', "/"))
+ .collect();
+ }
+
+ Answer::Candidates(candidates, ProcessingOptions::default())
+}
+
+fn simple_tokenize_by_delimiters(input: &str, delimiters: &[char]) -> Vec {
+ //
+ // This is an overly naive tokenization.
+ //
+
+ let mut tokens = vec![];
+ let mut start = 0;
+
+ for piece in input.split_inclusive(delimiters) {
+ let next_start = start + piece.len();
+
+ let piece = piece.strip_suffix(delimiters).unwrap_or(piece);
+ let end = start + piece.len();
+ tokens.push(brush_parser::Token::Word(
+ piece.to_string(),
+ brush_parser::TokenLocation {
+ start: brush_parser::SourcePosition {
+ index: start,
+ line: 1,
+ column: start + 1,
+ }
+ .into(),
+ end: brush_parser::SourcePosition {
+ index: end,
+ line: 1,
+ column: end + 1,
+ }
+ .into(),
+ },
+ ));
+
+ start = next_start;
+ }
+
+ tokens
+}
+
+fn completion_filter_pattern_matches(
+ pattern: &str,
+ candidate: &str,
+ token_being_completed: &str,
+ shell: &Shell,
+) -> Result {
+ let pattern = replace_unescaped_ampersands(pattern, token_being_completed);
+
+ //
+ // TODO: Replace unescaped '&' with the word being completed.
+ //
+
+ let pattern = patterns::Pattern::from(pattern.as_ref())
+ .set_extended_globbing(shell.options.extended_globbing)
+ .set_case_insensitive(shell.options.case_insensitive_pathname_expansion);
+
+ let matches = pattern.exactly_matches(candidate)?;
+
+ Ok(matches)
+}
+
+fn replace_unescaped_ampersands<'a>(pattern: &'a str, replacement: &str) -> Cow<'a, str> {
+ let mut in_escape = false;
+ let mut insertion_points = vec![];
+
+ for (i, c) in pattern.char_indices() {
+ if !in_escape && c == '&' {
+ insertion_points.push(i);
+ }
+ in_escape = !in_escape && c == '\\';
+ }
+
+ if insertion_points.is_empty() {
+ return pattern.into();
+ }
+
+ let mut result = pattern.to_owned();
+ for i in insertion_points.iter().rev() {
+ result.replace_range(*i..=*i, replacement);
+ }
+
+ result.into()
+}
diff --git a/crates/brush-core-vendored/src/env.rs b/crates/brush-core-vendored/src/env.rs
new file mode 100644
index 000000000..f87333077
--- /dev/null
+++ b/crates/brush-core-vendored/src/env.rs
@@ -0,0 +1,611 @@
+//! Implements a shell variable environment.
+
+use std::borrow::Cow;
+use std::collections::HashMap;
+use std::collections::hash_map;
+
+use crate::error;
+use crate::shell;
+use crate::variables::{self, ShellValue, ShellValueUnsetType, ShellVariable};
+
+/// Represents the policy for looking up variables in a shell environment.
+#[derive(Clone, Copy)]
+pub enum EnvironmentLookup {
+ /// Look anywhere.
+ Anywhere,
+ /// Look only in the global scope.
+ OnlyInGlobal,
+ /// Look only in the current local scope.
+ OnlyInCurrentLocal,
+ /// Look only in local scopes.
+ OnlyInLocal,
+}
+
+/// Represents a shell environment scope.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum EnvironmentScope {
+ /// Scope local to a function instance
+ Local,
+ /// Globals
+ Global,
+ /// Transient overrides for a command invocation
+ Command,
+}
+
+/// Represents the shell variable environment, composed of a stack of scopes.
+#[derive(Clone, Debug)]
+pub struct ShellEnvironment {
+ /// Stack of scopes, with the top of the stack being the current scope.
+ scopes: Vec<(EnvironmentScope, ShellVariableMap)>,
+ /// Whether or not to auto-export variables on creation or modification.
+ export_variables_on_modification: bool,
+ /// Count of total entries (may include duplicates with shadowed variables).
+ entry_count: usize,
+}
+
+impl Default for ShellEnvironment {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl ShellEnvironment {
+ /// Returns a new shell environment.
+ pub fn new() -> Self {
+ Self {
+ scopes: vec![(EnvironmentScope::Global, ShellVariableMap::default())],
+ export_variables_on_modification: false,
+ entry_count: 0,
+ }
+ }
+
+ /// Pushes a new scope of the given type onto the environment's scope stack.
+ ///
+ /// # Arguments
+ ///
+ /// * `scope_type` - The type of scope to push.
+ pub fn push_scope(&mut self, scope_type: EnvironmentScope) {
+ self.scopes.push((scope_type, ShellVariableMap::default()));
+ }
+
+ /// Pops the top-most scope off the environment's scope stack.
+ ///
+ /// # Arguments
+ ///
+ /// * `expected_scope_type` - The type of scope that is expected to be atop the stack.
+ pub fn pop_scope(&mut self, expected_scope_type: EnvironmentScope) -> Result<(), error::Error> {
+ // TODO: Should we panic instead on failure? It's effectively a broken invariant.
+ match self.scopes.pop() {
+ Some((actual_scope_type, _)) if actual_scope_type == expected_scope_type => Ok(()),
+ _ => Err(error::ErrorKind::MissingScope.into()),
+ }
+ }
+
+ //
+ // Iterators/Getters
+ //
+
+ /// Returns an iterator over all exported variables defined in the variable.
+ pub fn iter_exported(&self) -> impl Iterator- {
+ // We won't actually need to store all entries, but we expect it should be
+ // within the same order.
+ let mut visible_vars: HashMap<&String, &ShellVariable> =
+ HashMap::with_capacity(self.entry_count);
+
+ for (_, var_map) in self.scopes.iter().rev() {
+ for (name, var) in var_map.iter().filter(|(_, v)| v.is_exported()) {
+ // Only insert the variable if it hasn't been seen yet.
+ if let hash_map::Entry::Vacant(entry) = visible_vars.entry(name) {
+ entry.insert(var);
+ }
+ }
+ }
+
+ visible_vars.into_iter()
+ }
+
+ /// Returns an iterator over all the variables defined in the environment.
+ pub fn iter(&self) -> impl Iterator
- {
+ self.iter_using_policy(EnvironmentLookup::Anywhere)
+ }
+
+ /// Returns an iterator over all the variables defined in the environment,
+ /// using the given lookup policy.
+ ///
+ /// # Arguments
+ ///
+ /// * `lookup_policy` - The policy to use when looking up variables.
+ pub fn iter_using_policy(
+ &self,
+ lookup_policy: EnvironmentLookup,
+ ) -> impl Iterator
- {
+ // We won't actually need to store all entries, but we expect it should be
+ // within the same order.
+ let mut visible_vars: HashMap<&String, &ShellVariable> =
+ HashMap::with_capacity(self.entry_count);
+
+ let mut local_count = 0;
+ for (scope_type, var_map) in self.scopes.iter().rev() {
+ if matches!(scope_type, EnvironmentScope::Local) {
+ local_count += 1;
+ }
+
+ match lookup_policy {
+ EnvironmentLookup::Anywhere => (),
+ EnvironmentLookup::OnlyInGlobal => {
+ if !matches!(scope_type, EnvironmentScope::Global) {
+ continue;
+ }
+ }
+ EnvironmentLookup::OnlyInCurrentLocal => {
+ if !(matches!(scope_type, EnvironmentScope::Local) && local_count == 1) {
+ continue;
+ }
+ }
+ EnvironmentLookup::OnlyInLocal => {
+ if !matches!(scope_type, EnvironmentScope::Local) {
+ continue;
+ }
+ }
+ }
+
+ for (name, var) in var_map.iter() {
+ // Only insert the variable if it hasn't been seen yet.
+ if let hash_map::Entry::Vacant(entry) = visible_vars.entry(name) {
+ entry.insert(var);
+ }
+ }
+
+ if matches!(scope_type, EnvironmentScope::Local)
+ && matches!(lookup_policy, EnvironmentLookup::OnlyInCurrentLocal)
+ {
+ break;
+ }
+ }
+
+ visible_vars.into_iter()
+ }
+
+ /// Tries to retrieve an immutable reference to the variable with the given name
+ /// in the environment.
+ ///
+ /// # Arguments
+ ///
+ /// * `name` - The name of the variable to retrieve.
+ pub fn get
>(&self, name: S) -> Option<(EnvironmentScope, &ShellVariable)> {
+ // Look through scopes, from the top of the stack on down.
+ for (scope_type, map) in self.scopes.iter().rev() {
+ if let Some(var) = map.get(name.as_ref()) {
+ return Some((*scope_type, var));
+ }
+ }
+
+ None
+ }
+
+ /// Tries to retrieve a mutable reference to the variable with the given name
+ /// in the environment.
+ ///
+ /// # Arguments
+ ///
+ /// * `name` - The name of the variable to retrieve.
+ pub fn get_mut>(
+ &mut self,
+ name: S,
+ ) -> Option<(EnvironmentScope, &mut ShellVariable)> {
+ // Look through scopes, from the top of the stack on down.
+ for (scope_type, map) in self.scopes.iter_mut().rev() {
+ if let Some(var) = map.get_mut(name.as_ref()) {
+ return Some((*scope_type, var));
+ }
+ }
+
+ None
+ }
+
+ /// Tries to retrieve the string value of the variable with the given name in the
+ /// environment.
+ ///
+ /// # Arguments
+ ///
+ /// * `name` - The name of the variable to retrieve.
+ /// * `shell` - The shell owning the environment.
+ pub fn get_str>(&self, name: S, shell: &shell::Shell) -> Option> {
+ self.get(name.as_ref())
+ .map(|(_, v)| v.value().to_cow_str(shell))
+ }
+
+ /// Checks if a variable of the given name is set in the environment.
+ ///
+ /// # Arguments
+ ///
+ /// * `name` - The name of the variable to check.
+ pub fn is_set>(&self, name: S) -> bool {
+ if let Some((_, var)) = self.get(name) {
+ !matches!(var.value(), ShellValue::Unset(_))
+ } else {
+ false
+ }
+ }
+
+ //
+ // Setters
+ //
+
+ /// Tries to unset the variable with the given name in the environment, returning
+ /// whether or not such a variable existed.
+ ///
+ /// # Arguments
+ ///
+ /// * `name` - The name of the variable to unset.
+ pub fn unset(&mut self, name: &str) -> Result, error::Error> {
+ let mut local_count = 0;
+ for (scope_type, map) in self.scopes.iter_mut().rev() {
+ if matches!(scope_type, EnvironmentScope::Local) {
+ local_count += 1;
+ }
+
+ let unset_result = Self::try_unset_in_map(map, name)?;
+
+ if unset_result.is_some() {
+ // If we end up finding a local in the top-most local frame, then we replace
+ // it with a placeholder.
+ if matches!(scope_type, EnvironmentScope::Local) && local_count == 1 {
+ map.set(
+ name,
+ ShellVariable::new(ShellValue::Unset(ShellValueUnsetType::Untyped)),
+ );
+ } else if self.entry_count > 0 {
+ // Entry count should never be 0 here, but we're being defensive.
+ self.entry_count -= 1;
+ }
+
+ return Ok(unset_result);
+ }
+ }
+
+ Ok(None)
+ }
+
+ /// Tries to unset an array element from the environment, using the given name and
+ /// element index for lookup. Returns whether or not an element was unset.
+ ///
+ /// # Arguments
+ ///
+ /// * `name` - The name of the array variable to unset an element from.
+ /// * `index` - The index of the element to unset.
+ pub fn unset_index(&mut self, name: &str, index: &str) -> Result {
+ if let Some((_, var)) = self.get_mut(name) {
+ var.unset_index(index)
+ } else {
+ Ok(false)
+ }
+ }
+
+ fn try_unset_in_map(
+ map: &mut ShellVariableMap,
+ name: &str,
+ ) -> Result, error::Error> {
+ match map.get(name).map(|v| v.is_readonly()) {
+ Some(true) => Err(error::ErrorKind::ReadonlyVariable.into()),
+ Some(false) => Ok(map.unset(name)),
+ None => Ok(None),
+ }
+ }
+
+ /// Tries to retrieve an immutable reference to a variable from the environment,
+ /// using the given name and lookup policy.
+ ///
+ /// # Arguments
+ ///
+ /// * `name` - The name of the variable to retrieve.
+ /// * `lookup_policy` - The policy to use when looking up the variable.
+ pub fn get_using_policy>(
+ &self,
+ name: N,
+ lookup_policy: EnvironmentLookup,
+ ) -> Option<&ShellVariable> {
+ let mut local_count = 0;
+ for (scope_type, var_map) in self.scopes.iter().rev() {
+ if matches!(scope_type, EnvironmentScope::Local) {
+ local_count += 1;
+ }
+
+ match lookup_policy {
+ EnvironmentLookup::Anywhere => (),
+ EnvironmentLookup::OnlyInGlobal => {
+ if !matches!(scope_type, EnvironmentScope::Global) {
+ continue;
+ }
+ }
+ EnvironmentLookup::OnlyInCurrentLocal => {
+ if !(matches!(scope_type, EnvironmentScope::Local) && local_count == 1) {
+ continue;
+ }
+ }
+ EnvironmentLookup::OnlyInLocal => {
+ if !matches!(scope_type, EnvironmentScope::Local) {
+ continue;
+ }
+ }
+ }
+
+ if let Some(var) = var_map.get(name.as_ref()) {
+ return Some(var);
+ }
+
+ if matches!(scope_type, EnvironmentScope::Local)
+ && matches!(lookup_policy, EnvironmentLookup::OnlyInCurrentLocal)
+ {
+ break;
+ }
+ }
+
+ None
+ }
+
+ /// Tries to retrieve a mutable reference to a variable from the environment,
+ /// using the given name and lookup policy.
+ ///
+ /// # Arguments
+ ///
+ /// * `name` - The name of the variable to retrieve.
+ /// * `lookup_policy` - The policy to use when looking up the variable.
+ pub fn get_mut_using_policy>(
+ &mut self,
+ name: N,
+ lookup_policy: EnvironmentLookup,
+ ) -> Option<&mut ShellVariable> {
+ let mut local_count = 0;
+ for (scope_type, var_map) in self.scopes.iter_mut().rev() {
+ if matches!(scope_type, EnvironmentScope::Local) {
+ local_count += 1;
+ }
+
+ match lookup_policy {
+ EnvironmentLookup::Anywhere => (),
+ EnvironmentLookup::OnlyInGlobal => {
+ if !matches!(scope_type, EnvironmentScope::Global) {
+ continue;
+ }
+ }
+ EnvironmentLookup::OnlyInCurrentLocal => {
+ if !(matches!(scope_type, EnvironmentScope::Local) && local_count == 1) {
+ continue;
+ }
+ }
+ EnvironmentLookup::OnlyInLocal => {
+ if !matches!(scope_type, EnvironmentScope::Local) {
+ continue;
+ }
+ }
+ }
+
+ if let Some(var) = var_map.get_mut(name.as_ref()) {
+ return Some(var);
+ }
+
+ if matches!(scope_type, EnvironmentScope::Local)
+ && matches!(lookup_policy, EnvironmentLookup::OnlyInCurrentLocal)
+ {
+ break;
+ }
+ }
+
+ None
+ }
+
+ /// Update a variable in the environment, or add it if it doesn't already exist.
+ ///
+ /// # Arguments
+ ///
+ /// * `name` - The name of the variable to update or add.
+ /// * `value` - The value to assign to the variable.
+ /// * `updater` - A function to call to update the variable after assigning the value.
+ /// * `lookup_policy` - The policy to use when looking up the variable.
+ /// * `scope_if_creating` - The scope to create the variable in if it doesn't already exist.
+ pub fn update_or_add>(
+ &mut self,
+ name: N,
+ value: variables::ShellValueLiteral,
+ updater: impl Fn(&mut ShellVariable) -> Result<(), error::Error>,
+ lookup_policy: EnvironmentLookup,
+ scope_if_creating: EnvironmentScope,
+ ) -> Result<(), error::Error> {
+ let name = name.into();
+
+ let auto_export = self.export_variables_on_modification;
+ if let Some(var) = self.get_mut_using_policy(&name, lookup_policy) {
+ var.assign(value, false)?;
+ if auto_export {
+ var.export();
+ }
+ updater(var)
+ } else {
+ let mut var = ShellVariable::new(ShellValue::Unset(ShellValueUnsetType::Untyped));
+ var.assign(value, false)?;
+ if auto_export {
+ var.export();
+ }
+ updater(&mut var)?;
+
+ self.add(name, var, scope_if_creating)
+ }
+ }
+
+ /// Update an array element in the environment, or add it if it doesn't already exist.
+ ///
+ /// # Arguments
+ ///
+ /// * `name` - The name of the variable to update or add.
+ /// * `index` - The index of the element to update or add.
+ /// * `value` - The value to assign to the variable.
+ /// * `updater` - A function to call to update the variable after assigning the value.
+ /// * `lookup_policy` - The policy to use when looking up the variable.
+ /// * `scope_if_creating` - The scope to create the variable in if it doesn't already exist.
+ pub fn update_or_add_array_element>(
+ &mut self,
+ name: N,
+ index: String,
+ value: String,
+ updater: impl Fn(&mut ShellVariable) -> Result<(), error::Error>,
+ lookup_policy: EnvironmentLookup,
+ scope_if_creating: EnvironmentScope,
+ ) -> Result<(), error::Error> {
+ let name = name.into();
+
+ if let Some(var) = self.get_mut_using_policy(&name, lookup_policy) {
+ var.assign_at_index(index, value, false)?;
+ updater(var)
+ } else {
+ let mut var = ShellVariable::new(ShellValue::Unset(ShellValueUnsetType::Untyped));
+ var.assign(
+ variables::ShellValueLiteral::Array(variables::ArrayLiteral(vec![(
+ Some(index),
+ value,
+ )])),
+ false,
+ )?;
+ updater(&mut var)?;
+
+ self.add(name, var, scope_if_creating)
+ }
+ }
+
+ /// Adds a variable to the environment.
+ ///
+ /// # Arguments
+ ///
+ /// * `name` - The name of the variable to add.
+ /// * `var` - The variable to add.
+ /// * `target_scope` - The scope to add the variable to.
+ pub fn add>(
+ &mut self,
+ name: N,
+ mut var: ShellVariable,
+ target_scope: EnvironmentScope,
+ ) -> Result<(), error::Error> {
+ if self.export_variables_on_modification {
+ var.export();
+ }
+
+ for (scope_type, map) in self.scopes.iter_mut().rev() {
+ if *scope_type == target_scope {
+ let prev_var = map.set(name, var);
+ if prev_var.is_none() {
+ self.entry_count += 1;
+ }
+
+ return Ok(());
+ }
+ }
+
+ Err(error::ErrorKind::MissingScope.into())
+ }
+
+ /// Sets a global variable in the environment.
+ ///
+ /// # Arguments
+ ///
+ /// * `name` - The name of the variable to set.
+ /// * `var` - The variable to set.
+ pub fn set_global>(
+ &mut self,
+ name: N,
+ var: ShellVariable,
+ ) -> Result<(), error::Error> {
+ self.add(name, var, EnvironmentScope::Global)
+ }
+}
+
+/// Represents a map from names to shell variables.
+#[derive(Clone, Debug, Default)]
+pub struct ShellVariableMap {
+ variables: HashMap,
+}
+
+impl ShellVariableMap {
+ //
+ // Iterators/Getters
+ //
+
+ /// Returns an iterator over all the variables in the map.
+ pub fn iter(&self) -> impl Iterator- {
+ self.variables.iter()
+ }
+
+ /// Tries to retrieve an immutable reference to the variable with the given name.
+ ///
+ /// # Arguments
+ ///
+ /// * `name` - The name of the variable to retrieve.
+ pub fn get(&self, name: &str) -> Option<&ShellVariable> {
+ self.variables.get(name)
+ }
+
+ /// Tries to retrieve a mutable reference to the variable with the given name.
+ ///
+ /// # Arguments
+ ///
+ /// * `name` - The name of the variable to retrieve.
+ pub fn get_mut(&mut self, name: &str) -> Option<&mut ShellVariable> {
+ self.variables.get_mut(name)
+ }
+
+ //
+ // Setters
+ //
+
+ /// Tries to unset the variable with the given name, returning the removed
+ /// variable or None if it was not already set.
+ ///
+ /// # Arguments
+ ///
+ /// * `name` - The name of the variable to unset.
+ pub fn unset(&mut self, name: &str) -> Option
{
+ self.variables.remove(name)
+ }
+
+ /// Sets a variable in the map.
+ ///
+ /// # Arguments
+ ///
+ /// * `name` - The name of the variable to set.
+ /// * `var` - The variable to set.
+ pub fn set>(&mut self, name: N, var: ShellVariable) -> Option {
+ self.variables.insert(name.into(), var)
+ }
+}
+
+/// Checks if the given name is a valid variable name.
+pub fn valid_variable_name(s: &str) -> bool {
+ let mut cs = s.chars();
+ match cs.next() {
+ Some(c) if c.is_ascii_alphabetic() || c == '_' => {
+ cs.all(|c| c.is_ascii_alphanumeric() || c == '_')
+ }
+ Some(_) | None => false,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_valid_variable_name() {
+ assert!(!valid_variable_name(""));
+ assert!(!valid_variable_name("1"));
+ assert!(!valid_variable_name(" a"));
+ assert!(!valid_variable_name(" "));
+
+ assert!(valid_variable_name("_"));
+ assert!(valid_variable_name("_a"));
+ assert!(valid_variable_name("_1"));
+ assert!(valid_variable_name("_a1"));
+ assert!(valid_variable_name("a"));
+ assert!(valid_variable_name("A"));
+ assert!(valid_variable_name("a1"));
+ assert!(valid_variable_name("A1"));
+ }
+}
diff --git a/crates/brush-core-vendored/src/error.rs b/crates/brush-core-vendored/src/error.rs
new file mode 100644
index 000000000..baf34d631
--- /dev/null
+++ b/crates/brush-core-vendored/src/error.rs
@@ -0,0 +1,355 @@
+//! Error facilities
+
+use std::path::PathBuf;
+
+use crate::{Shell, ShellFd, results, sys};
+
+/// Unified error type for this crate. Contains just a kind for now,
+/// but will be extended later with additional context.
+#[derive(thiserror::Error, Debug)]
+#[error(transparent)]
+pub struct Error {
+ /// The kind of error.
+ kind: ErrorKind,
+}
+
+/// Monolithic error type for the shell
+#[derive(thiserror::Error, Debug)]
+pub enum ErrorKind {
+ /// A tilde expression was used without a valid HOME variable
+ #[error("cannot expand tilde expression with HOME not set")]
+ TildeWithoutValidHome,
+
+ /// An attempt was made to assign a list to an array member
+ #[error("cannot assign list to array member")]
+ AssigningListToArrayMember,
+
+ /// An attempt was made to convert an associative array to an indexed array.
+ #[error("cannot convert associative array to indexed array")]
+ ConvertingAssociativeArrayToIndexedArray,
+
+ /// An attempt was made to convert an indexed array to an associative array.
+ #[error("cannot convert indexed array to associative array")]
+ ConvertingIndexedArrayToAssociativeArray,
+
+ /// An error occurred while sourcing the indicated script file.
+ #[error("failed to source file: {0}")]
+ FailedSourcingFile(PathBuf, #[source] std::io::Error),
+
+ /// The shell failed to send a signal to a process.
+ #[error("failed to send signal to process")]
+ FailedToSendSignal,
+
+ /// An attempt was made to assign a value to a special parameter.
+ #[error("cannot assign in this way")]
+ CannotAssignToSpecialParameter,
+
+ /// Checked expansion error.
+ #[error("expansion error: {0}")]
+ CheckedExpansionError(String),
+
+ /// A reference was made to an unknown shell function.
+ #[error("function not found: {0}")]
+ FunctionNotFound(String),
+
+ /// Command was not found.
+ #[error("command not found: {0}")]
+ CommandNotFound(String),
+
+ /// Not a builtin.
+ #[error("not a shell builtin: {0}")]
+ BuiltinNotFound(String),
+
+ /// The working directory does not exist.
+ #[error("working directory does not exist: {0}")]
+ WorkingDirMissing(PathBuf),
+
+ /// Failed to execute command.
+ #[error("failed to execute command '{0}': {1}")]
+ FailedToExecuteCommand(String, #[source] std::io::Error),
+
+ /// History item was not found.
+ #[error("history item not found")]
+ HistoryItemNotFound,
+
+ /// The requested functionality has not yet been implemented in this shell.
+ #[error("not yet implemented: {0}")]
+ Unimplemented(&'static str),
+
+ /// The requested functionality has not yet been implemented in this shell; it is tracked in a
+ /// GitHub issue.
+ #[error("not yet implemented: {0}; see https://github.com/reubeno/brush/issues/{1}")]
+ UnimplementedAndTracked(&'static str, u32),
+
+ /// An expected environment scope could not be found.
+ #[error("missing scope")]
+ MissingScope,
+
+ /// The given path is not a directory.
+ #[error("not a directory: {0}")]
+ NotADirectory(PathBuf),
+
+ /// The given path is a directory.
+ #[error("path is a directory")]
+ IsADirectory,
+
+ /// The given variable is not an array.
+ #[error("variable is not an array")]
+ NotArray,
+
+ /// The current user could not be determined.
+ #[error("no current user")]
+ NoCurrentUser,
+
+ /// The requested input or output redirection is invalid.
+ #[error("invalid redirection")]
+ InvalidRedirection,
+
+ /// An error occurred while redirecting input or output with the given file.
+ #[error("failed to redirect to {0}: {1}")]
+ RedirectionFailure(String, String),
+
+ /// An error occurred evaluating an arithmetic expression.
+ #[error("arithmetic evaluation error: {0}")]
+ EvalError(#[from] crate::arithmetic::EvalError),
+
+ /// The given string could not be parsed as an integer.
+ #[error("failed to parse integer")]
+ IntParseError(#[from] std::num::ParseIntError),
+
+ /// The given string could not be parsed as an integer.
+ #[error("failed to parse integer")]
+ TryIntParseError(#[from] std::num::TryFromIntError),
+
+ /// A byte sequence could not be decoded as a valid UTF-8 string.
+ #[error("failed to decode utf-8")]
+ FromUtf8Error(#[from] std::string::FromUtf8Error),
+
+ /// A byte sequence could not be decoded as a valid UTF-8 string.
+ #[error("failed to decode utf-8")]
+ Utf8Error(#[from] std::str::Utf8Error),
+
+ /// An attempt was made to modify a readonly variable.
+ #[error("cannot mutate readonly variable")]
+ ReadonlyVariable,
+
+ /// The indicated pattern is invalid.
+ #[error("invalid pattern: '{0}'")]
+ InvalidPattern(String),
+
+ /// A regular expression error occurred
+ #[error("regex error: {0}")]
+ RegexError(#[from] fancy_regex::Error),
+
+ /// An invalid regular expression was provided.
+ #[error("invalid regex: {0}; expression: '{1}'")]
+ InvalidRegexError(fancy_regex::Error, String),
+
+ /// An I/O error occurred.
+ #[error("i/o error: {0}")]
+ IoError(#[from] std::io::Error),
+
+ /// Invalid substitution syntax.
+ #[error("bad substitution: {0}")]
+ BadSubstitution(String),
+
+ /// An error occurred while creating a child process.
+ #[error("failed to create child process")]
+ ChildCreationFailure,
+
+ /// An error occurred while formatting a string.
+ #[error(transparent)]
+ FormattingError(#[from] std::fmt::Error),
+
+ /// An error occurred while parsing.
+ #[error("{1}: {0}")]
+ ParseError(brush_parser::ParseError, brush_parser::SourceInfo),
+
+ /// An error occurred while parsing a function body.
+ #[error("{0}: {1}")]
+ FunctionParseError(String, brush_parser::ParseError),
+
+ /// An error occurred while parsing a word.
+ #[error(transparent)]
+ WordParseError(#[from] brush_parser::WordParseError),
+
+ /// Unable to parse a test command.
+ #[error(transparent)]
+ TestCommandParseError(#[from] brush_parser::TestCommandParseError),
+
+ /// Unable to parse a key binding specification.
+ #[error(transparent)]
+ BindingParseError(#[from] brush_parser::BindingParseError),
+
+ /// A threading error occurred.
+ #[error("threading error")]
+ ThreadingError(#[from] tokio::task::JoinError),
+
+ /// An invalid signal was referenced.
+ #[error("{0}: invalid signal specification")]
+ InvalidSignal(String),
+
+ /// A platform error occurred.
+ #[error("platform error: {0}")]
+ PlatformError(#[from] sys::PlatformError),
+
+ /// An invalid umask was provided.
+ #[error("invalid umask value")]
+ InvalidUmask,
+
+ /// The given open file cannot be read from.
+ #[error("cannot read from {0}")]
+ OpenFileNotReadable(&'static str),
+
+ /// The given open file cannot be written to.
+ #[error("cannot write to {0}")]
+ OpenFileNotWritable(&'static str),
+
+ /// Bad file descriptor.
+ #[error("bad file descriptor: {0}")]
+ BadFileDescriptor(ShellFd),
+
+ /// Printf failure
+ #[error("printf failure: {0}")]
+ PrintfFailure(i32),
+
+ /// Printf invalid usage
+ #[error("printf: {0}")]
+ PrintfInvalidUsage(String),
+
+ /// Interrupted
+ #[error("interrupted")]
+ Interrupted,
+
+ /// Maximum function call depth was exceeded.
+ #[error("maximum function call depth exceeded")]
+ MaxFunctionCallDepthExceeded,
+
+ /// System time error.
+ #[error("system time error: {0}")]
+ TimeError(#[from] std::time::SystemTimeError),
+
+ /// Array index out of range.
+ #[error("array index out of range: {0}")]
+ ArrayIndexOutOfRange(i64),
+
+ /// Unhandled key code.
+ #[error("unhandled key code: {0:?}")]
+ UnhandledKeyCode(Vec),
+
+ /// An error occurred in a built-in command.
+ #[error("{1}: {0}")]
+ BuiltinError(Box, String),
+
+ /// Operation not supported on this platform.
+ #[error("operation not supported on this platform: {0}")]
+ NotSupportedOnThisPlatform(&'static str),
+
+ /// Command history is not enabled in this shell.
+ #[error("command history is not enabled in this shell")]
+ HistoryNotEnabled,
+
+ /// Unknown key binding function.
+ #[error("unknown key binding function: {0}")]
+ UnknownKeyBindingFunction(String),
+}
+
+impl BuiltinError for Error {}
+
+/// Trait implementable by built-in commands to represent errors.
+pub trait BuiltinError: std::error::Error + ConvertibleToExitCode + Send + Sync {}
+
+/// Helper trait for converting values to exit codes.
+pub trait ConvertibleToExitCode {
+ /// Converts to an exit code.
+ fn as_exit_code(&self) -> results::ExecutionExitCode;
+}
+
+impl ConvertibleToExitCode for T
+where
+ results::ExecutionExitCode: for<'a> From<&'a T>,
+{
+ fn as_exit_code(&self) -> results::ExecutionExitCode {
+ self.into()
+ }
+}
+
+impl From<&ErrorKind> for results::ExecutionExitCode {
+ fn from(value: &ErrorKind) -> Self {
+ match value {
+ ErrorKind::CommandNotFound(..) => Self::NotFound,
+ ErrorKind::Unimplemented(..) | ErrorKind::UnimplementedAndTracked(..) => {
+ Self::Unimplemented
+ }
+ ErrorKind::ParseError(..) => Self::InvalidUsage,
+ ErrorKind::FunctionParseError(..) => Self::InvalidUsage,
+ ErrorKind::FailedToExecuteCommand(..) => Self::CannotExecute,
+ ErrorKind::BuiltinError(inner, ..) => inner.as_exit_code(),
+ _ => Self::GeneralError,
+ }
+ }
+}
+
+impl From<&Error> for results::ExecutionExitCode {
+ fn from(error: &Error) -> Self {
+ Self::from(&error.kind)
+ }
+}
+
+impl From for Error
+where
+ ErrorKind: From,
+{
+ fn from(convertible_to_kind: T) -> Self {
+ Self {
+ kind: convertible_to_kind.into(),
+ }
+ }
+}
+
+/// Trait implementable by consumers of this crate to customize formatting errors into
+/// displayable text.
+pub trait ErrorFormatter: Send {
+ /// Format the given error for display within the context of the provided shell.
+ ///
+ /// # Arguments
+ ///
+ /// * `error` - The error to format.
+ /// * `shell` - The shell in which the error occurred.
+ fn format_error(&self, error: &Error, shell: &Shell) -> String;
+}
+
+/// Default implementation of the [`ErrorFormatter`] trait.
+pub(crate) struct DefaultErrorFormatter {}
+
+impl DefaultErrorFormatter {
+ pub const fn new() -> Self {
+ Self {}
+ }
+}
+
+impl ErrorFormatter for DefaultErrorFormatter {
+ fn format_error(&self, err: &Error, _shell: &Shell) -> String {
+ std::format!("error: {err:#}\n")
+ }
+}
+
+/// Convenience function for returning an error for unimplemented functionality.
+///
+/// # Arguments
+///
+/// * `msg` - The message to include in the error
+pub fn unimp(msg: &'static str) -> Result {
+ Err(ErrorKind::Unimplemented(msg).into())
+}
+
+/// Convenience function for returning an error for *tracked*, unimplemented functionality.
+///
+/// # Arguments
+///
+/// * `msg` - The message to include in the error
+/// * `project_issue_id` - The GitHub issue ID where the implementation is tracked.
+#[allow(unused)]
+pub fn unimp_with_issue(msg: &'static str, project_issue_id: u32) -> Result {
+ Err(ErrorKind::UnimplementedAndTracked(msg, project_issue_id).into())
+}
diff --git a/crates/brush-core-vendored/src/escape.rs b/crates/brush-core-vendored/src/escape.rs
new file mode 100644
index 000000000..ef7955d4c
--- /dev/null
+++ b/crates/brush-core-vendored/src/escape.rs
@@ -0,0 +1,449 @@
+//! String escaping utilities
+
+use std::borrow::Cow;
+
+use itertools::Itertools;
+
+use crate::error;
+
+/// Escape expansion mode.
+#[derive(Clone, Copy)]
+pub enum EscapeExpansionMode {
+ /// echo builtin mode.
+ EchoBuiltin,
+ /// ANSI-C quotes.
+ AnsiCQuotes,
+}
+
+/// Expands backslash escapes in the provided string.
+///
+/// # Arguments
+///
+/// * `s` - The string to expand.
+/// * `mode` - The mode to use for expansion.
+#[expect(clippy::too_many_lines)]
+pub fn expand_backslash_escapes(
+ s: &str,
+ mode: EscapeExpansionMode,
+) -> Result<(Vec, bool), error::Error> {
+ let mut result: Vec = vec![];
+ let mut it = s.chars();
+ while let Some(c) = it.next() {
+ if c != '\\' {
+ // Not a backslash, add and move on.
+ result.append(c.to_string().into_bytes().as_mut());
+ continue;
+ }
+
+ match it.next() {
+ Some('a') => result.push(b'\x07'),
+ Some('b') => result.push(b'\x08'),
+ Some('c') => {
+ match mode {
+ EscapeExpansionMode::EchoBuiltin => {
+ // Stop all additional output!
+ return Ok((result, false));
+ }
+ EscapeExpansionMode::AnsiCQuotes => {
+ if let Some(_next_next) = it.next() {
+ return error::unimp("control character in ANSI C quotes");
+ } else {
+ result.push(b'\\');
+ result.push(b'c');
+ }
+ }
+ }
+ }
+ Some('e' | 'E') => result.push(b'\x1b'),
+ Some('f') => result.push(b'\x0c'),
+ Some('n') => result.push(b'\n'),
+ Some('r') => result.push(b'\r'),
+ Some('t') => result.push(b'\t'),
+ Some('v') => result.push(b'\x0b'),
+ Some('\\') => result.push(b'\\'),
+ Some('\'') if matches!(mode, EscapeExpansionMode::AnsiCQuotes) => result.push(b'\''),
+ Some('\"') if matches!(mode, EscapeExpansionMode::AnsiCQuotes) => result.push(b'\"'),
+ Some('?') if matches!(mode, EscapeExpansionMode::AnsiCQuotes) => result.push(b'?'),
+ Some('0') => {
+ // Consume 0-3 valid octal chars
+ let mut taken_so_far = 0;
+ let mut octal_chars: String = it
+ .take_while_ref(|c| {
+ if taken_so_far < 3 && matches!(*c, '0'..='7') {
+ taken_so_far += 1;
+ true
+ } else {
+ false
+ }
+ })
+ .collect();
+
+ if octal_chars.is_empty() {
+ octal_chars.push('0');
+ }
+
+ let value = u8::from_str_radix(octal_chars.as_str(), 8)?;
+ result.push(value);
+ }
+ Some('x') => {
+ // Consume 1-2 valid hex chars
+ let mut taken_so_far = 0;
+ let hex_chars: String = it
+ .take_while_ref(|c| {
+ if taken_so_far < 2 && c.is_ascii_hexdigit() {
+ taken_so_far += 1;
+ true
+ } else {
+ false
+ }
+ })
+ .collect();
+
+ if hex_chars.is_empty() {
+ result.push(b'\\');
+ result.append(c.to_string().into_bytes().as_mut());
+ } else {
+ let value = u8::from_str_radix(hex_chars.as_str(), 16)?;
+ result.push(value);
+ }
+ }
+ Some('u') => {
+ // Consume 1-4 hex digits
+ let mut taken_so_far = 0;
+ let hex_chars: String = it
+ .take_while_ref(|c| {
+ if taken_so_far < 4 && c.is_ascii_hexdigit() {
+ taken_so_far += 1;
+ true
+ } else {
+ false
+ }
+ })
+ .collect();
+
+ if hex_chars.is_empty() {
+ result.push(b'\\');
+ result.append(c.to_string().into_bytes().as_mut());
+ } else {
+ let value = u16::from_str_radix(hex_chars.as_str(), 16)?;
+
+ if let Some(decoded) = char::from_u32(u32::from(value)) {
+ result.append(decoded.to_string().into_bytes().as_mut());
+ } else {
+ result.push(b'\\');
+ result.append(c.to_string().into_bytes().as_mut());
+ }
+ }
+ }
+ Some('U') => {
+ // Consume 1-8 hex digits
+ let mut taken_so_far = 0;
+ let hex_chars: String = it
+ .take_while_ref(|c| {
+ if taken_so_far < 8 && c.is_ascii_hexdigit() {
+ taken_so_far += 1;
+ true
+ } else {
+ false
+ }
+ })
+ .collect();
+
+ if hex_chars.is_empty() {
+ result.push(b'\\');
+ result.append(c.to_string().into_bytes().as_mut());
+ } else {
+ let value = u32::from_str_radix(hex_chars.as_str(), 16)?;
+
+ if let Some(decoded) = char::from_u32(value) {
+ result.append(decoded.to_string().into_bytes().as_mut());
+ } else {
+ result.push(b'\\');
+ result.append(c.to_string().into_bytes().as_mut());
+ }
+ }
+ }
+ Some(c) => {
+ // Not a valid escape sequence.
+ result.push(b'\\');
+ result.append(c.to_string().into_bytes().as_mut());
+ }
+ None => {
+ // Trailing backslash.
+ result.push(b'\\');
+ }
+ }
+ }
+
+ Ok((result, true))
+}
+
+/// Quoting mode to use for escaping.
+#[derive(Clone, Copy, Default)]
+pub enum QuoteMode {
+ /// Single-quote.
+ #[default]
+ SingleQuote,
+ /// Double-quote.
+ DoubleQuote,
+ /// Backslash-escape.
+ BackslashEscape,
+}
+
+/// Options influencing how to escape/quote an input string.
+#[derive(Default)]
+pub(crate) struct QuoteOptions {
+ /// Whether or not to *always* escape or quote the input; if false, then escaping/quoting
+ /// will only be applied if the input contains characters that *require* it.
+ pub always_quote: bool,
+ /// Preferred mode for quoting/escaping. Quoting may be "upgraded" to a more expressive
+ /// format if the input is not expressible otherwise.
+ pub preferred_mode: QuoteMode,
+ /// Whether or not to *avoid* using ANSI C quoting just for the benefit of newline characters.
+ /// Default is for newline characters to require upgrading the string's quoting to
+ /// ANSI C quoting.
+ pub avoid_ansi_c_quoting_newline: bool,
+}
+
+pub(crate) fn quote<'a>(s: &'a str, options: &QuoteOptions) -> Cow<'a, str> {
+ let use_ansi_c_quotes = s.contains(|c| {
+ needs_ansi_c_quoting(c) && (!options.avoid_ansi_c_quoting_newline || c != '\n')
+ });
+
+ if use_ansi_c_quotes {
+ return ansi_c_quote(s).into();
+ }
+
+ let use_default_quotes =
+ !use_ansi_c_quotes && (options.always_quote || s.is_empty() || s.contains(needs_escaping));
+
+ if !use_default_quotes {
+ return s.into();
+ }
+
+ match options.preferred_mode {
+ QuoteMode::BackslashEscape => backslash_escape(s).into(),
+ QuoteMode::SingleQuote => single_quote(s).into(),
+ QuoteMode::DoubleQuote => double_quote(s).into(),
+ }
+}
+
+/// Escape the given string, forcing quoting.
+///
+/// # Arguments
+///
+/// * `s` - The string to escape.
+/// * `mode` - The quoting mode to use.
+pub fn force_quote(s: &str, mode: QuoteMode) -> String {
+ let options = QuoteOptions {
+ always_quote: true,
+ preferred_mode: mode,
+ ..Default::default()
+ };
+
+ quote(s, &options).to_string()
+}
+
+/// Applies the given quoting mode to the provided string, only changing it if required.
+///
+/// # Arguments
+///
+/// * `s` - The string to escape.
+/// * `mode` - The quoting mode to use.
+pub fn quote_if_needed(s: &str, mode: QuoteMode) -> Cow<'_, str> {
+ let options = QuoteOptions {
+ always_quote: false,
+ preferred_mode: mode,
+ ..Default::default()
+ };
+
+ quote(s, &options)
+}
+
+fn backslash_escape(s: &str) -> String {
+ let mut output = String::new();
+
+ // TODO: Handle other interesting sequences.
+ for c in s.chars() {
+ match c {
+ c if needs_escaping(c) => {
+ output.push('\\');
+ output.push(c);
+ }
+ c => output.push(c),
+ }
+ }
+
+ output
+}
+
+fn single_quote(s: &str) -> String {
+ // Special-case the empty string.
+ if s.is_empty() {
+ return "''".into();
+ }
+
+ let mut result = String::new();
+
+ // Go through the string; put everything in single quotes except for
+ // the single quote character itself. It will get escaped outside
+ // all quoting.
+ let mut first = true;
+ for part in s.split('\'') {
+ if !first {
+ result.push('\\');
+ result.push('\'');
+ } else {
+ first = false;
+ }
+
+ if !part.is_empty() {
+ result.push('\'');
+ result.push_str(part);
+ result.push('\'');
+ }
+ }
+
+ result
+}
+
+fn double_quote(s: &str) -> String {
+ let mut result = String::new();
+
+ result.push('"');
+
+ for c in s.chars() {
+ if matches!(c, '$' | '`' | '"' | '\\') {
+ result.push('\\');
+ }
+
+ result.push(c);
+ }
+
+ result.push('"');
+
+ result
+}
+
+fn ansi_c_quote(s: &str) -> String {
+ let mut result = String::new();
+
+ result.push_str("$'");
+
+ for c in s.chars() {
+ match c {
+ '\x07' => result.push_str("\\a"),
+ '\x08' => result.push_str("\\b"),
+ '\x1b' => result.push_str("\\E"),
+ '\x0c' => result.push_str("\\f"),
+ '\n' => result.push_str("\\n"),
+ '\r' => result.push_str("\\r"),
+ '\t' => result.push_str("\\t"),
+ '\x0b' => result.push_str("\\v"),
+ '\\' => result.push_str("\\\\"),
+ '\'' => result.push_str("\\'"),
+ c if needs_ansi_c_quoting(c) => {
+ result.push_str(std::format!("\\{:03o}", c as u8).as_str());
+ }
+ _ => result.push(c),
+ }
+ }
+
+ result.push('\'');
+
+ result
+}
+
+// Returns whether or not the given character needs to be escaped (or quoted) if outside
+// quotes.
+const fn needs_escaping(c: char) -> bool {
+ matches!(
+ c,
+ '(' | ')'
+ | '['
+ | ']'
+ | '{'
+ | '}'
+ | '$'
+ | '*'
+ | '?'
+ | '|'
+ | '&'
+ | ';'
+ | '<'
+ | '>'
+ | '`'
+ | '\\'
+ | '"'
+ | '!'
+ | '^'
+ | ','
+ | ' '
+ | '\''
+ )
+}
+
+const fn needs_ansi_c_quoting(c: char) -> bool {
+ c.is_ascii_control()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_backslash_escape() {
+ assert_eq!(quote_if_needed("a", QuoteMode::BackslashEscape), "a");
+ assert_eq!(quote_if_needed("a b", QuoteMode::BackslashEscape), r"a\ b");
+ assert_eq!(quote_if_needed("", QuoteMode::BackslashEscape), "");
+ }
+
+ #[test]
+ fn test_single_quote_escape() {
+ assert_eq!(quote_if_needed("a", QuoteMode::SingleQuote), "a");
+ assert_eq!(quote_if_needed("a b", QuoteMode::SingleQuote), "'a b'");
+ assert_eq!(quote_if_needed("", QuoteMode::SingleQuote), "''");
+ assert_eq!(quote_if_needed("'", QuoteMode::SingleQuote), "\\'");
+ }
+
+ fn assert_echo_expands_to(unexpanded: &str, expected: &str) {
+ assert_eq!(
+ String::from_utf8(
+ expand_backslash_escapes(unexpanded, EscapeExpansionMode::EchoBuiltin)
+ .unwrap()
+ .0
+ )
+ .unwrap(),
+ expected
+ );
+ }
+
+ #[test]
+ fn test_echo_expansion() {
+ assert_echo_expands_to("a", "a");
+ assert_echo_expands_to(r"\M", "\\M");
+ assert_echo_expands_to(r"a\nb", "a\nb");
+ assert_echo_expands_to(r"\a", "\x07");
+ assert_echo_expands_to(r"\b", "\x08");
+ assert_echo_expands_to(r"\e", "\x1b");
+ assert_echo_expands_to(r"\f", "\x0c");
+ assert_echo_expands_to(r"\n", "\n");
+ assert_echo_expands_to(r"\r", "\r");
+ assert_echo_expands_to(r"\t", "\t");
+ assert_echo_expands_to(r"\v", "\x0b");
+ assert_echo_expands_to(r"\\", "\\");
+ assert_echo_expands_to(r"\'", "\\'");
+ assert_echo_expands_to(r#"\""#, r#"\""#);
+ assert_echo_expands_to(r"\?", "\\?");
+ assert_echo_expands_to(r"\0", "\0");
+ assert_echo_expands_to(r"\00", "\0");
+ assert_echo_expands_to(r"\000", "\0");
+ assert_echo_expands_to(r"\081", "\081");
+ assert_echo_expands_to(r"\0101", "A");
+ assert_echo_expands_to(r"abc\", "abc\\");
+ assert_echo_expands_to(r"\x41", "A");
+ assert_echo_expands_to(r"\xf0\x9f\x90\x8d", "🐍");
+ assert_echo_expands_to(r"\u2620", "☠");
+ assert_echo_expands_to(r"\U0001f602", "😂");
+ }
+}
diff --git a/crates/brush-core-vendored/src/expansion.rs b/crates/brush-core-vendored/src/expansion.rs
new file mode 100644
index 000000000..090230b14
--- /dev/null
+++ b/crates/brush-core-vendored/src/expansion.rs
@@ -0,0 +1,1910 @@
+//! Word expansion utilities.
+
+use std::borrow::Cow;
+use std::cmp::min;
+
+use brush_parser::ast;
+use brush_parser::word::ParameterTransformOp;
+use brush_parser::word::SubstringMatchKind;
+use itertools::Itertools;
+
+use crate::ExecutionParameters;
+use crate::arithmetic;
+use crate::arithmetic::ExpandAndEvaluate;
+use crate::braceexpansion;
+use crate::commands;
+use crate::env;
+use crate::error;
+use crate::escape;
+use crate::patterns;
+use crate::prompt;
+use crate::shell::Shell;
+use crate::sys;
+use crate::trace_categories;
+use crate::variables::ShellValueUnsetType;
+use crate::variables::ShellVariable;
+use crate::variables::{self, ShellValue};
+
+#[derive(Debug)]
+struct Expansion {
+ fields: Vec,
+ concatenate: bool,
+ from_array: bool,
+ undefined: bool,
+}
+
+impl Default for Expansion {
+ fn default() -> Self {
+ Self {
+ fields: vec![],
+ concatenate: true,
+ from_array: false,
+ undefined: false,
+ }
+ }
+}
+
+impl From for String {
+ fn from(value: Expansion) -> Self {
+ // TODO: Use IFS instead for separator?
+ value.fields.into_iter().map(Self::from).join(" ")
+ }
+}
+
+impl From for Expansion {
+ fn from(value: String) -> Self {
+ Self {
+ fields: vec![WordField::from(value)],
+ ..Self::default()
+ }
+ }
+}
+
+impl From for Expansion {
+ fn from(piece: ExpansionPiece) -> Self {
+ Self {
+ fields: vec![WordField::from(piece)],
+ ..Self::default()
+ }
+ }
+}
+
+impl Expansion {
+ fn classify(&self) -> ParameterState {
+ let non_empty = self
+ .fields
+ .iter()
+ .any(|field| field.0.iter().any(|piece| !piece.as_str().is_empty()));
+
+ if self.undefined {
+ ParameterState::Undefined
+ } else if non_empty {
+ ParameterState::NonZeroLength
+ } else {
+ ParameterState::DefinedEmptyString
+ }
+ }
+
+ fn undefined() -> Self {
+ Self {
+ fields: vec![WordField::from(String::new())],
+ concatenate: true,
+ undefined: true,
+ from_array: false,
+ }
+ }
+
+ fn polymorphic_len(&self) -> usize {
+ if self.from_array {
+ self.fields.len()
+ } else {
+ self.fields.iter().fold(0, |acc, field| acc + field.len())
+ }
+ }
+
+ fn polymorphic_subslice(&self, index: usize, end: usize) -> Self {
+ let len = end - index;
+
+ // If we came from an array, then interpret `index` and `end` as indices
+ // into the elements.
+ if self.from_array {
+ let actual_len = min(len, self.fields.len() - index);
+ let fields = self.fields[index..(index + actual_len)].to_vec();
+
+ Self {
+ fields,
+ concatenate: self.concatenate,
+ undefined: self.undefined,
+ from_array: self.from_array,
+ }
+ } else {
+ // Otherwise, interpret `index` and `end` as indices into the string contents.
+ let mut fields = vec![];
+
+ // Keep track of how far away the interesting data is from the current read offset.
+ let mut dist_to_slice = index;
+ // Keep track of how many characters are left to be copied.
+ let mut left = len;
+
+ // Go through fields, copying the interesting parts.
+ for field in &self.fields {
+ let mut pieces = vec![];
+
+ for piece in &field.0 {
+ // Stop once we've extracted enough characters.
+ if left == 0 {
+ break;
+ }
+
+ // Get the inner string of the piece, and figure out how many
+ // characters are in it; make sure to get the *character count*
+ // and not just call `.len()` to get the byte count.
+ let piece_str = piece.as_str();
+ let piece_char_count = piece_str.chars().count();
+
+ // If the interesting data isn't even in this piece yet, then
+ // continue until we find it.
+ if dist_to_slice >= piece_char_count {
+ dist_to_slice -= piece_char_count;
+ continue;
+ }
+
+ // Figure out how far into this piece we're interested in copying.
+ let desired_offset_into_this_piece = dist_to_slice;
+ // Figure out how many characters we're going to use from *this* piece.
+ let len_from_this_piece =
+ min(left, piece_char_count - desired_offset_into_this_piece);
+
+ let new_piece = match piece {
+ ExpansionPiece::Unsplittable(s) => ExpansionPiece::Unsplittable(
+ s.chars()
+ .skip(desired_offset_into_this_piece)
+ .take(len_from_this_piece)
+ .collect(),
+ ),
+ ExpansionPiece::Splittable(s) => ExpansionPiece::Splittable(
+ s.chars()
+ .skip(desired_offset_into_this_piece)
+ .take(len_from_this_piece)
+ .collect(),
+ ),
+ };
+
+ pieces.push(new_piece);
+
+ left -= len_from_this_piece;
+ dist_to_slice = 0;
+ }
+
+ if !pieces.is_empty() {
+ fields.push(WordField(pieces));
+ }
+ }
+
+ Self {
+ fields,
+ concatenate: self.concatenate,
+ undefined: self.undefined,
+ from_array: self.from_array,
+ }
+ }
+ }
+}
+
+#[derive(Clone, Debug, Default, PartialEq)]
+struct WordField(Vec);
+
+impl WordField {
+ pub const fn new() -> Self {
+ Self(vec![])
+ }
+
+ pub fn len(&self) -> usize {
+ self.0.iter().fold(0, |acc, piece| acc + piece.len())
+ }
+}
+
+impl From for String {
+ fn from(field: WordField) -> Self {
+ field.0.into_iter().map(Self::from).collect()
+ }
+}
+
+impl From for patterns::Pattern {
+ fn from(value: WordField) -> Self {
+ let pieces: Vec<_> = value
+ .0
+ .into_iter()
+ .map(patterns::PatternPiece::from)
+ .collect();
+
+ Self::from(pieces)
+ }
+}
+
+impl From for WordField {
+ fn from(piece: ExpansionPiece) -> Self {
+ Self(vec![piece])
+ }
+}
+
+impl From for WordField {
+ fn from(value: String) -> Self {
+ Self(vec![ExpansionPiece::Splittable(value)])
+ }
+}
+
+#[derive(Clone, Debug, PartialEq)]
+enum ExpansionPiece {
+ Unsplittable(String),
+ Splittable(String),
+}
+
+impl From for String {
+ fn from(piece: ExpansionPiece) -> Self {
+ match piece {
+ ExpansionPiece::Unsplittable(s) => s,
+ ExpansionPiece::Splittable(s) => s,
+ }
+ }
+}
+
+impl From for patterns::PatternPiece {
+ fn from(piece: ExpansionPiece) -> Self {
+ match piece {
+ ExpansionPiece::Unsplittable(s) => Self::Literal(s),
+ ExpansionPiece::Splittable(s) => Self::Pattern(s),
+ }
+ }
+}
+
+impl From for crate::regex::RegexPiece {
+ fn from(piece: ExpansionPiece) -> Self {
+ match piece {
+ ExpansionPiece::Unsplittable(s) => Self::Literal(s),
+ ExpansionPiece::Splittable(s) => Self::Pattern(s),
+ }
+ }
+}
+
+impl ExpansionPiece {
+ const fn as_str(&self) -> &str {
+ match self {
+ Self::Unsplittable(s) => s.as_str(),
+ Self::Splittable(s) => s.as_str(),
+ }
+ }
+
+ const fn len(&self) -> usize {
+ match self {
+ Self::Unsplittable(s) => s.len(),
+ Self::Splittable(s) => s.len(),
+ }
+ }
+
+ fn make_unsplittable(self) -> Self {
+ match self {
+ Self::Unsplittable(_) => self,
+ Self::Splittable(s) => Self::Unsplittable(s),
+ }
+ }
+}
+
+enum ParameterState {
+ Undefined,
+ DefinedEmptyString,
+ NonZeroLength,
+}
+
+pub(crate) async fn basic_expand_pattern(
+ shell: &mut Shell,
+ params: &ExecutionParameters,
+ word: &ast::Word,
+) -> Result {
+ let mut expander = WordExpander::new(shell, params);
+ expander.basic_expand_pattern(&word.flatten()).await
+}
+
+pub(crate) async fn basic_expand_regex(
+ shell: &mut Shell,
+ params: &ExecutionParameters,
+ word: &ast::Word,
+) -> Result {
+ let mut expander = WordExpander::new(shell, params);
+
+ // Brace expansion does not appear to be used in regexes.
+ expander.force_disable_brace_expansion = true;
+
+ expander.basic_expand_regex(&word.flatten()).await
+}
+
+pub(crate) async fn basic_expand_word(
+ shell: &mut Shell,
+ params: &ExecutionParameters,
+ word: &ast::Word,
+) -> Result {
+ basic_expand_str(shell, params, word.flatten().as_str()).await
+}
+
+pub(crate) async fn basic_expand_str(
+ shell: &mut Shell,
+ params: &ExecutionParameters,
+ s: &str,
+) -> Result {
+ let mut expander = WordExpander::new(shell, params);
+ expander.basic_expand_to_str(s).await
+}
+
+pub(crate) async fn basic_expand_str_without_tilde(
+ shell: &mut Shell,
+ params: &ExecutionParameters,
+ s: &str,
+) -> Result {
+ let mut expander = WordExpander::new(shell, params);
+ expander.parser_options.tilde_expansion = false;
+ expander.basic_expand_to_str(s).await
+}
+
+pub(crate) async fn full_expand_and_split_word(
+ shell: &mut Shell,
+ params: &ExecutionParameters,
+ word: &ast::Word,
+) -> Result, error::Error> {
+ full_expand_and_split_str(shell, params, word.flatten().as_str()).await
+}
+
+pub(crate) async fn full_expand_and_split_str(
+ shell: &mut Shell,
+ params: &ExecutionParameters,
+ s: &str,
+) -> Result, error::Error> {
+ let mut expander = WordExpander::new(shell, params);
+ expander.full_expand_with_splitting(s).await
+}
+
+/// Assigns a value to a named parameter.
+///
+/// # Arguments
+///
+/// * `shell` - The shell in which to perform the assignment.
+/// * `params` - The execution parameters to use during the assignment.
+/// * `name` - The name of the parameter to assign to. May be a variable name,
+/// or a more complex, assignable parameter expression (e.g., an array
+/// element).
+/// * `value` - The value to assign to the parameter.
+pub async fn assign_to_named_parameter(
+ shell: &mut Shell,
+ params: &ExecutionParameters,
+ name: &str,
+ value: String,
+) -> Result<(), error::Error> {
+ let parser_options = shell.parser_options();
+ let mut expander = WordExpander::new(shell, params);
+ let parameter = brush_parser::word::parse_parameter(name, &parser_options)?;
+ expander.assign_to_parameter(¶meter, value).await
+}
+
+struct WordExpander<'a> {
+ shell: &'a mut Shell,
+ params: &'a ExecutionParameters,
+ parser_options: brush_parser::ParserOptions,
+ force_disable_brace_expansion: bool,
+ in_double_quotes: bool,
+}
+
+impl<'a> WordExpander<'a> {
+ pub const fn new(shell: &'a mut Shell, params: &'a ExecutionParameters) -> Self {
+ let parser_options = shell.parser_options();
+ Self {
+ shell,
+ params,
+ parser_options,
+ force_disable_brace_expansion: false,
+ in_double_quotes: false,
+ }
+ }
+
+ /// Apply tilde-expansion, parameter expansion, command substitution, and arithmetic expansion.
+ pub async fn basic_expand_to_str(&mut self, word: &str) -> Result {
+ Ok(String::from(self.basic_expand(word).await?))
+ }
+
+ #[expect(clippy::ref_option)]
+ async fn basic_expand_opt_pattern(
+ &mut self,
+ word: &Option,
+ ) -> Result, error::Error> {
+ if let Some(word) = word {
+ let pattern = self
+ .basic_expand_pattern(word)
+ .await?
+ .set_extended_globbing(self.parser_options.enable_extended_globbing);
+
+ Ok(Some(pattern))
+ } else {
+ Ok(None)
+ }
+ }
+
+ async fn basic_expand_pattern(
+ &mut self,
+ word: &str,
+ ) -> Result {
+ let expansion = self.basic_expand(word).await?;
+
+ // TODO: Use IFS instead for separator?
+ #[expect(unstable_name_collisions)]
+ let pattern_pieces: Vec<_> = expansion
+ .fields
+ .into_iter()
+ .map(|field| {
+ field
+ .0
+ .into_iter()
+ .map(patterns::PatternPiece::from)
+ .collect::>()
+ })
+ .intersperse(vec![patterns::PatternPiece::Literal(String::from(" "))])
+ .flatten()
+ .collect();
+
+ let pattern = patterns::Pattern::from(pattern_pieces);
+
+ Ok(pattern)
+ }
+
+ async fn basic_expand_regex(
+ &mut self,
+ word: &str,
+ ) -> Result {
+ let expansion = self.basic_expand(word).await?;
+
+ // TODO: Use IFS instead for separator?
+ #[expect(unstable_name_collisions)]
+ let regex_pieces: Vec<_> = expansion
+ .fields
+ .into_iter()
+ .map(|field| {
+ field
+ .0
+ .into_iter()
+ .map(crate::regex::RegexPiece::from)
+ .collect::>()
+ })
+ .intersperse(vec![crate::regex::RegexPiece::Literal(String::from(" "))])
+ .flatten()
+ .collect();
+
+ Ok(crate::regex::Regex::from(regex_pieces)
+ .set_case_insensitive(self.shell.options.case_insensitive_conditionals))
+ }
+
+ /// Apply tilde-expansion, parameter expansion, command substitution, and arithmetic expansion;
+ /// yield pieces that could be further processed.
+ async fn basic_expand(&mut self, word: &str) -> Result {
+ tracing::debug!(target: trace_categories::EXPANSION, "Basic expanding: '{word}'");
+
+ // Quick short circuit to avoid more expensive parsing. The characters below are
+ // understood to be the *only* ones indicative of *possible* expansion. There's
+ // still a possibility no expansion needs to be done, but that's okay; we'll still
+ // yield a correct result.
+ if !word.contains(['$', '`', '\\', '\'', '\"', '~', '{']) {
+ return Ok(Expansion::from(ExpansionPiece::Splittable(word.to_owned())));
+ }
+
+ // Apply brace expansion first, before anything else.
+ let brace_expanded: String = self.brace_expand_if_needed(word)?.into_iter().join(" ");
+ if tracing::enabled!(target: trace_categories::EXPANSION, tracing::Level::DEBUG)
+ && brace_expanded != word
+ {
+ tracing::debug!(target: trace_categories::EXPANSION, " => brace expanded to '{brace_expanded}'");
+ }
+
+ // Expand: tildes, parameters, command substitutions, arithmetic.
+ let mut expansions = vec![];
+ for piece in brush_parser::word::parse(brace_expanded.as_str(), &self.parser_options)? {
+ let piece_expansion = self.expand_word_piece(piece.piece).await?;
+ expansions.push(piece_expansion);
+ }
+
+ let coalesced = coalesce_expansions(expansions);
+
+ Ok(coalesced)
+ }
+
+ /// Expand a word used inside a parameter expansion (like the word in ${param:+word}).
+ /// When we're already inside double-quotes, we preserve literal backslashes and quotes
+ /// (except those escaped in ways valid in double-quotes) but still expand parameters,
+ /// command substitutions, and arithmetic.
+ async fn expand_parameter_word(&mut self, word: &str) -> Result {
+ // When inside double-quotes, we need to parse the word with double-quote semantics.
+ if self.in_double_quotes {
+ // If the word already starts with a double-quote, we need to remove those quotes
+ // and expand what's inside with normal (non-double-quote) semantics.
+ if let Some(stripped) = word.strip_prefix('"') {
+ if let Some(inner) = stripped.strip_suffix('"') {
+ // Remove the surrounding double-quotes and expand the content normally
+ // This requires us to temporarily clear in_double_quotes so the inner
+ // content gets normal processing.
+ let previously_in_double_quotes = self.in_double_quotes;
+ self.in_double_quotes = false;
+
+ // Now perform the expansion and make sure to restore the previous state,
+ // even if the expansion fails.
+ let result = self.basic_expand(inner).await;
+ self.in_double_quotes = previously_in_double_quotes;
+
+ return result;
+ }
+ }
+ // Not double-quoted - wrap in double-quotes to get double-quote parsing semantics
+ let wrapped = std::format!("\"{word}\"");
+ self.basic_expand(&wrapped).await
+ } else {
+ // When not inside double-quotes, perform normal expansion with quote removal
+ self.basic_expand(word).await
+ }
+ }
+
+ fn brace_expand_if_needed(&self, word: &'a str) -> Result>, error::Error> {
+ // We perform a non-authoritative check to see if the string *may* contain braces
+ // to expand. There may be false positives, but must be no false negatives.
+ if self.force_disable_brace_expansion
+ || !self.shell.options.perform_brace_expansion
+ || !may_contain_braces_to_expand(word)
+ {
+ return Ok(vec![word.into()]);
+ }
+
+ let parse_result = brush_parser::word::parse_brace_expansions(word, &self.parser_options);
+ if parse_result.is_err() {
+ tracing::error!("failed to parse for brace expansion: {parse_result:?}");
+ return Ok(vec![word.into()]);
+ }
+
+ let brace_expansion_pieces = parse_result?;
+ if let Some(brace_expansion_pieces) = brace_expansion_pieces {
+ tracing::debug!(target: trace_categories::EXPANSION, "Brace expansion pieces: {brace_expansion_pieces:?}");
+
+ let result =
+ braceexpansion::generate_and_combine_brace_expansions(brace_expansion_pieces)
+ .into_iter()
+ .map(|s| if s.is_empty() { "\"\"".into() } else { s });
+ let result = result.map(|s| s.into()).collect();
+
+ Ok(result)
+ } else {
+ Ok(vec![word.into()])
+ }
+ }
+
+ /// Apply tilde-expansion, parameter expansion, command substitution, and arithmetic expansion;
+ /// then perform field splitting and pathname expansion.
+ pub async fn full_expand_with_splitting(
+ &mut self,
+ word: &str,
+ ) -> Result, error::Error> {
+ // Perform basic expansion first.
+ let basic_expansion = self.basic_expand(word).await?;
+
+ // Then split.
+ let fields: Vec = self.split_fields(basic_expansion);
+
+ // Now expand pathnames if necessary. This also unquotes as a side effect.
+ let result = fields
+ .into_iter()
+ .flat_map(|field| {
+ if self.shell.options.disable_filename_globbing {
+ vec![String::from(field)]
+ } else {
+ self.expand_pathnames_in_field(field)
+ }
+ })
+ .collect();
+
+ Ok(result)
+ }
+
+ fn split_fields(&self, expansion: Expansion) -> Vec {
+ let ifs = self.shell.ifs();
+
+ let mut fields: Vec = vec![];
+ let mut current_field = WordField::new();
+
+ // Go through the fields we have so far.
+ for existing_field in expansion.fields {
+ for piece in existing_field.0 {
+ match piece {
+ ExpansionPiece::Unsplittable(_) => current_field.0.push(piece),
+ ExpansionPiece::Splittable(s) => {
+ for c in s.chars() {
+ if ifs.contains(c) {
+ if !current_field.0.is_empty() {
+ fields.push(std::mem::take(&mut current_field));
+ }
+ } else {
+ match current_field.0.last_mut() {
+ Some(ExpansionPiece::Splittable(last)) => last.push(c),
+ Some(ExpansionPiece::Unsplittable(_)) | None => {
+ current_field
+ .0
+ .push(ExpansionPiece::Splittable(c.to_string()));
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ if !current_field.0.is_empty() {
+ fields.push(std::mem::take(&mut current_field));
+ }
+ }
+
+ fields
+ }
+
+ fn expand_pathnames_in_field(&self, field: WordField) -> Vec {
+ let pattern = patterns::Pattern::from(field.clone())
+ .set_extended_globbing(self.parser_options.enable_extended_globbing)
+ .set_case_insensitive(self.shell.options.case_insensitive_pathname_expansion);
+
+ let options = patterns::FilenameExpansionOptions {
+ require_dot_in_pattern_to_match_dot_files: !self.shell.options.glob_matches_dotfiles,
+ };
+
+ let expansions = pattern
+ .expand(
+ self.shell.working_dir(),
+ Some(&patterns::Pattern::accept_all_expand_filter),
+ &options,
+ )
+ .unwrap_or_default();
+
+ if expansions.is_empty() && !self.shell.options.expand_non_matching_patterns_to_null {
+ vec![String::from(field)]
+ } else {
+ expansions
+ }
+ }
+
+ #[async_recursion::async_recursion]
+ async fn expand_word_piece(
+ &mut self,
+ word_piece: brush_parser::word::WordPiece,
+ ) -> Result {
+ let expansion: Expansion = match word_piece {
+ brush_parser::word::WordPiece::Text(s) => {
+ Expansion::from(ExpansionPiece::Splittable(s))
+ }
+ brush_parser::word::WordPiece::SingleQuotedText(s) => {
+ Expansion::from(ExpansionPiece::Unsplittable(s))
+ }
+ brush_parser::word::WordPiece::AnsiCQuotedText(s) => {
+ let (expanded, _) = escape::expand_backslash_escapes(
+ s.as_str(),
+ escape::EscapeExpansionMode::AnsiCQuotes,
+ )?;
+ Expansion::from(ExpansionPiece::Unsplittable(
+ String::from_utf8_lossy(expanded.as_slice()).into_owned(),
+ ))
+ }
+ brush_parser::word::WordPiece::DoubleQuotedSequence(pieces)
+ | brush_parser::word::WordPiece::GettextDoubleQuotedSequence(pieces) => {
+ let pieces_is_empty = pieces.is_empty();
+
+ // Save the previous state and set the flag
+ let previously_in_double_quotes = self.in_double_quotes;
+ self.in_double_quotes = true;
+
+ // Process pieces; don't inspect the result yet, so we can make
+ // sure we restore the previous value of the 'in_double_quotes' flag.
+ let result = self.process_double_quoted_pieces(pieces).await;
+
+ // Restore the previous state
+ self.in_double_quotes = previously_in_double_quotes;
+
+ // Now we can inspect the result.
+ let mut fields = result?;
+
+ // If there were no pieces, then make sure we yield a single field containing an
+ // empty, unsplittable string.
+ if pieces_is_empty {
+ fields.push(WordField::from(ExpansionPiece::Unsplittable(String::new())));
+ }
+
+ Expansion {
+ fields,
+ concatenate: false,
+ undefined: false,
+ from_array: false,
+ }
+ }
+ brush_parser::word::WordPiece::TildePrefix(prefix) => Expansion::from(
+ ExpansionPiece::Unsplittable(self.expand_tilde_expression(prefix.as_str())?),
+ ),
+ brush_parser::word::WordPiece::ParameterExpansion(p) => {
+ self.expand_parameter_expr(p).await?
+ }
+ brush_parser::word::WordPiece::BackquotedCommandSubstitution(s)
+ | brush_parser::word::WordPiece::CommandSubstitution(s) => {
+ let output_str =
+ commands::invoke_command_in_subshell_and_get_output(self.shell, self.params, s)
+ .await?;
+
+ // We trim trailing newlines, per spec.
+ let trimmed = output_str.trim_end_matches('\n');
+
+ Expansion::from(ExpansionPiece::Splittable(trimmed.to_owned()))
+ }
+ brush_parser::word::WordPiece::EscapeSequence(s) => {
+ let expanded = s.strip_prefix('\\').unwrap();
+ Expansion::from(ExpansionPiece::Unsplittable(expanded.to_owned()))
+ }
+ brush_parser::word::WordPiece::ArithmeticExpression(e) => Expansion::from(
+ ExpansionPiece::Splittable(self.expand_arithmetic_expr(e).await?),
+ ),
+ };
+
+ Ok(expansion)
+ }
+
+ fn expand_tilde_expression(&self, prefix: &str) -> Result {
+ if !prefix.is_empty() {
+ Ok(sys::users::get_user_home_dir(prefix).map_or_else(
+ || std::format!("~{prefix}"),
+ |p| p.to_string_lossy().to_string(),
+ ))
+ } else if let Some(home_dir) = self.shell.home_dir() {
+ Ok(home_dir.to_string_lossy().to_string())
+ } else {
+ Err(error::ErrorKind::TildeWithoutValidHome.into())
+ }
+ }
+
+ /// Helper function to process pieces within a double-quoted sequence.
+ /// This ensures proper handling of concatenation and field building.
+ async fn process_double_quoted_pieces(
+ &mut self,
+ pieces: Vec,
+ ) -> Result, error::Error> {
+ let mut fields: Vec = vec![];
+ let concatenation_joiner = self.shell.get_ifs_first_char();
+
+ for piece in pieces {
+ let Expansion {
+ fields: this_fields,
+ concatenate,
+ ..
+ } = self.expand_word_piece(piece.piece).await?;
+
+ let fields_to_append = if concatenate {
+ #[expect(unstable_name_collisions)]
+ let mut concatenated: Vec = this_fields
+ .into_iter()
+ .map(|WordField(pieces)| {
+ pieces
+ .into_iter()
+ .map(|piece| piece.make_unsplittable())
+ .collect()
+ })
+ .intersperse(vec![ExpansionPiece::Unsplittable(
+ concatenation_joiner.to_string(),
+ )])
+ .flatten()
+ .collect();
+
+ // If there were no pieces, make sure there's an empty string after
+ // concatenation.
+ if concatenated.is_empty() {
+ concatenated.push(ExpansionPiece::Splittable(String::new()));
+ }
+
+ vec![WordField(concatenated)]
+ } else {
+ this_fields
+ };
+
+ for (i, WordField(next_pieces)) in fields_to_append.into_iter().enumerate() {
+ // Flip to unsplittable.
+ let mut next_pieces: Vec<_> = next_pieces
+ .into_iter()
+ .map(|piece| piece.make_unsplittable())
+ .collect();
+
+ if i == 0 {
+ if let Some(WordField(last_pieces)) = fields.last_mut() {
+ last_pieces.append(&mut next_pieces);
+ continue;
+ }
+ }
+
+ fields.push(WordField(next_pieces));
+ }
+ }
+
+ Ok(fields)
+ }
+
+ #[expect(clippy::too_many_lines)]
+ async fn expand_parameter_expr(
+ &mut self,
+ expr: brush_parser::word::ParameterExpr,
+ ) -> Result {
+ #[expect(clippy::cast_possible_truncation)]
+ match expr {
+ brush_parser::word::ParameterExpr::Parameter {
+ parameter,
+ indirect,
+ } => self.expand_parameter(¶meter, indirect).await,
+ brush_parser::word::ParameterExpr::UseDefaultValues {
+ parameter,
+ indirect,
+ test_type,
+ default_value,
+ } => {
+ let expanded_parameter = self.expand_parameter(¶meter, indirect).await?;
+ let default_value = default_value.as_ref().map_or_else(|| "", |v| v.as_str());
+
+ match (test_type, expanded_parameter.classify()) {
+ (_, ParameterState::NonZeroLength)
+ | (
+ brush_parser::word::ParameterTestType::Unset,
+ ParameterState::DefinedEmptyString,
+ ) => Ok(expanded_parameter),
+ _ => Ok(self.expand_parameter_word(default_value).await?),
+ }
+ }
+ brush_parser::word::ParameterExpr::AssignDefaultValues {
+ parameter,
+ indirect,
+ test_type,
+ default_value,
+ } => {
+ let expanded_parameter = self.expand_parameter(¶meter, indirect).await?;
+ let default_value = default_value.as_ref().map_or_else(|| "", |v| v.as_str());
+
+ match (test_type, expanded_parameter.classify()) {
+ (_, ParameterState::NonZeroLength)
+ | (
+ brush_parser::word::ParameterTestType::Unset,
+ ParameterState::DefinedEmptyString,
+ ) => Ok(expanded_parameter),
+ _ => {
+ let expanded_default_value =
+ String::from(self.expand_parameter_word(default_value).await?);
+ self.assign_to_parameter(¶meter, expanded_default_value.clone())
+ .await?;
+ Ok(Expansion::from(expanded_default_value))
+ }
+ }
+ }
+ brush_parser::word::ParameterExpr::IndicateErrorIfNullOrUnset {
+ parameter,
+ indirect,
+ test_type,
+ error_message,
+ } => {
+ let expanded_parameter = self.expand_parameter(¶meter, indirect).await?;
+ let error_message = error_message.as_ref().map_or_else(|| "", |v| v.as_str());
+
+ match (test_type, expanded_parameter.classify()) {
+ (_, ParameterState::NonZeroLength)
+ | (
+ brush_parser::word::ParameterTestType::Unset,
+ ParameterState::DefinedEmptyString,
+ ) => Ok(expanded_parameter),
+ _ => Err(error::ErrorKind::CheckedExpansionError(
+ self.basic_expand_to_str(error_message).await?,
+ )
+ .into()),
+ }
+ }
+ brush_parser::word::ParameterExpr::UseAlternativeValue {
+ parameter,
+ indirect,
+ test_type,
+ alternative_value,
+ } => {
+ let expanded_parameter = self.expand_parameter(¶meter, indirect).await?;
+ let alternative_value = alternative_value
+ .as_ref()
+ .map_or_else(|| "", |v| v.as_str());
+
+ match (test_type, expanded_parameter.classify()) {
+ (_, ParameterState::NonZeroLength)
+ | (
+ brush_parser::word::ParameterTestType::Unset,
+ ParameterState::DefinedEmptyString,
+ ) => Ok(self.expand_parameter_word(alternative_value).await?),
+ _ => Ok(Expansion::from(String::new())),
+ }
+ }
+ brush_parser::word::ParameterExpr::ParameterLength {
+ parameter,
+ indirect,
+ } => {
+ let expansion = self.expand_parameter(¶meter, indirect).await?;
+ Ok(Expansion::from(expansion.polymorphic_len().to_string()))
+ }
+ brush_parser::word::ParameterExpr::RemoveSmallestSuffixPattern {
+ parameter,
+ indirect,
+ pattern,
+ } => {
+ let expanded_parameter = self.expand_parameter(¶meter, indirect).await?;
+ let expanded_pattern = self.basic_expand_opt_pattern(&pattern).await?;
+ transform_expansion(expanded_parameter, async |s| {
+ patterns::remove_smallest_matching_suffix(s.as_str(), &expanded_pattern)
+ .map(|s| s.to_owned())
+ })
+ .await
+ }
+ brush_parser::word::ParameterExpr::RemoveLargestSuffixPattern {
+ parameter,
+ indirect,
+ pattern,
+ } => {
+ let expanded_parameter = self.expand_parameter(¶meter, indirect).await?;
+ let expanded_pattern = self.basic_expand_opt_pattern(&pattern).await?;
+ transform_expansion(expanded_parameter, async |s| {
+ patterns::remove_largest_matching_suffix(s.as_str(), &expanded_pattern)
+ .map(|s| s.to_owned())
+ })
+ .await
+ }
+ brush_parser::word::ParameterExpr::RemoveSmallestPrefixPattern {
+ parameter,
+ indirect,
+ pattern,
+ } => {
+ let expanded_parameter = self.expand_parameter(¶meter, indirect).await?;
+ let expanded_pattern = self.basic_expand_opt_pattern(&pattern).await?;
+
+ transform_expansion(expanded_parameter, async |s| {
+ patterns::remove_smallest_matching_prefix(s.as_str(), &expanded_pattern)
+ .map(|s| s.to_owned())
+ })
+ .await
+ }
+ brush_parser::word::ParameterExpr::RemoveLargestPrefixPattern {
+ parameter,
+ indirect,
+ pattern,
+ } => {
+ let expanded_parameter = self.expand_parameter(¶meter, indirect).await?;
+ let expanded_pattern = self.basic_expand_opt_pattern(&pattern).await?;
+
+ transform_expansion(expanded_parameter, async |s| {
+ patterns::remove_largest_matching_prefix(s.as_str(), &expanded_pattern)
+ .map(|s| s.to_owned())
+ })
+ .await
+ }
+ brush_parser::word::ParameterExpr::Substring {
+ parameter,
+ indirect,
+ offset,
+ length,
+ } => {
+ let mut expanded_parameter = self.expand_parameter(¶meter, indirect).await?;
+
+ // If this is ${@:...} then make sure $0 is in the array being sliced.
+ if matches!(
+ parameter,
+ brush_parser::word::Parameter::Special(
+ brush_parser::word::SpecialParameter::AllPositionalParameters {
+ concatenate: _
+ },
+ )
+ ) {
+ let shell_name = self
+ .shell
+ .shell_name
+ .as_ref()
+ .map_or_else(|| "", |name| name.as_str());
+
+ expanded_parameter.fields.insert(
+ 0,
+ WordField::from(ExpansionPiece::Splittable(shell_name.to_owned())),
+ );
+ }
+
+ #[expect(clippy::cast_possible_wrap)]
+ let expanded_parameter_len = expanded_parameter.polymorphic_len() as i64;
+
+ let mut expanded_offset = offset.eval(self.shell, self.params, false).await?;
+ if expanded_offset < 0 {
+ // For arrays--and only arrays--we handle negative indexes as offsets from the
+ // end of the array, with -1 referencing the last element of
+ // the array.
+ if expanded_parameter.from_array {
+ expanded_offset += expanded_parameter_len;
+
+ // If the offset is still negative, then we need to yield an empty slice.
+ // We force the offset to the end of the array.
+ if expanded_offset < 0 {
+ expanded_offset = expanded_parameter_len;
+ }
+ } else {
+ // For other values, we just treat negative indexes as 0.
+ expanded_offset = 0;
+ }
+ }
+
+ // Make sure the offset is within the bounds of the array.
+ let expanded_offset = min(expanded_offset, expanded_parameter_len);
+
+ let end_offset = if let Some(length) = length {
+ let mut expanded_length = length.eval(self.shell, self.params, false).await?;
+ if expanded_length < 0 {
+ expanded_length += expanded_parameter_len;
+ }
+
+ let expanded_length =
+ min(expanded_length, expanded_parameter_len - expanded_offset);
+
+ expanded_offset + expanded_length
+ } else {
+ expanded_parameter_len
+ };
+
+ #[expect(clippy::cast_sign_loss)]
+ Ok(expanded_parameter
+ .polymorphic_subslice(expanded_offset as usize, end_offset as usize))
+ }
+ brush_parser::word::ParameterExpr::Transform {
+ parameter,
+ indirect,
+ op: ParameterTransformOp::ToAttributeFlags,
+ } => {
+ if let (_, _, Some(var)) = self
+ .try_resolve_parameter_to_variable(¶meter, indirect)
+ .await?
+ {
+ Ok(var.attribute_flags(self.shell).into())
+ } else {
+ Ok(String::new().into())
+ }
+ }
+ brush_parser::word::ParameterExpr::Transform {
+ parameter,
+ indirect,
+ op: ParameterTransformOp::ToAssignmentLogic,
+ } => {
+ if let (Some(name), index, Some(var)) = self
+ .try_resolve_parameter_to_variable(¶meter, indirect)
+ .await?
+ {
+ let assignable_value_str =
+ var.value().to_assignable_str(index.as_deref(), self.shell);
+
+ let mut attr_str = var.attribute_flags(self.shell);
+ if attr_str.is_empty() {
+ attr_str.push('-');
+ }
+
+ match var.value() {
+ ShellValue::IndexedArray(_)
+ | ShellValue::AssociativeArray(_)
+ // TODO(dynamic): confirm this
+ | ShellValue::Dynamic { .. } => {
+ let equals_or_nothing = if assignable_value_str.is_empty() {
+ ""
+ } else {
+ "="
+ };
+
+ Ok(std::format!(
+ "declare -{attr_str} {name}{equals_or_nothing}{assignable_value_str}"
+ )
+ .into())
+ }
+ ShellValue::String(_) => {
+ Ok(std::format!("{name}={assignable_value_str}",).into())
+ }
+ ShellValue::Unset(_) => {
+ Ok(std::format!("declare -{attr_str} {name}").into())
+ }
+ }
+ } else {
+ Ok(String::new().into())
+ }
+ }
+ brush_parser::word::ParameterExpr::Transform {
+ parameter,
+ indirect,
+ op,
+ } => {
+ let expanded_parameter = self.expand_parameter(¶meter, indirect).await?;
+ let came_from_undefined = expanded_parameter.undefined;
+
+ //
+ // For typing reasons (issues with FnMut and our mut use of self), we can't use
+ // transform_expansion. Instead, we inline its logic here.
+ //
+
+ let mut transformed_fields = vec![];
+ for field in expanded_parameter.fields {
+ let s = String::from(field);
+ let transformed = self.apply_transform_to(&op, s, came_from_undefined).await?;
+ transformed_fields.push(WordField::from(transformed));
+ }
+
+ Ok(Expansion {
+ fields: transformed_fields,
+ concatenate: expanded_parameter.concatenate,
+ from_array: expanded_parameter.from_array,
+ undefined: expanded_parameter.undefined,
+ })
+ }
+ brush_parser::word::ParameterExpr::UppercaseFirstChar {
+ parameter,
+ indirect,
+ pattern,
+ } => {
+ let expanded_parameter = self.expand_parameter(¶meter, indirect).await?;
+ let expanded_pattern = self.basic_expand_opt_pattern(&pattern).await?;
+
+ transform_expansion(expanded_parameter, async |s| {
+ Self::uppercase_first_char(s, &expanded_pattern)
+ })
+ .await
+ }
+ brush_parser::word::ParameterExpr::UppercasePattern {
+ parameter,
+ indirect,
+ pattern,
+ } => {
+ let expanded_parameter = self.expand_parameter(¶meter, indirect).await?;
+ let expanded_pattern = self.basic_expand_opt_pattern(&pattern).await?;
+
+ transform_expansion(expanded_parameter, async |s| {
+ Self::uppercase_pattern(s.as_str(), &expanded_pattern)
+ })
+ .await
+ }
+ brush_parser::word::ParameterExpr::LowercaseFirstChar {
+ parameter,
+ indirect,
+ pattern,
+ } => {
+ let expanded_parameter = self.expand_parameter(¶meter, indirect).await?;
+ let expanded_pattern = self.basic_expand_opt_pattern(&pattern).await?;
+
+ transform_expansion(expanded_parameter, async |s| {
+ Self::lowercase_first_char(s, &expanded_pattern)
+ })
+ .await
+ }
+ brush_parser::word::ParameterExpr::LowercasePattern {
+ parameter,
+ indirect,
+ pattern,
+ } => {
+ let expanded_parameter = self.expand_parameter(¶meter, indirect).await?;
+ let expanded_pattern = self.basic_expand_opt_pattern(&pattern).await?;
+
+ transform_expansion(expanded_parameter, async |s| {
+ Self::lowercase_pattern(s.as_str(), &expanded_pattern)
+ })
+ .await
+ }
+ brush_parser::word::ParameterExpr::ReplaceSubstring {
+ parameter,
+ indirect,
+ pattern,
+ replacement,
+ match_kind,
+ } => {
+ let expanded_parameter = self.expand_parameter(¶meter, indirect).await?;
+ let expanded_pattern = self
+ .basic_expand_pattern(pattern.as_str())
+ .await?
+ .set_extended_globbing(self.parser_options.enable_extended_globbing)
+ .set_case_insensitive(self.shell.options.case_insensitive_conditionals);
+
+ // If no replacement was provided, then we replace with an empty string.
+ let replacement = replacement.unwrap_or(String::new());
+ let expanded_replacement = self.basic_expand_to_str(&replacement).await?;
+
+ let regex = expanded_pattern.to_regex(
+ matches!(match_kind, brush_parser::word::SubstringMatchKind::Prefix),
+ matches!(match_kind, brush_parser::word::SubstringMatchKind::Suffix),
+ )?;
+
+ transform_expansion(expanded_parameter, async |s| {
+ Ok(Self::replace_substring(
+ s.as_str(),
+ ®ex,
+ expanded_replacement.as_str(),
+ &match_kind,
+ ))
+ })
+ .await
+ }
+ brush_parser::word::ParameterExpr::VariableNames {
+ prefix,
+ concatenate,
+ } => {
+ if prefix.is_empty() {
+ Ok(Expansion::from(String::new()))
+ } else {
+ let matching_names = self
+ .shell
+ .env
+ .iter()
+ .filter_map(|(name, _)| {
+ if name.starts_with(prefix.as_str()) {
+ Some(name.to_owned())
+ } else {
+ None
+ }
+ })
+ .sorted();
+
+ Ok(Expansion {
+ fields: matching_names
+ .into_iter()
+ .map(|name| WordField(vec![ExpansionPiece::Splittable(name)]))
+ .collect(),
+ concatenate,
+ from_array: true,
+ undefined: false,
+ })
+ }
+ }
+ brush_parser::word::ParameterExpr::MemberKeys {
+ variable_name,
+ concatenate,
+ } => {
+ let keys = if let Some((_, var)) = self.shell.env.get(variable_name) {
+ var.value().element_keys(self.shell)
+ } else {
+ vec![]
+ };
+
+ Ok(Expansion {
+ fields: keys
+ .into_iter()
+ .map(|key| WordField(vec![ExpansionPiece::Splittable(key)]))
+ .collect(),
+ concatenate,
+ from_array: true,
+ undefined: false,
+ })
+ }
+ }
+ }
+
+ async fn assign_to_parameter(
+ &mut self,
+ parameter: &brush_parser::word::Parameter,
+ value: String,
+ ) -> Result<(), error::Error> {
+ let (variable_name, index) = match parameter {
+ brush_parser::word::Parameter::Named(name) => (name, None),
+ brush_parser::word::Parameter::NamedWithIndex { name, index } => {
+ let is_set_assoc_array = if let Some((_, var)) = self.shell.env.get(name) {
+ matches!(
+ var.value(),
+ ShellValue::AssociativeArray(_)
+ | ShellValue::Unset(ShellValueUnsetType::AssociativeArray)
+ )
+ } else {
+ false
+ };
+
+ let index_to_use = self
+ .expand_array_index(index.as_str(), is_set_assoc_array)
+ .await?;
+ (name, Some(index_to_use))
+ }
+ brush_parser::word::Parameter::Positional(_)
+ | brush_parser::word::Parameter::NamedWithAllIndices {
+ name: _,
+ concatenate: _,
+ }
+ | brush_parser::word::Parameter::Special(_) => {
+ return Err(error::ErrorKind::CannotAssignToSpecialParameter.into());
+ }
+ };
+
+ if let Some(index) = index {
+ self.shell.env.update_or_add_array_element(
+ variable_name,
+ index,
+ value,
+ |_| Ok(()),
+ env::EnvironmentLookup::Anywhere,
+ env::EnvironmentScope::Global,
+ )
+ } else {
+ self.shell.env.update_or_add(
+ variable_name,
+ variables::ShellValueLiteral::Scalar(value),
+ |_| Ok(()),
+ env::EnvironmentLookup::Anywhere,
+ env::EnvironmentScope::Global,
+ )
+ }
+ }
+
+ async fn try_resolve_parameter_to_variable(
+ &mut self,
+ parameter: &brush_parser::word::Parameter,
+ indirect: bool,
+ ) -> Result<(Option, Option, Option), error::Error> {
+ if !indirect {
+ Ok(self.try_resolve_parameter_to_variable_without_indirect(parameter))
+ } else {
+ let expansion = self.expand_parameter(parameter, false).await?;
+ let parameter_str: String = expansion.into();
+ let inner_parameter =
+ brush_parser::word::parse_parameter(parameter_str.as_str(), &self.parser_options)?;
+ Ok(self.try_resolve_parameter_to_variable_without_indirect(&inner_parameter))
+ }
+ }
+
+ fn try_resolve_parameter_to_variable_without_indirect(
+ &self,
+ parameter: &brush_parser::word::Parameter,
+ ) -> (Option, Option, Option) {
+ let (name, index) = match parameter {
+ brush_parser::word::Parameter::Positional(_)
+ | brush_parser::word::Parameter::Special(_) => (None, None),
+ brush_parser::word::Parameter::Named(name) => (Some(name.to_owned()), Some("0".into())),
+ brush_parser::word::Parameter::NamedWithIndex { name, index } => {
+ (Some(name.to_owned()), Some(index.to_owned()))
+ }
+ brush_parser::word::Parameter::NamedWithAllIndices {
+ name,
+ concatenate: _concatenate,
+ } => (Some(name.to_owned()), None),
+ };
+
+ let var = name
+ .as_ref()
+ .and_then(|name| self.shell.env.get(name).map(|(_, var)| var.clone()));
+
+ (name, index, var)
+ }
+
+ async fn expand_parameter(
+ &mut self,
+ parameter: &brush_parser::word::Parameter,
+ indirect: bool,
+ ) -> Result {
+ let expansion = self.expand_parameter_without_indirect(parameter).await?;
+ if !indirect {
+ Ok(expansion)
+ } else {
+ let parameter_str: String = expansion.into();
+ let inner_parameter =
+ brush_parser::word::parse_parameter(parameter_str.as_str(), &self.parser_options)?;
+
+ self.expand_parameter_without_indirect(&inner_parameter)
+ .await
+ }
+ }
+
+ async fn expand_parameter_without_indirect(
+ &mut self,
+ parameter: &brush_parser::word::Parameter,
+ ) -> Result {
+ match parameter {
+ brush_parser::word::Parameter::Positional(p) => {
+ if *p == 0 {
+ Ok(self
+ .expand_special_parameter(&brush_parser::word::SpecialParameter::ShellName))
+ } else if let Some(parameter) =
+ self.shell.positional_parameters.get((p - 1) as usize)
+ {
+ Ok(Expansion::from(parameter.to_owned()))
+ } else {
+ Ok(Expansion::undefined())
+ }
+ }
+ brush_parser::word::Parameter::Special(s) => Ok(self.expand_special_parameter(s)),
+ brush_parser::word::Parameter::Named(n) => {
+ if !env::valid_variable_name(n.as_str()) {
+ Err(error::ErrorKind::BadSubstitution(n.clone()).into())
+ } else if let Some((_, var)) = self.shell.env.get(n) {
+ if matches!(var.value(), ShellValue::Unset(_)) {
+ Ok(Expansion::undefined())
+ } else {
+ let value = var.value().try_get_cow_str(self.shell);
+ if let Some(value) = value {
+ Ok(Expansion::from(value.to_string()))
+ } else {
+ Ok(Expansion::undefined())
+ }
+ }
+ } else {
+ Ok(Expansion::undefined())
+ }
+ }
+ brush_parser::word::Parameter::NamedWithIndex { name, index } => {
+ // First check to see if it's an associative array.
+ let is_set_assoc_array = if let Some((_, var)) = self.shell.env.get(name) {
+ matches!(
+ var.value(),
+ ShellValue::AssociativeArray(_)
+ | ShellValue::Unset(ShellValueUnsetType::AssociativeArray)
+ )
+ } else {
+ false
+ };
+
+ // Figure out which index to use.
+ let index_to_use = self
+ .expand_array_index(index.as_str(), is_set_assoc_array)
+ .await?;
+
+ // Index into the array.
+ if let Some((_, var)) = self.shell.env.get(name) {
+ if let Ok(Some(value)) = var.value().get_at(index_to_use.as_str(), self.shell) {
+ Ok(Expansion::from(value.to_string()))
+ } else {
+ Ok(Expansion::undefined())
+ }
+ } else {
+ Ok(Expansion::undefined())
+ }
+ }
+ brush_parser::word::Parameter::NamedWithAllIndices { name, concatenate } => {
+ if let Some((_, var)) = self.shell.env.get(name) {
+ let values = var.value().element_values(self.shell);
+
+ Ok(Expansion {
+ fields: values
+ .into_iter()
+ .map(|value| WordField(vec![ExpansionPiece::Splittable(value)]))
+ .collect(),
+ concatenate: *concatenate,
+ from_array: true,
+ undefined: false,
+ })
+ } else {
+ Ok(Expansion {
+ fields: vec![],
+ concatenate: *concatenate,
+ from_array: true,
+ undefined: false,
+ })
+ }
+ }
+ }
+ }
+
+ async fn expand_array_index(
+ &mut self,
+ index: &str,
+ for_set_associative_array: bool,
+ ) -> Result {
+ let index_to_use = if for_set_associative_array {
+ self.basic_expand_to_str(index).await?
+ } else {
+ arithmetic::expand_and_eval(self.shell, self.params, index, false)
+ .await?
+ .to_string()
+ };
+
+ Ok(index_to_use)
+ }
+
+ fn expand_special_parameter(
+ &self,
+ parameter: &brush_parser::word::SpecialParameter,
+ ) -> Expansion {
+ match parameter {
+ brush_parser::word::SpecialParameter::AllPositionalParameters { concatenate } => {
+ let positional_params = self.shell.positional_parameters.iter();
+
+ Expansion {
+ fields: positional_params
+ .into_iter()
+ .map(|param| WordField(vec![ExpansionPiece::Splittable(param.to_owned())]))
+ .collect(),
+ concatenate: *concatenate,
+ from_array: true,
+ undefined: false,
+ }
+ }
+ brush_parser::word::SpecialParameter::PositionalParameterCount => {
+ Expansion::from(self.shell.positional_parameters.len().to_string())
+ }
+ brush_parser::word::SpecialParameter::LastExitStatus => {
+ Expansion::from(self.shell.last_result().to_string())
+ }
+ brush_parser::word::SpecialParameter::CurrentOptionFlags => {
+ Expansion::from(self.shell.options.option_flags())
+ }
+ brush_parser::word::SpecialParameter::ProcessId => {
+ Expansion::from(std::process::id().to_string())
+ }
+ brush_parser::word::SpecialParameter::LastBackgroundProcessId => {
+ if let Some(job) = self.shell.jobs.current_job() {
+ if let Some(pid) = job.representative_pid() {
+ return Expansion::from(pid.to_string());
+ }
+ }
+ Expansion::from(String::new())
+ }
+ brush_parser::word::SpecialParameter::ShellName => Expansion::from(
+ self.shell
+ .shell_name
+ .as_ref()
+ .map_or_else(String::new, |name| name.clone()),
+ ),
+ }
+ }
+
+ async fn expand_arithmetic_expr(
+ &mut self,
+ expr: brush_parser::ast::UnexpandedArithmeticExpr,
+ ) -> Result {
+ let value = expr.eval(self.shell, self.params, false).await?;
+ Ok(value.to_string())
+ }
+
+ #[allow(clippy::unwrap_in_result)]
+ #[expect(clippy::ref_option)]
+ fn uppercase_first_char(
+ s: String,
+ pattern: &Option,
+ ) -> Result {
+ if let Some(first_char) = s.chars().next() {
+ let applicable = if let Some(pattern) = pattern {
+ pattern.is_empty() || pattern.exactly_matches(first_char.to_string().as_str())?
+ } else {
+ true
+ };
+
+ if applicable {
+ let mut result = String::new();
+ result.push(first_char.to_uppercase().next().unwrap());
+ result.push_str(s.get(1..).unwrap());
+ Ok(result)
+ } else {
+ Ok(s)
+ }
+ } else {
+ Ok(s)
+ }
+ }
+
+ #[allow(clippy::unwrap_in_result)]
+ #[expect(clippy::ref_option)]
+ fn lowercase_first_char(
+ s: String,
+ pattern: &Option,
+ ) -> Result {
+ if let Some(first_char) = s.chars().next() {
+ let applicable = if let Some(pattern) = pattern {
+ pattern.is_empty() || pattern.exactly_matches(first_char.to_string().as_str())?
+ } else {
+ true
+ };
+
+ if applicable {
+ let mut result = String::new();
+ result.push(first_char.to_lowercase().next().unwrap());
+ result.push_str(s.get(1..).unwrap());
+ Ok(result)
+ } else {
+ Ok(s)
+ }
+ } else {
+ Ok(s)
+ }
+ }
+
+ #[expect(clippy::ref_option)]
+ fn uppercase_pattern(
+ s: &str,
+ pattern: &Option,
+ ) -> Result {
+ if let Some(pattern) = pattern {
+ if !pattern.is_empty() {
+ let regex = pattern.to_regex(false, false)?;
+ let result = regex.replace_all(s.as_ref(), |caps: &fancy_regex::Captures<'_>| {
+ caps[0].to_uppercase()
+ });
+ Ok(result.into_owned())
+ } else {
+ Ok(s.to_uppercase())
+ }
+ } else {
+ Ok(s.to_uppercase())
+ }
+ }
+
+ #[expect(clippy::ref_option)]
+ fn lowercase_pattern(
+ s: &str,
+ pattern: &Option,
+ ) -> Result {
+ if let Some(pattern) = pattern {
+ if !pattern.is_empty() {
+ let regex = pattern.to_regex(false, false)?;
+ let result = regex.replace_all(s.as_ref(), |caps: &fancy_regex::Captures<'_>| {
+ caps[0].to_lowercase()
+ });
+ Ok(result.into_owned())
+ } else {
+ Ok(s.to_lowercase())
+ }
+ } else {
+ Ok(s.to_lowercase())
+ }
+ }
+
+ fn replace_substring(
+ s: &str,
+ regex: &fancy_regex::Regex,
+ replacement: &str,
+ match_kind: &SubstringMatchKind,
+ ) -> String {
+ match match_kind {
+ brush_parser::word::SubstringMatchKind::Prefix
+ | brush_parser::word::SubstringMatchKind::Suffix
+ | brush_parser::word::SubstringMatchKind::FirstOccurrence => {
+ regex.replace(s, replacement).into_owned()
+ }
+
+ brush_parser::word::SubstringMatchKind::Anywhere => {
+ regex.replace_all(s, replacement).into_owned()
+ }
+ }
+ }
+
+ async fn apply_transform_to(
+ &mut self,
+ op: &ParameterTransformOp,
+ s: String,
+ came_from_undefined: bool,
+ ) -> Result {
+ match op {
+ brush_parser::word::ParameterTransformOp::PromptExpand => {
+ prompt::expand_prompt(self.shell, self.params, s).await
+ }
+ brush_parser::word::ParameterTransformOp::CapitalizeInitial => {
+ Ok(to_initial_capitals(s.as_str()))
+ }
+ brush_parser::word::ParameterTransformOp::ExpandEscapeSequences => {
+ let (result, _) = escape::expand_backslash_escapes(
+ s.as_str(),
+ escape::EscapeExpansionMode::AnsiCQuotes,
+ )?;
+ Ok(String::from_utf8_lossy(result.as_slice()).into_owned())
+ }
+ brush_parser::word::ParameterTransformOp::PossiblyQuoteWithArraysExpanded {
+ separate_words: _separate_words,
+ } => {
+ if came_from_undefined {
+ Ok(String::new())
+ } else {
+ // TODO: This isn't right for arrays.
+ // TODO: This doesn't honor 'separate_words'
+ Ok(escape::force_quote(
+ s.as_str(),
+ escape::QuoteMode::SingleQuote,
+ ))
+ }
+ }
+ brush_parser::word::ParameterTransformOp::Quoted => {
+ if came_from_undefined {
+ Ok(String::new())
+ } else {
+ Ok(escape::force_quote(
+ s.as_str(),
+ escape::QuoteMode::SingleQuote,
+ ))
+ }
+ }
+ brush_parser::word::ParameterTransformOp::ToLowerCase => Ok(s.to_lowercase()),
+ brush_parser::word::ParameterTransformOp::ToUpperCase => Ok(s.to_uppercase()),
+ brush_parser::word::ParameterTransformOp::ToAssignmentLogic
+ | brush_parser::word::ParameterTransformOp::ToAttributeFlags => {
+ unreachable!("covered in caller")
+ }
+ }
+ }
+}
+
+fn coalesce_expansions(expansions: Vec) -> Expansion {
+ expansions
+ .into_iter()
+ .fold(Expansion::default(), |mut acc, expansion| {
+ for (i, mut field) in expansion.fields.into_iter().enumerate() {
+ match acc.fields.last_mut() {
+ Some(last) if i == 0 => {
+ last.0.append(&mut field.0);
+ }
+ _ => acc.fields.push(field),
+ }
+ }
+
+ // TODO: What if expansions have different concatenation values?
+ acc.concatenate = expansion.concatenate;
+ acc.from_array = expansion.from_array;
+
+ acc
+ })
+}
+
+fn to_initial_capitals(s: &str) -> String {
+ let mut result = String::new();
+ let mut capitalize_next = true;
+
+ for c in s.chars() {
+ if c.is_whitespace() {
+ capitalize_next = true;
+ result.push(c);
+ } else if capitalize_next {
+ result.push_str(c.to_uppercase().to_string().as_str());
+ capitalize_next = false;
+ } else {
+ result.push(c);
+ }
+ }
+
+ result
+}
+
+async fn transform_expansion(
+ expansion: Expansion,
+ mut f: F,
+) -> Result
+where
+ F: FnMut(String) -> FReturn,
+ FReturn: Future>,
+{
+ let mut transformed_fields = vec![];
+ for field in expansion.fields {
+ let transformed_field = WordField::from(f(String::from(field)).await?);
+ transformed_fields.push(transformed_field);
+ }
+
+ Ok(Expansion {
+ fields: transformed_fields,
+ concatenate: expansion.concatenate,
+ from_array: expansion.from_array,
+ undefined: expansion.undefined,
+ })
+}
+
+fn may_contain_braces_to_expand(s: &str) -> bool {
+ // This is a completely inaccurate but quick heuristic used to see if
+ // it's even worth properly parsing the string to find brace expressions.
+ // It's mostly used to avoid more expensive parsing just because we've
+ // encountered a brace used in a parameter expansion.
+ let mut last_was_unescaped_dollar_sign = false;
+ let mut last_was_escape = false;
+ let mut saw_opening_brace = false;
+ let mut saw_closing_brace = false;
+ for c in s.chars() {
+ if !last_was_unescaped_dollar_sign {
+ if c == '{' {
+ saw_opening_brace = true;
+ } else if c == '}' {
+ saw_closing_brace = true;
+ if saw_opening_brace {
+ return true;
+ }
+ }
+ }
+
+ last_was_unescaped_dollar_sign = !last_was_escape && c == '$';
+ last_was_escape = c == '\\';
+ }
+
+ saw_opening_brace && saw_closing_brace
+}
+
+#[expect(clippy::panic_in_result_fn)]
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use anyhow::Result;
+
+ #[tokio::test]
+ async fn test_full_expansion() -> Result<()> {
+ let mut shell = crate::shell::Shell::builder().build().await?;
+ let params = shell.default_exec_params();
+
+ assert_eq!(
+ full_expand_and_split_str(&mut shell, ¶ms, "\"\"").await?,
+ vec![""]
+ );
+ assert_eq!(
+ full_expand_and_split_str(&mut shell, ¶ms, "a b").await?,
+ vec!["a", "b"]
+ );
+ assert_eq!(
+ full_expand_and_split_str(&mut shell, ¶ms, "ab").await?,
+ vec!["ab"]
+ );
+ assert_eq!(
+ full_expand_and_split_str(&mut shell, ¶ms, r#""a b""#).await?,
+ vec!["a b"]
+ );
+ assert_eq!(
+ full_expand_and_split_str(&mut shell, ¶ms, "").await?,
+ Vec::::new()
+ );
+ assert_eq!(
+ full_expand_and_split_str(&mut shell, ¶ms, "$@").await?,
+ Vec::::new()
+ );
+ assert_eq!(
+ full_expand_and_split_str(&mut shell, ¶ms, "$*").await?,
+ Vec::::new()
+ );
+
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn test_brace_expansion() -> Result<()> {
+ let mut shell = crate::shell::Shell::builder().build().await?;
+ let params = shell.default_exec_params();
+ let expander = WordExpander::new(&mut shell, ¶ms);
+
+ assert_eq!(expander.brace_expand_if_needed("abc")?, ["abc"]);
+ assert_eq!(expander.brace_expand_if_needed("a{,b}d")?, ["ad", "abd"]);
+ assert_eq!(expander.brace_expand_if_needed("a{b,c}d")?, ["abd", "acd"]);
+ assert_eq!(
+ expander.brace_expand_if_needed("a{1..3}d")?,
+ ["a1d", "a2d", "a3d"]
+ );
+ assert_eq!(
+ expander.brace_expand_if_needed(r#""{a,b}""#)?,
+ [r#""{a,b}""#]
+ );
+ assert_eq!(expander.brace_expand_if_needed("a{}b")?, ["a{}b"]);
+ assert_eq!(expander.brace_expand_if_needed("a{ }b")?, ["a{ }b"]);
+ assert_eq!(
+ expander.brace_expand_if_needed("{a,b{1,2}}")?,
+ ["a", "b1", "b2"]
+ );
+
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn test_field_splitting() -> Result<()> {
+ let mut shell = crate::shell::Shell::builder().build().await?;
+ let params = shell.default_exec_params();
+ let expander = WordExpander::new(&mut shell, ¶ms);
+
+ let expansion = Expansion {
+ fields: vec![
+ WordField(vec![ExpansionPiece::Unsplittable("A".into())]),
+ WordField(vec![ExpansionPiece::Unsplittable(String::new())]),
+ ],
+ ..Expansion::default()
+ };
+
+ let fields = expander.split_fields(expansion);
+
+ assert_eq!(
+ fields,
+ vec![
+ WordField(vec![ExpansionPiece::Unsplittable(String::from("A"))]),
+ WordField(vec![ExpansionPiece::Unsplittable(String::new())])
+ ]
+ );
+
+ Ok(())
+ }
+
+ #[test]
+ fn test_to_initial_capitals() {
+ assert_eq!(to_initial_capitals("ab bc cd"), String::from("Ab Bc Cd"));
+ assert_eq!(to_initial_capitals(" a "), String::from(" A "));
+ assert_eq!(to_initial_capitals(""), String::new());
+ }
+}
diff --git a/crates/brush-core-vendored/src/extendedtests.rs b/crates/brush-core-vendored/src/extendedtests.rs
new file mode 100644
index 000000000..e6435c175
--- /dev/null
+++ b/crates/brush-core-vendored/src/extendedtests.rs
@@ -0,0 +1,609 @@
+use brush_parser::ast;
+use std::path::Path;
+
+use crate::{
+ ExecutionParameters, Shell, ShellFd, arithmetic, env, error, escape, expansion, namedoptions,
+ patterns,
+ sys::{
+ fs::{MetadataExt, PathExt},
+ users,
+ },
+ variables::{self, ArrayLiteral},
+};
+
+#[async_recursion::async_recursion]
+pub(crate) async fn eval_extended_test_expr(
+ expr: &ast::ExtendedTestExpr,
+ shell: &mut Shell,
+ params: &ExecutionParameters,
+) -> Result {
+ match expr {
+ ast::ExtendedTestExpr::UnaryTest(op, operand) => {
+ apply_unary_predicate(op, operand, shell, params).await
+ }
+ ast::ExtendedTestExpr::BinaryTest(op, left, right) => {
+ apply_binary_predicate(op, left, right, shell, params).await
+ }
+ ast::ExtendedTestExpr::And(left, right) => {
+ let result = eval_extended_test_expr(left, shell, params).await?
+ && eval_extended_test_expr(right, shell, params).await?;
+ Ok(result)
+ }
+ ast::ExtendedTestExpr::Or(left, right) => {
+ let result = eval_extended_test_expr(left, shell, params).await?
+ || eval_extended_test_expr(right, shell, params).await?;
+ Ok(result)
+ }
+ ast::ExtendedTestExpr::Not(expr) => {
+ let result = !eval_extended_test_expr(expr, shell, params).await?;
+ Ok(result)
+ }
+ ast::ExtendedTestExpr::Parenthesized(expr) => {
+ eval_extended_test_expr(expr, shell, params).await
+ }
+ }
+}
+
+async fn apply_unary_predicate(
+ op: &ast::UnaryPredicate,
+ operand: &ast::Word,
+ shell: &mut Shell,
+ params: &ExecutionParameters,
+) -> Result {
+ let expanded_operand = expansion::basic_expand_word(shell, params, operand).await?;
+
+ if shell.options.print_commands_and_arguments {
+ shell
+ .trace_command(
+ params,
+ std::format!(
+ "[[ {op} {} ]]",
+ escape::quote_if_needed(&expanded_operand, escape::QuoteMode::SingleQuote)
+ ),
+ )
+ .await?;
+ }
+
+ apply_unary_predicate_to_str(op, expanded_operand.as_str(), shell, params)
+}
+
+#[expect(clippy::too_many_lines)]
+pub(crate) fn apply_unary_predicate_to_str(
+ op: &ast::UnaryPredicate,
+ operand: &str,
+ shell: &Shell,
+ params: &ExecutionParameters,
+) -> Result {
+ match op {
+ ast::UnaryPredicate::StringHasNonZeroLength => Ok(!operand.is_empty()),
+ ast::UnaryPredicate::StringHasZeroLength => Ok(operand.is_empty()),
+ ast::UnaryPredicate::FileExists => {
+ let path = shell.absolute_path(Path::new(operand));
+ Ok(path.exists())
+ }
+ ast::UnaryPredicate::FileExistsAndIsBlockSpecialFile => {
+ let path = shell.absolute_path(Path::new(operand));
+ Ok(path.exists_and_is_block_device())
+ }
+ ast::UnaryPredicate::FileExistsAndIsCharSpecialFile => {
+ let path = shell.absolute_path(Path::new(operand));
+ Ok(path.exists_and_is_char_device())
+ }
+ ast::UnaryPredicate::FileExistsAndIsDir => {
+ let path = shell.absolute_path(Path::new(operand));
+ Ok(path.is_dir())
+ }
+ ast::UnaryPredicate::FileExistsAndIsRegularFile => {
+ let path = shell.absolute_path(Path::new(operand));
+ Ok(path.is_file())
+ }
+ ast::UnaryPredicate::FileExistsAndIsSetgid => {
+ let path = shell.absolute_path(Path::new(operand));
+ Ok(path.exists_and_is_setgid())
+ }
+ ast::UnaryPredicate::FileExistsAndIsSymlink => {
+ let path = shell.absolute_path(Path::new(operand));
+ Ok(path.is_symlink())
+ }
+ ast::UnaryPredicate::FileExistsAndHasStickyBit => {
+ let path = shell.absolute_path(Path::new(operand));
+ Ok(path.exists_and_is_sticky_bit())
+ }
+ ast::UnaryPredicate::FileExistsAndIsFifo => {
+ let path = shell.absolute_path(Path::new(operand));
+ Ok(path.exists_and_is_fifo())
+ }
+ ast::UnaryPredicate::FileExistsAndIsReadable => {
+ let path = shell.absolute_path(Path::new(operand));
+ Ok(path.readable())
+ }
+ ast::UnaryPredicate::FileExistsAndIsNotZeroLength => {
+ let path = shell.absolute_path(Path::new(operand));
+ if let Ok(metadata) = path.metadata() {
+ Ok(metadata.len() > 0)
+ } else {
+ Ok(false)
+ }
+ }
+ ast::UnaryPredicate::FdIsOpenTerminal => {
+ if let Ok(fd) = operand.parse::() {
+ if let Some(open_file) = params.try_fd(shell, fd) {
+ Ok(open_file.is_term())
+ } else {
+ Ok(false)
+ }
+ } else {
+ Ok(false)
+ }
+ }
+ ast::UnaryPredicate::FileExistsAndIsSetuid => {
+ let path = shell.absolute_path(Path::new(operand));
+ Ok(path.exists_and_is_setuid())
+ }
+ ast::UnaryPredicate::FileExistsAndIsWritable => {
+ let path = shell.absolute_path(Path::new(operand));
+ Ok(path.writable())
+ }
+ ast::UnaryPredicate::FileExistsAndIsExecutable => {
+ let path = shell.absolute_path(Path::new(operand));
+ Ok(path.executable())
+ }
+ ast::UnaryPredicate::FileExistsAndOwnedByEffectiveGroupId => {
+ let path = shell.absolute_path(Path::new(operand));
+ if !path.exists() {
+ return Ok(false);
+ }
+
+ let md = path.metadata()?;
+ Ok(md.gid() == users::get_effective_gid()?)
+ }
+ ast::UnaryPredicate::FileExistsAndModifiedSinceLastRead => {
+ error::unimp("unary extended test predicate: FileExistsAndModifiedSinceLastRead")
+ }
+ ast::UnaryPredicate::FileExistsAndOwnedByEffectiveUserId => {
+ let path = shell.absolute_path(Path::new(operand));
+ if !path.exists() {
+ return Ok(false);
+ }
+
+ let md = path.metadata()?;
+ Ok(md.uid() == users::get_effective_uid()?)
+ }
+ ast::UnaryPredicate::FileExistsAndIsSocket => {
+ let path = shell.absolute_path(Path::new(operand));
+ Ok(path.exists_and_is_socket())
+ }
+ ast::UnaryPredicate::ShellOptionEnabled => {
+ let shopt_name = operand;
+ if let Some(option) =
+ namedoptions::options(namedoptions::ShellOptionKind::SetO).get(shopt_name)
+ {
+ Ok(option.get(&shell.options))
+ } else {
+ Ok(false)
+ }
+ }
+ ast::UnaryPredicate::ShellVariableIsSetAndAssigned => Ok(shell.env.is_set(operand)),
+ ast::UnaryPredicate::ShellVariableIsSetAndNameRef => match shell.env.get(operand) {
+ Some((_, reffed)) => Ok(reffed.value().is_set() && reffed.is_treated_as_nameref()),
+ None => Ok(false),
+ },
+ }
+}
+
+#[expect(clippy::too_many_lines)]
+async fn apply_binary_predicate(
+ op: &ast::BinaryPredicate,
+ left: &ast::Word,
+ right: &ast::Word,
+ shell: &mut Shell,
+ params: &ExecutionParameters,
+) -> Result {
+ match op {
+ ast::BinaryPredicate::StringMatchesRegex => {
+ let s = expansion::basic_expand_word(shell, params, left).await?;
+ let regex = expansion::basic_expand_regex(shell, params, right)
+ .await?
+ .set_multiline(true);
+
+ if shell.options.print_commands_and_arguments {
+ shell
+ .trace_command(params, std::format!("[[ {s} {op} {right} ]]"))
+ .await?;
+ }
+
+ let (matches, captures) = match regex.matches(s.as_str()) {
+ Ok(Some(captures)) => (true, captures),
+ Ok(None) => (false, vec![]),
+ // If we can't compile the regex, don't abort the whole operation but make sure to
+ // report it.
+ // TODO: Docs indicate we should yield 2 on an invalid regex (not 1).
+ Err(e) => {
+ tracing::warn!("error using regex: {}", e);
+ (false, vec![])
+ }
+ };
+
+ let captures_value = variables::ShellValueLiteral::Array(ArrayLiteral(
+ captures
+ .into_iter()
+ .map(|c| (None, c.unwrap_or_default()))
+ .collect(),
+ ));
+
+ shell.env.update_or_add(
+ "BASH_REMATCH",
+ captures_value,
+ |_| Ok(()),
+ env::EnvironmentLookup::Anywhere,
+ env::EnvironmentScope::Global,
+ )?;
+
+ Ok(matches)
+ }
+ ast::BinaryPredicate::StringExactlyMatchesString => {
+ let left = expansion::basic_expand_word(shell, params, left).await?;
+ let right = expansion::basic_expand_word(shell, params, right).await?;
+
+ if shell.options.print_commands_and_arguments {
+ shell
+ .trace_command(params, std::format!("[[ {left} {op} {right} ]]"))
+ .await?;
+ }
+
+ Ok(left == right)
+ }
+ ast::BinaryPredicate::StringDoesNotExactlyMatchString => {
+ let left = expansion::basic_expand_word(shell, params, left).await?;
+ let right = expansion::basic_expand_word(shell, params, right).await?;
+
+ if shell.options.print_commands_and_arguments {
+ shell
+ .trace_command(params, std::format!("[[ {left} {op} {right} ]]"))
+ .await?;
+ }
+
+ Ok(left != right)
+ }
+ ast::BinaryPredicate::StringContainsSubstring => {
+ let s = expansion::basic_expand_word(shell, params, left).await?;
+ let substring = expansion::basic_expand_word(shell, params, right).await?;
+
+ if shell.options.print_commands_and_arguments {
+ shell
+ .trace_command(params, std::format!("[[ {s} {op} {substring} ]]"))
+ .await?;
+ }
+
+ Ok(s.contains(substring.as_str()))
+ }
+ ast::BinaryPredicate::FilesReferToSameDeviceAndInodeNumbers => {
+ let left = expansion::basic_expand_word(shell, params, left).await?;
+ let right = expansion::basic_expand_word(shell, params, right).await?;
+
+ if shell.options.print_commands_and_arguments {
+ shell
+ .trace_command(params, std::format!("[[ {left} {op} {right} ]]"))
+ .await?;
+ }
+
+ files_refer_to_same_device_and_inode_numbers(shell, left, right)
+ }
+ ast::BinaryPredicate::LeftFileIsNewerOrExistsWhenRightDoesNot => {
+ let left = expansion::basic_expand_word(shell, params, left).await?;
+ let right = expansion::basic_expand_word(shell, params, right).await?;
+
+ if shell.options.print_commands_and_arguments {
+ shell
+ .trace_command(params, std::format!("[[ {left} {op} {right} ]]"))
+ .await?;
+ }
+
+ left_file_is_newer_or_exists_when_right_does_not(shell, left, right)
+ }
+ ast::BinaryPredicate::LeftFileIsOlderOrDoesNotExistWhenRightDoes => {
+ let left = expansion::basic_expand_word(shell, params, left).await?;
+ let right = expansion::basic_expand_word(shell, params, right).await?;
+
+ if shell.options.print_commands_and_arguments {
+ shell
+ .trace_command(params, std::format!("[[ {left} {op} {right} ]]"))
+ .await?;
+ }
+
+ left_file_is_older_or_does_not_exist_when_right_does(shell, left, right)
+ }
+ ast::BinaryPredicate::LeftSortsBeforeRight => {
+ let left = expansion::basic_expand_word(shell, params, left).await?;
+ let right = expansion::basic_expand_word(shell, params, right).await?;
+
+ if shell.options.print_commands_and_arguments {
+ shell
+ .trace_command(params, std::format!("[[ {left} {op} {right} ]]"))
+ .await?;
+ }
+
+ // TODO: According to docs, should be lexicographical order of the current locale.
+ Ok(left < right)
+ }
+ ast::BinaryPredicate::LeftSortsAfterRight => {
+ let left = expansion::basic_expand_word(shell, params, left).await?;
+ let right = expansion::basic_expand_word(shell, params, right).await?;
+
+ if shell.options.print_commands_and_arguments {
+ shell
+ .trace_command(params, std::format!("[[ {left} {op} {right} ]]"))
+ .await?;
+ }
+
+ // TODO: According to docs, should be lexicographical order of the current locale.
+ Ok(left > right)
+ }
+ ast::BinaryPredicate::ArithmeticEqualTo => {
+ let left =
+ arithmetic::expand_and_eval(shell, params, left.value.as_str(), false).await?;
+ let right =
+ arithmetic::expand_and_eval(shell, params, right.value.as_str(), false).await?;
+
+ if shell.options.print_commands_and_arguments {
+ shell
+ .trace_command(params, std::format!("[[ {left} {op} {right} ]]"))
+ .await?;
+ }
+
+ Ok(left == right)
+ }
+ ast::BinaryPredicate::ArithmeticNotEqualTo => {
+ let left =
+ arithmetic::expand_and_eval(shell, params, left.value.as_str(), false).await?;
+ let right =
+ arithmetic::expand_and_eval(shell, params, right.value.as_str(), false).await?;
+
+ if shell.options.print_commands_and_arguments {
+ shell
+ .trace_command(params, std::format!("[[ {left} {op} {right} ]]"))
+ .await?;
+ }
+
+ Ok(left != right)
+ }
+ ast::BinaryPredicate::ArithmeticLessThan => {
+ let left =
+ arithmetic::expand_and_eval(shell, params, left.value.as_str(), false).await?;
+ let right =
+ arithmetic::expand_and_eval(shell, params, right.value.as_str(), false).await?;
+
+ if shell.options.print_commands_and_arguments {
+ shell
+ .trace_command(params, std::format!("[[ {left} {op} {right} ]]"))
+ .await?;
+ }
+
+ Ok(left < right)
+ }
+ ast::BinaryPredicate::ArithmeticLessThanOrEqualTo => {
+ let left =
+ arithmetic::expand_and_eval(shell, params, left.value.as_str(), false).await?;
+ let right =
+ arithmetic::expand_and_eval(shell, params, right.value.as_str(), false).await?;
+
+ if shell.options.print_commands_and_arguments {
+ shell
+ .trace_command(params, std::format!("[[ {left} {op} {right} ]]"))
+ .await?;
+ }
+
+ Ok(left <= right)
+ }
+ ast::BinaryPredicate::ArithmeticGreaterThan => {
+ let left =
+ arithmetic::expand_and_eval(shell, params, left.value.as_str(), false).await?;
+ let right =
+ arithmetic::expand_and_eval(shell, params, right.value.as_str(), false).await?;
+
+ if shell.options.print_commands_and_arguments {
+ shell
+ .trace_command(params, std::format!("[[ {left} {op} {right} ]]"))
+ .await?;
+ }
+
+ Ok(left > right)
+ }
+ ast::BinaryPredicate::ArithmeticGreaterThanOrEqualTo => {
+ let left =
+ arithmetic::expand_and_eval(shell, params, left.value.as_str(), false).await?;
+ let right =
+ arithmetic::expand_and_eval(shell, params, right.value.as_str(), false).await?;
+
+ if shell.options.print_commands_and_arguments {
+ shell
+ .trace_command(params, std::format!("[[ {left} {op} {right} ]]"))
+ .await?;
+ }
+
+ Ok(left >= right)
+ }
+ // N.B. The "=", "==", and "!=" operators don't compare 2 strings; they check
+ // for whether the lefthand operand (a string) is matched by the righthand
+ // operand (treated as a shell pattern).
+ // TODO: implement case-insensitive matching if relevant via shopt options (nocasematch).
+ ast::BinaryPredicate::StringExactlyMatchesPattern => {
+ let s = expansion::basic_expand_word(shell, params, left).await?;
+ let pattern = expansion::basic_expand_pattern(shell, params, right)
+ .await?
+ .set_extended_globbing(shell.options.extended_globbing)
+ .set_case_insensitive(shell.options.case_insensitive_conditionals);
+
+ if shell.options.print_commands_and_arguments {
+ let expanded_right = expansion::basic_expand_word(shell, params, right).await?;
+ let escaped_right = escape::quote_if_needed(
+ expanded_right.as_str(),
+ escape::QuoteMode::BackslashEscape,
+ );
+ shell
+ .trace_command(params, std::format!("[[ {s} {op} {escaped_right} ]]"))
+ .await?;
+ }
+
+ pattern.exactly_matches(s.as_str())
+ }
+ ast::BinaryPredicate::StringDoesNotExactlyMatchPattern => {
+ let s = expansion::basic_expand_word(shell, params, left).await?;
+ let pattern = expansion::basic_expand_pattern(shell, params, right)
+ .await?
+ .set_extended_globbing(shell.options.extended_globbing)
+ .set_case_insensitive(shell.options.case_insensitive_conditionals);
+
+ if shell.options.print_commands_and_arguments {
+ let expanded_right = expansion::basic_expand_word(shell, params, right).await?;
+ let escaped_right = escape::quote_if_needed(
+ expanded_right.as_str(),
+ escape::QuoteMode::BackslashEscape,
+ );
+ shell
+ .trace_command(params, std::format!("[[ {s} {op} {escaped_right} ]]"))
+ .await?;
+ }
+
+ let eq = pattern.exactly_matches(s.as_str())?;
+ Ok(!eq)
+ }
+ }
+}
+
+pub(crate) fn apply_binary_predicate_to_strs(
+ op: &ast::BinaryPredicate,
+ left: &str,
+ right: &str,
+ shell: &Shell,
+) -> Result {
+ match op {
+ ast::BinaryPredicate::FilesReferToSameDeviceAndInodeNumbers => {
+ files_refer_to_same_device_and_inode_numbers(shell, left, right)
+ }
+ ast::BinaryPredicate::LeftFileIsNewerOrExistsWhenRightDoesNot => {
+ left_file_is_newer_or_exists_when_right_does_not(shell, left, right)
+ }
+ ast::BinaryPredicate::LeftFileIsOlderOrDoesNotExistWhenRightDoes => {
+ left_file_is_older_or_does_not_exist_when_right_does(shell, left, right)
+ }
+ ast::BinaryPredicate::LeftSortsBeforeRight => {
+ // TODO: According to docs, should be lexicographical order of the current locale.
+ Ok(left < right)
+ }
+ ast::BinaryPredicate::LeftSortsAfterRight => {
+ // TODO: According to docs, should be lexicographical order of the current locale.
+ Ok(left > right)
+ }
+ ast::BinaryPredicate::ArithmeticEqualTo => Ok(apply_test_binary_arithmetic_predicate(
+ left,
+ right,
+ |left, right| left == right,
+ )),
+ ast::BinaryPredicate::ArithmeticNotEqualTo => Ok(apply_test_binary_arithmetic_predicate(
+ left,
+ right,
+ |left, right| left != right,
+ )),
+ ast::BinaryPredicate::ArithmeticLessThan => Ok(apply_test_binary_arithmetic_predicate(
+ left,
+ right,
+ |left, right| left < right,
+ )),
+ ast::BinaryPredicate::ArithmeticLessThanOrEqualTo => Ok(
+ apply_test_binary_arithmetic_predicate(left, right, |left, right| left <= right),
+ ),
+ ast::BinaryPredicate::ArithmeticGreaterThan => Ok(apply_test_binary_arithmetic_predicate(
+ left,
+ right,
+ |left, right| left > right,
+ )),
+ ast::BinaryPredicate::ArithmeticGreaterThanOrEqualTo => Ok(
+ apply_test_binary_arithmetic_predicate(left, right, |left, right| left >= right),
+ ),
+ ast::BinaryPredicate::StringExactlyMatchesPattern => {
+ let pattern = patterns::Pattern::from(right)
+ .set_extended_globbing(shell.options.extended_globbing)
+ .set_case_insensitive(shell.options.case_insensitive_conditionals);
+
+ pattern.exactly_matches(left)
+ }
+ ast::BinaryPredicate::StringDoesNotExactlyMatchPattern => {
+ let pattern = patterns::Pattern::from(right)
+ .set_extended_globbing(shell.options.extended_globbing)
+ .set_case_insensitive(shell.options.case_insensitive_conditionals);
+
+ let eq = pattern.exactly_matches(left)?;
+ Ok(!eq)
+ }
+ ast::BinaryPredicate::StringExactlyMatchesString => Ok(left == right),
+ ast::BinaryPredicate::StringDoesNotExactlyMatchString => Ok(left != right),
+ _ => error::unimp("unsupported test binary predicate"),
+ }
+}
+
+fn apply_test_binary_arithmetic_predicate(
+ left: &str,
+ right: &str,
+ op: fn(i64, i64) -> bool,
+) -> bool {
+ let left: Result = left.parse();
+ let right: Result = right.parse();
+
+ if let (Ok(left), Ok(right)) = (left, right) {
+ op(left, right)
+ } else {
+ false
+ }
+}
+
+fn left_file_is_older_or_does_not_exist_when_right_does(
+ shell: &Shell,
+ left: impl AsRef,
+ right: impl AsRef,
+) -> Result {
+ let (l_path, r_path) = (
+ shell.absolute_path(Path::new(left.as_ref())),
+ shell.absolute_path(Path::new(right.as_ref())),
+ );
+
+ match (l_path.metadata(), r_path.metadata()) {
+ (Ok(m1), Ok(m2)) => Ok(m1.modified()? < m2.modified()?),
+ (Err(_), Ok(_)) => Ok(true),
+ _ => Ok(false),
+ }
+}
+
+fn left_file_is_newer_or_exists_when_right_does_not(
+ shell: &Shell,
+ left: impl AsRef,
+ right: impl AsRef,
+) -> Result {
+ let (l_path, r_path) = (
+ shell.absolute_path(Path::new(left.as_ref())),
+ shell.absolute_path(Path::new(right.as_ref())),
+ );
+
+ match (l_path.metadata(), r_path.metadata()) {
+ (Ok(m1), Ok(m2)) => Ok(m1.modified()? > m2.modified()?),
+ (Ok(_), Err(_)) => Ok(true),
+ _ => Ok(false),
+ }
+}
+
+fn files_refer_to_same_device_and_inode_numbers(
+ shell: &Shell,
+ left: impl AsRef,
+ right: impl AsRef,
+) -> Result {
+ let (l_path, r_path) = (
+ shell.absolute_path(Path::new(left.as_ref())),
+ shell.absolute_path(Path::new(right.as_ref())),
+ );
+
+ if !l_path.readable() || !r_path.readable() {
+ return Ok(false);
+ }
+
+ Ok(l_path.get_device_and_inode()? == r_path.get_device_and_inode()?)
+}
diff --git a/crates/brush-core-vendored/src/functions.rs b/crates/brush-core-vendored/src/functions.rs
new file mode 100644
index 000000000..89bf1e248
--- /dev/null
+++ b/crates/brush-core-vendored/src/functions.rs
@@ -0,0 +1,177 @@
+//! Structures for managing function registrations and calls.
+
+use std::{
+ collections::{HashMap, VecDeque},
+ sync::Arc,
+};
+
+use brush_parser::ast;
+
+/// An environment for defined, named functions.
+#[derive(Clone, Default)]
+pub struct FunctionEnv {
+ functions: HashMap,
+}
+
+impl FunctionEnv {
+ /// Tries to retrieve the registration for a function by name.
+ ///
+ /// # Arguments
+ ///
+ /// * `name` - The name of the function to retrieve.
+ pub fn get(&self, name: &str) -> Option<&Registration> {
+ self.functions.get(name)
+ }
+
+ /// Tries to retrieve a mutable reference to the registration for a
+ /// function by name.
+ ///
+ /// # Arguments
+ ///
+ /// * `name` - The name of the function to retrieve.
+ pub fn get_mut(&mut self, name: &str) -> Option<&mut Registration> {
+ self.functions.get_mut(name)
+ }
+
+ /// Unregisters a function from the environment.
+ ///
+ /// # Arguments
+ ///
+ /// * `name` - The name of the function to remove.
+ pub fn remove(&mut self, name: &str) -> Option {
+ self.functions.remove(name)
+ }
+
+ /// Updates a function registration in this environment.
+ ///
+ /// # Arguments
+ ///
+ /// * `name` - The name of the function to update.
+ /// * `registration` - The new registration for the function.
+ pub fn update(&mut self, name: String, registration: Registration) {
+ self.functions.insert(name, registration);
+ }
+
+ /// Clear all functions in this environment.
+ pub fn clear(&mut self) {
+ self.functions.clear();
+ }
+
+ /// Returns an iterator over the functions registered in this environment.
+ pub fn iter(&self) -> impl Iterator- {
+ self.functions.iter()
+ }
+}
+
+/// Encapsulates a registration for a defined function.
+#[derive(Clone)]
+pub struct Registration {
+ /// The definition of the function.
+ pub(crate) definition: Arc
,
+ /// Whether or not this function definition should be exported to children.
+ exported: bool,
+}
+
+impl From for Registration {
+ fn from(definition: brush_parser::ast::FunctionDefinition) -> Self {
+ Self {
+ definition: Arc::new(definition),
+ exported: false,
+ }
+ }
+}
+
+impl Registration {
+ /// Returns a reference to the function definition.
+ pub fn definition(&self) -> &brush_parser::ast::FunctionDefinition {
+ &self.definition
+ }
+
+ /// Marks the function for export.
+ pub const fn export(&mut self) {
+ self.exported = true;
+ }
+
+ /// Unmarks the function for export.
+ pub const fn unexport(&mut self) {
+ self.exported = false;
+ }
+
+ /// Returns whether this function is exported.
+ pub const fn is_exported(&self) -> bool {
+ self.exported
+ }
+}
+
+/// Represents an active shell function call.
+#[derive(Clone, Debug)]
+pub struct FunctionCall {
+ /// The name of the function invoked.
+ pub function_name: String,
+ /// The definition of the invoked function.
+ pub function_definition: Arc,
+}
+
+/// Encapsulates a function call stack.
+#[derive(Clone, Debug, Default)]
+pub struct CallStack {
+ frames: VecDeque,
+}
+
+impl std::fmt::Display for CallStack {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ if self.is_empty() {
+ return Ok(());
+ }
+
+ writeln!(f, "Function call stack (most recent first):")?;
+
+ for (index, frame) in self.iter().enumerate() {
+ writeln!(f, " #{}| {}", index, frame.function_name)?;
+ }
+
+ Ok(())
+ }
+}
+
+impl CallStack {
+ /// Creates a new empty function call stack.
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Removes the top from from the stack. If the stack is empty, does nothing and
+ /// returns `None`; otherwise, returns the removed call frame.
+ pub fn pop(&mut self) -> Option {
+ self.frames.pop_front()
+ }
+
+ /// Pushes a new frame onto the stack.
+ ///
+ /// # Arguments
+ ///
+ /// * `name` - The name of the function being called.
+ /// * `function_def` - The definition of the function being called.
+ pub fn push(&mut self, name: impl Into