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

+ + + + + + + + + + +
+ + + + + Packaging status + + + Dependency status + + + Discord invite + +

+ + + + +

+ +
+ +`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, function_def: &Arc) { + self.frames.push_front(FunctionCall { + function_name: name.into(), + function_definition: function_def.clone(), + }); + } + + /// Returns the current depth of the function call stack. + pub fn depth(&self) -> usize { + self.frames.len() + } + + /// Returns whether or not the function call stack is empty. + pub fn is_empty(&self) -> bool { + self.frames.is_empty() + } + + /// Returns an iterator over the function call frames, starting from the most + /// recent. + pub fn iter(&self) -> impl Iterator { + self.frames.iter() + } +} diff --git a/crates/brush-core-vendored/src/history.rs b/crates/brush-core-vendored/src/history.rs new file mode 100644 index 000000000..60621e39d --- /dev/null +++ b/crates/brush-core-vendored/src/history.rs @@ -0,0 +1,490 @@ +//! Facilities for tracking and persisting the shell's command history. + +use chrono::Utc; +use std::{ + io::{BufRead, Read, Write}, + path::Path, +}; + +use crate::error; + +/// Represents a unique identifier for a history item. +type ItemId = i64; + +/// Interface for querying and manipulating the shell's recorded history of commands. +// TODO: support maximum item count +#[derive(Clone, Default)] +pub struct History { + items: rpds::VectorSync, + id_map: rpds::HashTrieMapSync, + next_id: ItemId, +} + +impl History { + /// Constructs a new `History` instance, with its contents initialized from the given readable + /// stream. If errors are encountered reading lines from the stream, unreadable lines will + /// be skipped but the call will still return successfully, with a warning logged. An error + /// result will be returned only if an internal error occurs updating the history. + /// + /// # Arguments + /// + /// * `reader` - The readable stream to import history from. + pub fn import(reader: impl Read) -> Result { + let mut history = Self::default(); + + let buf_reader = std::io::BufReader::new(reader); + + let mut next_timestamp = None; + for line_result in buf_reader.lines() { + // If we couldn't decode the line (perhaps it wasn't valid UTF8?), skip it and make + // a best-effort attempt to proceed on. We'll later warn the user. + let line = match line_result { + Ok(line) => line, + Err(err) => { + tracing::warn!("unreadable history line; {err}"); + continue; + } + }; + + // Look for timestamp comments; ignore other comment lines. + if let Some(comment) = line.strip_prefix("#") { + if let Ok(seconds_since_epoch) = comment.trim().parse() { + next_timestamp = + chrono::DateTime::::from_timestamp(seconds_since_epoch, 0); + } else { + next_timestamp = None; + } + + continue; + } + + let item = Item { + id: history.next_id, + command_line: line, + timestamp: next_timestamp.take(), + dirty: false, + }; + + history.add(item)?; + } + + Ok(history) + } + + /// Tries to retrieve a history item by its unique identifier. Returns `None` if no item is + /// found. + /// + /// # Arguments + /// + /// * `id` - The unique identifier of the history item to retrieve. + pub fn get_by_id(&self, id: ItemId) -> Result, error::Error> { + Ok(self.id_map.get(&id)) + } + + /// Replaces the history item with the given ID with a new item. Returns an error if the item + /// cannot be updated. + /// + /// # Arguments + /// + /// * `id` - The unique identifier of the history item to update. + /// * `item` - The new history item to replace the old one. + pub fn update_by_id(&mut self, id: ItemId, item: Item) -> Result<(), error::Error> { + let existing_item = self + .id_map + .get_mut(&id) + .ok_or(error::ErrorKind::HistoryItemNotFound)?; + *existing_item = item; + Ok(()) + } + + /// Removes the nth item from the history. Returns the removed item, or `None` if no such item + /// exists (i.e., because it was out of range). + pub fn remove_nth_item(&mut self, n: usize) -> bool { + if let Some(id) = self.items.get(n).copied() { + self.items = self + .items + .into_iter() + .enumerate() + .filter_map(|(i, id)| if i != n { Some(id) } else { None }) + .copied() + .collect(); + + self.id_map.remove_mut(&id); + + true + } else { + false + } + } + + /// Adds a new history item. Returns the unique identifier of the newly added item. + /// + /// # Arguments + /// + /// * `item` - The history item to add. + pub fn add(&mut self, mut item: Item) -> Result { + let id = self.next_id; + + item.id = id; + self.next_id += 1; + + self.items.push_back_mut(item.id); + self.id_map.insert_mut(item.id, item); + + Ok(id) + } + + /// Deletes a history item by its unique identifier. Returns an error if the item cannot be + /// deleted. + /// + /// # Arguments + /// + /// * `id` - The unique identifier of the history item to delete. + pub fn delete_item_by_id(&mut self, id: ItemId) -> Result<(), error::Error> { + self.id_map.remove_mut(&id); + self.items = self + .items + .into_iter() + .filter(|&item_id| *item_id != id) + .copied() + .collect(); + + Ok(()) + } + + /// Clears all history items. + pub fn clear(&mut self) -> Result<(), error::Error> { + self.id_map = rpds::HashTrieMapSync::new_sync(); + self.items = rpds::VectorSync::new_sync(); + Ok(()) + } + + /// Flushes the history to backing storage (if relevant). + /// + /// # Arguments + /// + /// * `history_file_path` - The path to the history file. + /// * `append` - Whether to append to the file or overwrite it. + /// * `unsaved_items_only` - Whether to only write unsaved items; if true, any items will be marked as "saved" once saved. + /// * `write_timestamps` - Whether to write timestamps for each command line. + pub fn flush( + &mut self, + history_file_path: impl AsRef, + append: bool, + unsaved_items_only: bool, + write_timestamps: bool, + ) -> Result<(), error::Error> { + // Open the file + let mut file_options = std::fs::File::options(); + + if append { + file_options.append(true); + } else { + file_options.write(true).truncate(true); + } + + let mut file = file_options.create(true).open(history_file_path.as_ref())?; + + for item_id in &self.items { + if let Some(item) = self.id_map.get_mut(item_id) { + if unsaved_items_only && !item.dirty { + continue; + } + + if write_timestamps { + if let Some(timestamp) = item.timestamp { + writeln!(file, "#{}", timestamp.timestamp())?; + } + } + + writeln!(file, "{}", item.command_line)?; + + if unsaved_items_only { + item.dirty = false; + } + } + } + + file.flush()?; + + Ok(()) + } + + /// Searches through history using the given query. + /// + /// # Arguments + /// + /// * `query` - The query to use. + pub fn search(&self, query: Query) -> Result, error::Error> { + Ok(Search::new(self, query)) + } + + /// Returns an iterator over the history items. + pub fn iter(&self) -> impl Iterator { + Search::all(self) + } + + /// Retrieves the nth history item, if it exists. Returns `None` if no such item exists. + /// Indexing is zero-based, with an index of 0 referencing the oldest item in the history. + /// + /// # Arguments + /// + /// * `index` - The index of the history item to retrieve. + pub fn get(&self, index: usize) -> Option<&Item> { + if let Some(id) = self.items.get(index) { + self.id_map.get(id) + } else { + None + } + } + + /// Returns the number of items in the history. + pub fn count(&self) -> usize { + self.items.len() + } +} + +/// Represents an item in the history. +#[derive(Clone, Default)] +pub struct Item { + /// The unique identifier of the history item. + pub id: ItemId, + /// The actual command line. + pub command_line: String, + /// The timestamp when the command was started. + pub timestamp: Option>, + /// Whether or not the item is dirty, i.e., has not yet been written to backing storage. + pub dirty: bool, +} + +impl Item { + /// Constructs a new `Item` with the given command line. + /// + /// # Arguments + /// + /// * `command_line` - The command line of the item. + pub fn new(command_line: impl Into) -> Self { + Self { + id: 0, // NOTE: ID will be assigned when added to the history. + command_line: command_line.into(), + timestamp: Some(chrono::Utc::now()), + dirty: true, + } + } +} + +/// Encapsulates query parameters for searching through history. +#[derive(Default)] +pub struct Query { + /// Whether to search forward or backward + pub direction: Direction, + /// Optionally, clamp results to items with a timestamp strictly after this. + pub not_at_or_before_time: Option>, + /// Optionally, clamp results to items with a timestamp strictly before this. + pub not_at_or_after_time: Option>, + /// Optionally, clamp results to items with an ID equal strictly after this. + pub not_at_or_before_id: Option, + /// Optionally, clamp results to items with an ID equal strictly before this. + pub not_at_or_after_id: Option, + /// Optionally, maximum number of items to retrieve + pub max_items: Option, + /// Optionally, a string-based filter on command line. + pub command_line_filter: Option, +} + +impl Query { + /// Checks if the query includes the given item. + /// + /// # Arguments + /// + /// * `item` - The item to check. + pub fn includes(&self, item: &Item) -> bool { + // Filter based on not_at_or_before_time. + if let Some(not_at_or_before_time) = &self.not_at_or_before_time { + if item + .timestamp + .is_some_and(|ts| ts <= *not_at_or_before_time) + { + return false; + } + } + + // Filter based on not_at_or_after_time + if let Some(not_at_or_after_time) = &self.not_at_or_after_time { + if item.timestamp.is_some_and(|ts| ts >= *not_at_or_after_time) { + return false; + } + } + + // Filter based on not_at_or_before_id + if self + .not_at_or_before_id + .is_some_and(|query_id| item.id <= query_id) + { + return false; + } + + // Filter based on not_at_or_after_id + if self + .not_at_or_after_id + .is_some_and(|query_id| item.id >= query_id) + { + return false; + } + + // Filter based on command_line_filter + if let Some(command_line_filter) = &self.command_line_filter { + match command_line_filter { + CommandLineFilter::Prefix(prefix) => { + if !item.command_line.starts_with(prefix) { + return false; + } + } + CommandLineFilter::Suffix(suffix) => { + if !item.command_line.ends_with(suffix) { + return false; + } + } + CommandLineFilter::Contains(contains) => { + if !item.command_line.contains(contains) { + return false; + } + } + CommandLineFilter::Exact(exact) => { + if item.command_line != *exact { + return false; + } + } + } + } + + true + } +} + +/// Represents the direction of a search operation. +#[derive(Default)] +pub enum Direction { + /// Search forward from the oldest part of history. + #[default] + Forward, + /// Search backward from the youngest part of history. + Backward, +} + +/// Filter criteria for command lines. +pub enum CommandLineFilter { + /// The command line must start with this string. + Prefix(String), + /// The command line must end with this string. + Suffix(String), + /// The command line must contain this string. + Contains(String), + /// The command line must match this string exactly. + Exact(String), +} + +/// Represents a search operation. +pub struct Search<'a> { + /// The history to search through. + history: &'a History, + /// The query to apply. + query: Query, + /// The next index in `items`. + next_index: Option, + /// Count of items returned so far. + count: usize, +} + +impl<'a> Search<'a> { + /// Constructs a new search against the provided history, querying *all* items. + /// + /// # Arguments + /// + /// * `history` - The history to search through. + pub fn all(history: &'a History) -> Self { + Self::new(history, Query::default()) + } + + /// Constructs a new search against the provided history, using the given query. + /// + /// # Arguments + /// + /// * `history` - The history to search through. + /// * `query` - The query to use. + pub fn new(history: &'a History, query: Query) -> Self { + let next_index = match query.direction { + Direction::Forward => Some(0), + Direction::Backward => { + if history.items.is_empty() { + None + } else { + Some(history.items.len() - 1) + } + } + }; + + Self { + history, + query, + next_index, + count: 0, + } + } + + const fn increment_next_index(&mut self) { + if let Some(index) = self.next_index { + self.next_index = match self.query.direction { + Direction::Forward => Some(index + 1), + Direction::Backward => { + if index == 0 { + None + } else { + Some(index - 1) + } + } + } + } + } +} + +impl<'a> Iterator for Search<'a> { + type Item = &'a Item; + + fn next(&mut self) -> Option { + loop { + if let Some(index) = self.next_index { + // Make sure we haven't hit the end of the history. + if index >= self.history.items.len() { + return None; + } + + let id = self.history.items[index]; + self.increment_next_index(); + + if let Some(item) = self.history.id_map.get(&id) { + // Filter based on max_items. Once we hit the limit, + // we stop searching. + #[expect(clippy::cast_possible_truncation)] + #[expect(clippy::cast_sign_loss)] + if self + .query + .max_items + .is_some_and(|max_items| self.count >= max_items as usize) + { + return None; + } + + // Check other filters. If they don't match, then we + // skip but keep searching. + if self.query.includes(item) { + self.count += 1; + return Some(item); + } + } + } else { + return None; + } + } + } +} diff --git a/crates/brush-core-vendored/src/interfaces.rs b/crates/brush-core-vendored/src/interfaces.rs new file mode 100644 index 000000000..1bb87ab32 --- /dev/null +++ b/crates/brush-core-vendored/src/interfaces.rs @@ -0,0 +1,5 @@ +//! Exports traits for shell interfaces implemented by callers. + +mod keybindings; + +pub use keybindings::{InputFunction, Key, KeyAction, KeyBindings, KeySequence, KeyStroke}; diff --git a/crates/brush-core-vendored/src/interfaces/keybindings.rs b/crates/brush-core-vendored/src/interfaces/keybindings.rs new file mode 100644 index 000000000..122d3f57b --- /dev/null +++ b/crates/brush-core-vendored/src/interfaces/keybindings.rs @@ -0,0 +1,337 @@ +use std::{ + collections::HashMap, + fmt::{self, Display, Formatter}, +}; + +/// Represents an action that can be taken in response to a key sequence. +#[derive(Debug)] +pub enum KeyAction { + /// Execute a shell command. + ShellCommand(String), + /// Execute an input "function". + DoInputFunction(InputFunction), +} + +impl Display for KeyAction { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::ShellCommand(command) => write!(f, "shell command: {command}"), + Self::DoInputFunction(function) => function.fmt(f), + } + } +} + +/// Defines all input functions. +#[derive(Debug, strum_macros::EnumString, strum_macros::Display, strum_macros::EnumIter)] +#[strum(serialize_all = "kebab-case")] +#[expect(missing_docs)] +pub enum InputFunction { + Abort, + AcceptLine, + AliasExpandLine, + ArrowKeyPrefix, + BackwardByte, + BackwardChar, + BackwardDeleteChar, + BackwardKillLine, + BackwardKillWord, + BackwardWord, + BeginningOfHistory, + BeginningOfLine, + BracketedPasteBegin, + CallLastKbdMacro, + CapitalizeWord, + CharacterSearch, + CharacterSearchBackward, + ClearDisplay, + ClearScreen, + Complete, + CompleteCommand, + CompleteFilename, + CompleteHostname, + CompleteIntoBraces, + CompleteUsername, + CompleteVariable, + CopyBackwardWord, + CopyForwardWord, + CopyRegionAsKill, + DabbrevExpand, + DeleteChar, + DeleteCharOrList, + DeleteHorizontalSpace, + DigitArgument, + DisplayShellVersion, + DoLowercaseVersion, + DowncaseWord, + DumpFunctions, + DumpMacros, + DumpVariables, + DynamicCompleteHistory, + EditAndExecuteCommand, + EmacsEditingMode, + EndKbdMacro, + EndOfHistory, + EndOfLine, + ExchangePointAndMark, + ForwardBackwardDeleteChar, + ForwardByte, + ForwardChar, + ForwardSearchHistory, + ForwardWord, + GlobCompleteWord, + GlobExpandWord, + GlobListExpansions, + HistoryAndAliasExpandLine, + HistoryExpandLine, + HistorySearchBackward, + HistorySearchForward, + HistorySubstringSearchBackward, + HistorySubstringSearchForward, + InsertComment, + InsertCompletions, + InsertLastArgument, + KillLine, + KillRegion, + KillWholeLine, + KillWord, + MagicSpace, + MenuComplete, + MenuCompleteBackward, + NextHistory, + NextScreenLine, + NonIncrementalForwardSearchHistory, + NonIncrementalForwardSearchHistoryAgain, + NonIncrementalReverseSearchHistory, + NonIncrementalReverseSearchHistoryAgain, + OldMenuComplete, + OperateAndGetNext, + OverwriteMode, + PossibleCommandCompletions, + PossibleCompletions, + PossibleFilenameCompletions, + PossibleHostnameCompletions, + PossibleUsernameCompletions, + PossibleVariableCompletions, + PreviousHistory, + PreviousScreenLine, + PrintLastKbdMacro, + QuotedInsert, + ReReadInitFile, + RedrawCurrentLine, + ReverseSearchHistory, + RevertLine, + SelfInsert, + SetMark, + ShellBackwardKillWord, + ShellBackwardWord, + ShellExpandLine, + ShellForwardWord, + ShellKillWord, + ShellTransposeWords, + SkipCsiSequence, + StartKbdMacro, + TabInsert, + TildeExpand, + TransposeChars, + TransposeWords, + TtyStatus, + Undo, + UniversalArgument, + UnixFilenameRubout, + UnixLineDiscard, + UnixWordRubout, + UpcaseWord, + ViAppendEol, + ViAppendMode, + ViArgDigit, + ViBWord, + ViBackToIndent, + ViBackwardBigword, + ViBackwardWord, + ViBword, + ViChangeCase, + ViChangeChar, + ViChangeTo, + ViCharSearch, + ViColumn, + ViComplete, + ViDelete, + ViDeleteTo, + ViEWord, + ViEditingMode, + ViEndBigword, + ViEndWord, + ViEofMaybe, + ViEword, + ViFWord, + ViFetchHistory, + ViFirstPrint, + ViForwardBigword, + ViForwardWord, + ViFword, + ViGotoMark, + ViInsertBeg, + ViInsertionMode, + ViMatch, + ViMovementMode, + ViNextWord, + ViOverstrike, + ViOverstrikeDelete, + ViPrevWord, + ViPut, + ViRedo, + ViReplace, + ViRubout, + ViSearch, + ViSearchAgain, + ViSetMark, + ViSubst, + ViTildeExpand, + ViUnixWordRubout, + ViYankArg, + ViYankPop, + ViYankTo, + Yank, + YankLastArg, + YankNthArg, + YankPop, +} + +/// Represents a sequence of keys. +#[derive(Debug, Eq, Hash, PartialEq)] +pub struct KeySequence { + /// The strokes in the sequence. + pub strokes: Vec, +} + +impl Display for KeySequence { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + for stroke in &self.strokes { + stroke.fmt(f)?; + } + Ok(()) + } +} + +impl From for KeySequence { + /// Creates a new key sequence with a single stroke. + fn from(value: KeyStroke) -> Self { + Self { + strokes: vec![value], + } + } +} + +#[derive(Debug, Eq, Hash, PartialEq)] +/// Represents a single key press. +pub struct KeyStroke { + /// Alt key was pressed. + pub alt: bool, + /// Control key was pressed. + pub control: bool, + /// Shift key was pressed. + pub shift: bool, + /// Primary key pressed. + pub key: Key, +} + +impl Display for KeyStroke { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + if self.alt { + write!(f, "\\e")?; + } + if self.control { + write!(f, "\\C-")?; + } + if self.shift { + // TODO: Figure out what to do here or if the key encodes the shift in it. + } + self.key.fmt(f) + } +} + +impl From for KeyStroke { + /// Creates a new key stroke with a single key. + fn from(value: Key) -> Self { + Self { + alt: false, + control: false, + shift: false, + key: value, + } + } +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +/// Represents a single key. +pub enum Key { + /// A simple character key. + Character(char), + /// Backspace key. + Backspace, + /// Enter key. + Enter, + /// Left arrow key. + Left, + /// Right arrow key. + Right, + /// Up arrow key. + Up, + /// Down arrow key. + Down, + /// Home key. + Home, + /// End key. + End, + /// Page up key. + PageUp, + /// Page down key. + PageDown, + /// Tab key. + Tab, + /// Shift + Tab key. + BackTab, + /// Delete key. + Delete, + /// Insert key. + Insert, + /// F key. + F(u8), + /// Escape key. + Escape, +} + +impl Display for Key { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::Character(c @ ('\\' | '\"' | '\'')) => write!(f, "\\{c}")?, + Self::Character(c) => write!(f, "{c}")?, + Self::Backspace => write!(f, "Backspace")?, + Self::Enter => write!(f, "Enter")?, + Self::Left => write!(f, "Left")?, + Self::Right => write!(f, "Right")?, + Self::Up => write!(f, "Up")?, + Self::Down => write!(f, "Down")?, + Self::Home => write!(f, "Home")?, + Self::End => write!(f, "End")?, + Self::PageUp => write!(f, "PageUp")?, + Self::PageDown => write!(f, "PageDown")?, + Self::Tab => write!(f, "Tab")?, + Self::BackTab => write!(f, "BackTab")?, + Self::Delete => write!(f, "Delete")?, + Self::Insert => write!(f, "Insert")?, + Self::F(n) => write!(f, "F{n}")?, + Self::Escape => write!(f, "Esc")?, + } + + Ok(()) + } +} + +/// Encapsulates the shell's interaction with key bindings for input. +pub trait KeyBindings: Send { + /// Retrieves current bindings. + fn get_current(&self) -> HashMap; + /// Updates a binding. + fn bind(&mut self, seq: KeySequence, action: KeyAction) -> Result<(), std::io::Error>; +} diff --git a/crates/brush-core-vendored/src/interp.rs b/crates/brush-core-vendored/src/interp.rs new file mode 100644 index 000000000..c1d2075a8 --- /dev/null +++ b/crates/brush-core-vendored/src/interp.rs @@ -0,0 +1,1682 @@ +use brush_parser::ast::{self, CommandPrefixOrSuffixItem}; +use itertools::Itertools; +use std::collections::VecDeque; +use std::io::Write; +use std::path::{Path, PathBuf}; +use sys::commands::ExitStatusExt; + +use crate::arithmetic::{self, ExpandAndEvaluate}; +use crate::commands::{self, CommandArg}; +use crate::env::{EnvironmentLookup, EnvironmentScope}; +use crate::openfiles::{OpenFile, OpenFiles}; +use crate::results::{ + self, ExecutionExitCode, ExecutionResult, ExecutionSpawnResult, ExecutionWaitResult, +}; +use crate::shell::Shell; +use crate::variables::{ + ArrayLiteral, ShellValue, ShellValueLiteral, ShellValueUnsetType, ShellVariable, +}; +use crate::{ShellFd, error, expansion, extendedtests, jobs, openfiles, processes, sys, timing}; + +impl From for results::ExecutionResult { + fn from(wait_result: processes::ProcessWaitResult) -> Self { + match wait_result { + processes::ProcessWaitResult::Completed(output) => output.into(), + processes::ProcessWaitResult::Stopped => Self::stopped(), + } + } +} + +impl From for results::ExecutionResult { + fn from(output: std::process::Output) -> Self { + if let Some(code) = output.status.code() { + #[expect(clippy::cast_sign_loss)] + return Self::new((code & 0xFF) as u8); + } + + if let Some(signal) = output.status.signal() { + #[expect(clippy::cast_sign_loss)] + return Self::new((signal & 0xFF) as u8 + 128); + } + + tracing::error!("unhandled process exit"); + Self::new(127) + } +} + +/// Encapsulates the context of execution in a command pipeline. +struct PipelineExecutionContext<'a> { + /// The shell in which the command is being executed. + shell: &'a mut Shell, + + current_pipeline_index: usize, + pipeline_len: usize, + output_pipes: &'a mut Vec, + + process_group_id: Option, +} + +/// Parameters for execution. +#[derive(Clone, Default)] +pub struct ExecutionParameters { + /// The open files tracked by the current context. + open_files: openfiles::OpenFiles, + /// Policy for how to manage spawned external processes. + pub process_group_policy: ProcessGroupPolicy, +} + +impl ExecutionParameters { + /// Returns the standard input file; usable with `write!` et al. + /// + /// # Arguments + /// + /// * `shell` - The shell context. + pub fn stdin(&self, shell: &Shell) -> impl std::io::Read + 'static { + self.try_stdin(shell).unwrap() + } + + /// Tries to retrieve the standard input file. Returns `None` if not set. + /// + /// # Arguments + /// + /// * `shell` - The shell context. + pub fn try_stdin(&self, shell: &Shell) -> Option { + self.try_fd(shell, openfiles::OpenFiles::STDIN_FD) + } + + /// Returns the standard output file; usable with `write!` et al. + /// + /// # Arguments + /// + /// * `shell` - The shell context. + pub fn stdout(&self, shell: &Shell) -> impl std::io::Write + 'static { + self.try_stdout(shell).unwrap() + } + + /// Tries to retrieve the standard output file. Returns `None` if not set. + /// + /// # Arguments + /// + /// * `shell` - The shell context. + pub fn try_stdout(&self, shell: &Shell) -> Option { + self.try_fd(shell, openfiles::OpenFiles::STDOUT_FD) + } + + /// Returns the standard error file; usable with `write!` et al. + /// + /// # Arguments + /// + /// * `shell` - The shell context. + pub fn stderr(&self, shell: &Shell) -> impl std::io::Write + 'static { + self.try_stderr(shell).unwrap() + } + + /// Tries to retrieve the standard error file. Returns `None` if not set. + /// + /// # Arguments + /// + /// * `shell` - The shell context. + pub fn try_stderr(&self, shell: &Shell) -> Option { + self.try_fd(shell, openfiles::OpenFiles::STDERR_FD) + } + + /// Returns the file descriptor with the given number. Returns `None` + /// if the file descriptor is not open. + /// + /// # Arguments + /// + /// * `shell` - The shell context. + /// * `fd` - The file descriptor number to retrieve. + pub fn try_fd(&self, shell: &Shell, fd: ShellFd) -> Option { + match self.open_files.fd_entry(fd) { + openfiles::OpenFileEntry::Open(f) => Some(f.clone()), + openfiles::OpenFileEntry::NotPresent => None, + openfiles::OpenFileEntry::NotSpecified => { + // We didn't have this fd specified one way or the other; we fallback + // to what's represented in the shell's open files. + shell.persistent_open_files().try_fd(fd).cloned() + } + } + } + + /// Sets the given file descriptor to the provided open file. + /// + /// # Arguments + /// + /// * `fd` - The file descriptor number to set. + /// * `file` - The open file to set. + pub fn set_fd(&mut self, fd: ShellFd, file: openfiles::OpenFile) { + self.open_files.set_fd(fd, file); + } + + /// Iterates over all open file descriptors in this context. + /// + /// # Arguments + /// + /// * `shell` - The shell context. + pub fn iter_fds(&self, shell: &Shell) -> impl Iterator { + let our_fds = self.open_files.iter_fds(); + let shell_fds = shell + .persistent_open_files() + .iter_fds() + .filter(|(fd, _)| !self.open_files.contains_fd(*fd)); + + #[allow(clippy::needless_collect)] + let all_fds: Vec<_> = our_fds + .chain(shell_fds) + .map(|(fd, file)| (fd, file.clone())) + .collect(); + + all_fds.into_iter() + } +} + +#[derive(Clone, Debug, Default)] +/// Policy for how to manage spawned external processes. +pub enum ProcessGroupPolicy { + /// Place the process in a new process group. + #[default] + NewProcessGroup, + /// Place the process in the same process group as its parent. + SameProcessGroup, +} + +#[async_trait::async_trait] +pub trait Execute { + async fn execute( + &self, + shell: &mut Shell, + params: &ExecutionParameters, + ) -> Result; +} + +#[async_trait::async_trait] +trait ExecuteInPipeline { + async fn execute_in_pipeline( + &self, + context: &mut PipelineExecutionContext<'_>, + params: ExecutionParameters, + ) -> Result; +} + +#[async_trait::async_trait] +impl Execute for ast::Program { + async fn execute( + &self, + shell: &mut Shell, + params: &ExecutionParameters, + ) -> Result { + let mut result = ExecutionResult::success(); + + for command in &self.complete_commands { + result = command.execute(shell, params).await?; + if !result.is_normal_flow() { + break; + } + } + + *shell.last_exit_status_mut() = result.exit_code.into(); + Ok(result) + } +} + +#[async_trait::async_trait] +impl Execute for ast::CompoundList { + async fn execute( + &self, + shell: &mut Shell, + params: &ExecutionParameters, + ) -> Result { + let mut result = ExecutionResult::success(); + + for ast::CompoundListItem(ao_list, sep) in &self.0 { + let run_async = matches!(sep, ast::SeparatorOperator::Async); + + if run_async { + // TODO: Reenable launching in child process? + // let job = spawn_ao_list_in_child(ao_list, shell, params).await?; + + let job = spawn_ao_list_in_task(ao_list, shell, params); + let job_formatted = job.to_pid_style_string(); + + if shell.options.interactive && !shell.is_subshell() { + writeln!(params.stderr(shell), "{job_formatted}")?; + } + + result = ExecutionResult::success(); + } else { + result = ao_list.execute(shell, params).await?; + } + + if !result.is_normal_flow() { + break; + } + } + + *shell.last_exit_status_mut() = result.exit_code.into(); + Ok(result) + } +} + +fn spawn_ao_list_in_task<'a>( + ao_list: &ast::AndOrList, + shell: &'a mut Shell, + params: &ExecutionParameters, +) -> &'a jobs::Job { + // Clone the inputs. + let mut cloned_shell = shell.clone(); + let cloned_params = params.clone(); + let cloned_ao_list = ao_list.clone(); + + // Mark the child shell as not interactive; we don't want it messing with the terminal too much. + cloned_shell.options.interactive = false; + + let join_handle = tokio::spawn(async move { + cloned_ao_list + .execute(&mut cloned_shell, &cloned_params) + .await + }); + + shell.jobs.add_as_current(jobs::Job::new( + [jobs::JobTask::Internal(join_handle)], + ao_list.to_string(), + jobs::JobState::Running, + )) +} + +#[async_trait::async_trait] +impl Execute for ast::AndOrList { + async fn execute( + &self, + shell: &mut Shell, + params: &ExecutionParameters, + ) -> Result { + let mut result = self.first.execute(shell, params).await?; + + for next_ao in &self.additional { + // Check for non-normal control flow. + if !result.is_normal_flow() { + break; + } + + let (is_and, pipeline) = match next_ao { + ast::AndOr::And(p) => (true, p), + ast::AndOr::Or(p) => (false, p), + }; + + // If we short-circuit, then we don't break out of the whole loop + // but we skip evaluating the current pipeline. We'll then continue + // on and possibly evaluate a subsequent one (depending on the + // operator before it). + if is_and { + if !result.is_success() { + continue; + } + } else if result.is_success() { + continue; + } + + result = pipeline.execute(shell, params).await?; + } + + Ok(result) + } +} + +#[async_trait::async_trait] +impl Execute for ast::Pipeline { + async fn execute( + &self, + shell: &mut Shell, + params: &ExecutionParameters, + ) -> Result { + // Capture current timing if so requested. + let stopwatch = self + .timed + .is_some() + .then(timing::start_timing) + .transpose()?; + + // Spawn all the processes required for the pipeline, connecting outputs/inputs with pipes + // as needed. + let spawn_results = spawn_pipeline_processes(self, shell, params).await?; + + // Wait for the processes. This also has a side effect of updating pipeline status. + let mut result = + wait_for_pipeline_processes_and_update_status(self, spawn_results, shell, params) + .await?; + + // Invert the exit code if requested. + if self.bang { + result.exit_code = ExecutionExitCode::from(if result.is_success() { 1 } else { 0 }); + } + + // Update statuses. + *shell.last_exit_status_mut() = result.exit_code.into(); + + // If requested, report timing. + if let Some(timed) = &self.timed { + if let Some(mut stderr) = params.try_fd(shell, openfiles::OpenFiles::STDERR_FD) { + let timing = stopwatch.unwrap().stop()?; + + if timed.is_posix_output() { + std::write!( + stderr, + "real {}\nuser {}\nsys {}\n", + timing::format_duration_posixly(&timing.wall), + timing::format_duration_posixly(&timing.user), + timing::format_duration_posixly(&timing.system), + )?; + } else { + std::write!( + stderr, + "\nreal\t{}\nuser\t{}\nsys\t{}\n", + timing::format_duration_non_posixly(&timing.wall), + timing::format_duration_non_posixly(&timing.user), + timing::format_duration_non_posixly(&timing.system), + )?; + } + } + } + + Ok(result) + } +} + +async fn spawn_pipeline_processes( + pipeline: &ast::Pipeline, + shell: &mut Shell, + params: &ExecutionParameters, +) -> Result, error::Error> { + let pipeline_len = pipeline.seq.len(); + let mut output_pipes = vec![]; + let mut spawn_results = VecDeque::new(); + let mut process_group_id: Option = None; + + for (current_pipeline_index, command) in pipeline.seq.iter().enumerate() { + // + // We run a command directly in the current shell if either of the following is true: + // * There's only one command in the pipeline. + // * This is the *last* command in the pipeline, the lastpipe option is enabled, and job + // monitoring is disabled. + // Otherwise, we spawn a separate subshell for each command in the pipeline. + // + + let run_in_current_shell = pipeline_len == 1 + || (current_pipeline_index == pipeline_len - 1 + && shell.options.run_last_pipeline_cmd_in_current_shell + && !shell.options.enable_job_control); + + if !run_in_current_shell { + let mut subshell = shell.clone(); + let mut pipeline_context = PipelineExecutionContext { + shell: &mut subshell, + current_pipeline_index, + pipeline_len, + output_pipes: &mut output_pipes, + process_group_id, + }; + + let mut cmd_params = params.clone(); + + // Make sure that all commands in the pipeline are in the same process group. + if current_pipeline_index > 0 { + cmd_params.process_group_policy = ProcessGroupPolicy::SameProcessGroup; + } + + spawn_results.push_back( + command + .execute_in_pipeline(&mut pipeline_context, cmd_params) + .await?, + ); + process_group_id = pipeline_context.process_group_id; + } else { + let mut pipeline_context = PipelineExecutionContext { + shell, + current_pipeline_index, + pipeline_len, + output_pipes: &mut output_pipes, + process_group_id, + }; + + spawn_results.push_back( + command + .execute_in_pipeline(&mut pipeline_context, params.clone()) + .await?, + ); + process_group_id = pipeline_context.process_group_id; + } + } + + Ok(spawn_results) +} + +async fn wait_for_pipeline_processes_and_update_status( + pipeline: &ast::Pipeline, + mut process_spawn_results: VecDeque, + shell: &mut Shell, + params: &ExecutionParameters, +) -> Result { + let mut result = ExecutionResult::success(); + let mut stopped_children = vec![]; + + // Clear our the pipeline status so we can start filling it out. + shell.last_pipeline_statuses.clear(); + + while let Some(child) = process_spawn_results.pop_front() { + match child.wait(!stopped_children.is_empty()).await? { + ExecutionWaitResult::Completed(current_result) => { + result = current_result; + *shell.last_exit_status_mut() = result.exit_code.into(); + shell.last_pipeline_statuses.push(result.exit_code.into()); + } + ExecutionWaitResult::Stopped(child) => { + result = ExecutionResult::stopped(); + *shell.last_exit_status_mut() = result.exit_code.into(); + shell.last_pipeline_statuses.push(result.exit_code.into()); + + stopped_children.push(jobs::JobTask::External(child)); + } + } + } + + if shell.options.interactive { + sys::terminal::move_self_to_foreground()?; + } + + // If there were stopped jobs, then encapsulate the pipeline as a managed job and hand it + // off to the job manager. + if !stopped_children.is_empty() { + let job = shell.jobs.add_as_current(jobs::Job::new( + stopped_children, + pipeline.to_string(), + jobs::JobState::Stopped, + )); + + let formatted = job.to_string(); + + // N.B. We use the '\r' to overwrite any ^Z output. + writeln!(params.stderr(shell), "\r{formatted}")?; + } + + Ok(result) +} + +#[async_trait::async_trait] +impl ExecuteInPipeline for ast::Command { + async fn execute_in_pipeline( + &self, + pipeline_context: &mut PipelineExecutionContext<'_>, + mut params: ExecutionParameters, + ) -> Result { + if pipeline_context.shell.options.do_not_execute_commands { + return Ok(ExecutionSpawnResult::Completed(ExecutionResult::success())); + } + + match self { + Self::Simple(simple) => simple.execute_in_pipeline(pipeline_context, params).await, + Self::Compound(compound, redirects) => { + // Set up pipelining. + setup_pipeline_redirection(&mut params.open_files, pipeline_context)?; + + // Set up any additional redirects. + if let Some(redirects) = redirects { + for redirect in &redirects.0 { + setup_redirect(pipeline_context.shell, &mut params, redirect).await?; + } + } + + Ok(compound + .execute(pipeline_context.shell, ¶ms) + .await? + .into()) + } + Self::Function(func) => Ok(func.execute(pipeline_context.shell, ¶ms).await?.into()), + Self::ExtendedTest(e) => { + let result = if extendedtests::eval_extended_test_expr( + &e.expr, + pipeline_context.shell, + ¶ms, + ) + .await? + { + 0 + } else { + 1 + }; + Ok(ExecutionResult::new(result).into()) + } + } + } +} + +enum WhileOrUntil { + While, + Until, +} + +#[async_trait::async_trait] +impl Execute for ast::CompoundCommand { + async fn execute( + &self, + shell: &mut Shell, + params: &ExecutionParameters, + ) -> Result { + match self { + Self::BraceGroup(ast::BraceGroupCommand { list, .. }) => { + list.execute(shell, params).await + } + Self::Subshell(ast::SubshellCommand { list, .. }) => { + // Clone off a new subshell, and run the body of the subshell there. + let mut subshell = shell.clone(); + let subshell_result = list.execute(&mut subshell, params).await?; + + // Preserve the subshell's exit code, but don't honor any of its requests to exit + // the shell, break out of loops, etc. + Ok(ExecutionResult::from(subshell_result.exit_code)) + } + Self::ForClause(f) => f.execute(shell, params).await, + Self::CaseClause(c) => c.execute(shell, params).await, + Self::IfClause(i) => i.execute(shell, params).await, + Self::WhileClause(w) => (WhileOrUntil::While, w).execute(shell, params).await, + Self::UntilClause(u) => (WhileOrUntil::Until, u).execute(shell, params).await, + Self::Arithmetic(a) => a.execute(shell, params).await, + Self::ArithmeticForClause(a) => a.execute(shell, params).await, + } + } +} + +#[async_trait::async_trait] +impl Execute for ast::ForClauseCommand { + async fn execute( + &self, + shell: &mut Shell, + params: &ExecutionParameters, + ) -> Result { + let mut result = ExecutionResult::success(); + + // If we were given explicit words to iterate over, then expand them all, with splitting + // enabled. + let mut expanded_values = vec![]; + if let Some(unexpanded_values) = &self.values { + for value in unexpanded_values { + let mut expanded = + expansion::full_expand_and_split_word(shell, params, value).await?; + expanded_values.append(&mut expanded); + } + } else { + // Otherwise, we use the current positional parameters. + expanded_values.extend_from_slice(&shell.positional_parameters); + } + + for value in expanded_values { + if shell.options.print_commands_and_arguments { + if let Some(unexpanded_values) = &self.values { + shell + .trace_command( + params, + std::format!( + "for {} in {}", + self.variable_name, + unexpanded_values.iter().join(" ") + ), + ) + .await?; + } else { + shell + .trace_command(params, std::format!("for {}", self.variable_name,)) + .await?; + } + } + + // Update the variable. + shell.env.update_or_add( + &self.variable_name, + ShellValueLiteral::Scalar(value), + |_| Ok(()), + EnvironmentLookup::Anywhere, + EnvironmentScope::Global, + )?; + + result = self.body.list.execute(shell, params).await?; + if result.is_return_or_exit() { + break; + } + + let is_break = result.is_break(); + + result.next_control_flow = result.next_control_flow.try_decrement_loop_levels(); + + if is_break || result.is_continue() { + break; + } + } + + *shell.last_exit_status_mut() = result.exit_code.into(); + Ok(result) + } +} + +#[async_trait::async_trait] +impl Execute for ast::CaseClauseCommand { + async fn execute( + &self, + shell: &mut Shell, + params: &ExecutionParameters, + ) -> Result { + // N.B. One would think it makes sense to trace the expanded value being switched + // on, but that's not it. + if shell.options.print_commands_and_arguments { + shell + .trace_command(params, std::format!("case {} in", &self.value)) + .await?; + } + + let expanded_value = expansion::basic_expand_word(shell, params, &self.value).await?; + let mut result: ExecutionResult = ExecutionResult::success(); + let mut force_execute_next_case = false; + + for case in &self.cases { + if force_execute_next_case { + force_execute_next_case = false; + } else { + let mut matches = false; + for pattern in &case.patterns { + let expanded_pattern = expansion::basic_expand_pattern(shell, params, pattern) + .await? + .set_extended_globbing(shell.options.extended_globbing) + .set_case_insensitive(shell.options.case_insensitive_conditionals); + + if expanded_pattern.exactly_matches(expanded_value.as_str())? { + matches = true; + break; + } + } + + if !matches { + continue; + } + } + + result = if let Some(case_cmd) = &case.cmd { + case_cmd.execute(shell, params).await? + } else { + ExecutionResult::success() + }; + + // Check for early return (return/exit) or loop control flow (break/continue) + if !result.is_normal_flow() { + break; + } + + match case.post_action { + ast::CaseItemPostAction::ExitCase => break, + ast::CaseItemPostAction::UnconditionallyExecuteNextCaseItem => { + force_execute_next_case = true; + } + ast::CaseItemPostAction::ContinueEvaluatingCases => (), + } + } + + *shell.last_exit_status_mut() = result.exit_code.into(); + + Ok(result) + } +} + +#[async_trait::async_trait] +impl Execute for ast::IfClauseCommand { + async fn execute( + &self, + shell: &mut Shell, + params: &ExecutionParameters, + ) -> Result { + let condition = self.condition.execute(shell, params).await?; + + // Check if the condition itself resulted in non-normal control flow. + if !condition.is_normal_flow() { + return Ok(condition); + } + + if condition.is_success() { + return self.then.execute(shell, params).await; + } + + if let Some(elses) = &self.elses { + for else_clause in elses { + match &else_clause.condition { + Some(else_condition) => { + let else_condition_result = else_condition.execute(shell, params).await?; + + // Check if the elif condition caused non-normal control flow. + if !else_condition_result.is_normal_flow() { + return Ok(else_condition_result); + } + + if else_condition_result.is_success() { + return else_clause.body.execute(shell, params).await; + } + } + None => { + return else_clause.body.execute(shell, params).await; + } + } + } + } + + let result = ExecutionResult::success(); + *shell.last_exit_status_mut() = result.exit_code.into(); + + Ok(result) + } +} + +#[async_trait::async_trait] +impl Execute for (WhileOrUntil, &ast::WhileOrUntilClauseCommand) { + async fn execute( + &self, + shell: &mut Shell, + params: &ExecutionParameters, + ) -> Result { + let is_while = match self.0 { + WhileOrUntil::While => true, + WhileOrUntil::Until => false, + }; + let test_condition = &self.1.0; + let body = &self.1.1; + + let mut result = ExecutionResult::success(); + + loop { + let condition_result = test_condition.execute(shell, params).await?; + if !condition_result.is_normal_flow() { + result = condition_result; + + // If the condition has break/continue, the while/until loop itself + // consumes one level. We need to decrement the level before returning. + result.next_control_flow = result.next_control_flow.try_decrement_loop_levels(); + break; + } + + if condition_result.is_success() != is_while { + break; + } + + result = body.list.execute(shell, params).await?; + if result.is_return_or_exit() { + break; + } + + let is_break = result.is_break(); + + result.next_control_flow = result.next_control_flow.try_decrement_loop_levels(); + + if is_break || result.is_continue() { + break; + } + } + + Ok(result) + } +} + +#[async_trait::async_trait] +impl Execute for ast::ArithmeticCommand { + async fn execute( + &self, + shell: &mut Shell, + params: &ExecutionParameters, + ) -> Result { + let value = self.expr.eval(shell, params, true).await?; + let result = if value != 0 { + ExecutionResult::success() + } else { + ExecutionResult::general_error() + }; + + *shell.last_exit_status_mut() = result.exit_code.into(); + + Ok(result) + } +} + +#[async_trait::async_trait] +impl Execute for ast::ArithmeticForClauseCommand { + async fn execute( + &self, + shell: &mut Shell, + params: &ExecutionParameters, + ) -> Result { + let mut result = ExecutionResult::success(); + if let Some(initializer) = &self.initializer { + initializer.eval(shell, params, true).await?; + } + + loop { + if let Some(condition) = &self.condition { + if condition.eval(shell, params, true).await? == 0 { + break; + } + } + + result = self.body.list.execute(shell, params).await?; + if result.is_return_or_exit() { + break; + } + + let is_break = result.is_break(); + + result.next_control_flow = result.next_control_flow.try_decrement_loop_levels(); + + if is_break || result.is_continue() { + break; + } + + if let Some(updater) = &self.updater { + updater.eval(shell, params, true).await?; + } + } + + *shell.last_exit_status_mut() = result.exit_code.into(); + Ok(result) + } +} + +#[async_trait::async_trait] +impl Execute for ast::FunctionDefinition { + async fn execute( + &self, + shell: &mut Shell, + _params: &ExecutionParameters, + ) -> Result { + shell.define_func(self.fname.value.clone(), self.clone()); + + let result = ExecutionResult::success(); + *shell.last_exit_status_mut() = result.exit_code.into(); + + Ok(result) + } +} + +#[async_trait::async_trait] +#[allow(clippy::too_many_lines)] +impl ExecuteInPipeline for ast::SimpleCommand { + async fn execute_in_pipeline( + &self, + context: &mut PipelineExecutionContext<'_>, + mut params: ExecutionParameters, + ) -> Result { + let prefix_iter = self.prefix.as_ref().map(|s| s.0.iter()).unwrap_or_default(); + let suffix_iter = self.suffix.as_ref().map(|s| s.0.iter()).unwrap_or_default(); + let cmd_name_items = self + .word_or_name + .as_ref() + .map(|won| CommandPrefixOrSuffixItem::Word(won.clone())); + + // Set up pipelining. + setup_pipeline_redirection(&mut params.open_files, context)?; + + let mut assignments = vec![]; + let mut args: Vec = vec![]; + let mut command_takes_assignments = false; + + for item in prefix_iter.chain(cmd_name_items.iter()).chain(suffix_iter) { + match item { + CommandPrefixOrSuffixItem::IoRedirect(redirect) => { + if let Err(e) = setup_redirect(context.shell, &mut params, redirect).await { + writeln!(params.stderr(context.shell), "error: {e}")?; + return Ok(ExecutionResult::general_error().into()); + } + } + CommandPrefixOrSuffixItem::ProcessSubstitution(kind, subshell_command) => { + let (installed_fd_num, substitution_file) = + setup_process_substitution(context.shell, ¶ms, kind, subshell_command)?; + + params + .open_files + .set_fd(installed_fd_num, substitution_file); + + args.push(CommandArg::String(std::format!( + "/dev/fd/{installed_fd_num}" + ))); + } + CommandPrefixOrSuffixItem::AssignmentWord(assignment, word) => { + if args.is_empty() { + // If we haven't yet seen any arguments, then this must be a proper + // scoped assignment. Add it to the list we're accumulating. + assignments.push(assignment); + } else { + if command_takes_assignments { + // This looks like an assignment, and the command being invoked is a + // well-known builtin that takes arguments that need to function like + // assignments (but which are processed by the builtin). + let expanded = + expand_assignment(context.shell, ¶ms, assignment).await?; + args.push(CommandArg::Assignment(expanded)); + } else { + // This *looks* like an assignment, but it's really a string we should + // fully treat as a regular looking + // argument. + let mut next_args = + expansion::full_expand_and_split_word(context.shell, ¶ms, word) + .await? + .into_iter() + .map(CommandArg::String) + .collect(); + args.append(&mut next_args); + } + } + } + CommandPrefixOrSuffixItem::Word(arg) => { + let mut next_args = + expansion::full_expand_and_split_word(context.shell, ¶ms, arg).await?; + + if args.is_empty() { + if let Some(cmd_name) = next_args.first() { + if let Some(alias_value) = context.shell.aliases.get(cmd_name.as_str()) + { + // + // TODO(#57): This is a total hack; aliases are supposed to be + // handled much earlier in the process. + // + let mut alias_pieces: Vec<_> = alias_value + .split_ascii_whitespace() + .map(|i| i.to_owned()) + .collect(); + + next_args.remove(0); + alias_pieces.append(&mut next_args); + + next_args = alias_pieces; + } + + let first_arg = next_args[0].as_str(); + + // Check if we're going to be invoking a special declaration builtin. + // That will change how we parse and process args. + if context + .shell + .builtins() + .get(first_arg) + .is_some_and(|r| !r.disabled && r.declaration_builtin) + { + command_takes_assignments = true; + } + } + } + + let mut next_args = next_args.into_iter().map(CommandArg::String).collect(); + args.append(&mut next_args); + } + } + } + + // If we have a command, then execute it. + if let Some(CommandArg::String(cmd_name)) = args.first().cloned() { + let mut stderr = params.stderr(context.shell); + + match execute_command(context, params, cmd_name, assignments, args).await { + Ok(result) => Ok(result), + Err(err) => { + let _ = context.shell.display_error(&mut stderr, &err).await; + let exit_code = ExecutionExitCode::from(&err); + Ok(ExecutionResult::from(exit_code).into()) + } + } + } else { + // Reset last status. + *context.shell.last_exit_status_mut() = 0; + + // No command to run; assignments must be applied to this shell. + for assignment in assignments { + apply_assignment( + assignment, + context.shell, + ¶ms, + false, + None, + EnvironmentScope::Global, + ) + .await?; + } + + // Return the last exit status we have; in some cases, an expansion + // might result in a non-zero exit status stored in the shell. + Ok(ExecutionResult::new(context.shell.last_result()).into()) + } + } +} + +async fn execute_command( + context: &mut PipelineExecutionContext<'_>, + params: ExecutionParameters, + cmd_name: String, + assignments: Vec<&ast::Assignment>, + args: Vec, +) -> Result { + // Push a new ephemeral environment scope for the duration of the command. We'll + // set command-scoped variable assignments after doing so, and revert them before + // returning. + context.shell.env.push_scope(EnvironmentScope::Command); + for assignment in &assignments { + // Ensure it's tagged as exported and created in the command scope. + apply_assignment( + assignment, + context.shell, + ¶ms, + true, + Some(EnvironmentScope::Command), + EnvironmentScope::Command, + ) + .await?; + } + + if context.shell.options.print_commands_and_arguments { + context + .shell + .trace_command( + ¶ms, + args.iter().map(|arg| arg.quote_for_tracing()).join(" "), + ) + .await?; + } + + let mut cmd_context = commands::ExecutionContext { + shell: context.shell, + command_name: cmd_name, + params, + }; + + // Run through any pre-execution hooks. + commands::on_preexecute(&mut cmd_context, args.as_slice()).await?; + + // Execute. + let execution_result = commands::execute( + cmd_context, + &mut context.process_group_id, + args, + true, /* use functions? */ + None, + ) + .await; + + // Pop off that ephemeral environment scope. + // TODO: jobs: do we need to move self back to foreground on error here? + context.shell.env.pop_scope(EnvironmentScope::Command)?; + + execution_result +} + +async fn expand_assignment( + shell: &mut Shell, + params: &ExecutionParameters, + assignment: &ast::Assignment, +) -> Result { + let value = expand_assignment_value(shell, params, &assignment.value).await?; + Ok(ast::Assignment { + name: basic_expand_assignment_name(shell, params, &assignment.name).await?, + value, + append: assignment.append, + loc: assignment.loc.clone(), + }) +} + +async fn basic_expand_assignment_name( + shell: &mut Shell, + params: &ExecutionParameters, + name: &ast::AssignmentName, +) -> Result { + match name { + ast::AssignmentName::VariableName(name) => { + let expanded = expansion::basic_expand_str(shell, params, name).await?; + Ok(ast::AssignmentName::VariableName(expanded)) + } + ast::AssignmentName::ArrayElementName(name, index) => { + let expanded_name = expansion::basic_expand_str(shell, params, name).await?; + let expanded_index = expansion::basic_expand_str(shell, params, index).await?; + Ok(ast::AssignmentName::ArrayElementName( + expanded_name, + expanded_index, + )) + } + } +} + +async fn expand_assignment_value( + shell: &mut Shell, + params: &ExecutionParameters, + value: &ast::AssignmentValue, +) -> Result { + let expanded = match value { + ast::AssignmentValue::Scalar(s) => { + let expanded_word = expansion::basic_expand_word(shell, params, s).await?; + ast::AssignmentValue::Scalar(ast::Word::from(expanded_word)) + } + ast::AssignmentValue::Array(arr) => { + let mut expanded_values = vec![]; + for (key, value) in arr { + if let Some(k) = key { + let expanded_key = expansion::basic_expand_word(shell, params, k).await?.into(); + let expanded_value = expansion::basic_expand_word(shell, params, value) + .await? + .into(); + expanded_values.push((Some(expanded_key), expanded_value)); + } else { + let split_expanded_value = + expansion::full_expand_and_split_word(shell, params, value).await?; + for expanded_value in split_expanded_value { + expanded_values.push((None, expanded_value.into())); + } + } + } + + ast::AssignmentValue::Array(expanded_values) + } + }; + + Ok(expanded) +} + +#[expect(clippy::too_many_lines)] +async fn apply_assignment( + assignment: &ast::Assignment, + shell: &mut Shell, + params: &ExecutionParameters, + mut export: bool, + required_scope: Option, + creation_scope: EnvironmentScope, +) -> Result<(), error::Error> { + // Figure out if we are trying to assign to a variable or assign to an element of an existing + // array. + let mut array_index; + let variable_name = match &assignment.name { + ast::AssignmentName::VariableName(name) => { + array_index = None; + name + } + ast::AssignmentName::ArrayElementName(name, index) => { + let expanded = expansion::basic_expand_str(shell, params, index).await?; + array_index = Some(expanded); + name + } + }; + + // Expand the values. + let new_value = match &assignment.value { + ast::AssignmentValue::Scalar(unexpanded_value) => { + let value = expansion::basic_expand_word(shell, params, unexpanded_value).await?; + ShellValueLiteral::Scalar(value) + } + ast::AssignmentValue::Array(unexpanded_values) => { + let mut elements = vec![]; + for (unexpanded_key, unexpanded_value) in unexpanded_values { + let key = match unexpanded_key { + Some(unexpanded_key) => { + Some(expansion::basic_expand_word(shell, params, unexpanded_key).await?) + } + None => None, + }; + + if key.is_some() { + let value = + expansion::basic_expand_word(shell, params, unexpanded_value).await?; + elements.push((key, value)); + } else { + let values = + expansion::full_expand_and_split_word(shell, params, unexpanded_value) + .await?; + for value in values { + elements.push((None, value)); + } + } + } + ShellValueLiteral::Array(ArrayLiteral(elements)) + } + }; + + if shell.options.print_commands_and_arguments { + let op = if assignment.append { "+=" } else { "=" }; + shell + .trace_command(params, std::format!("{}{op}{new_value}", assignment.name)) + .await?; + } + + // See if we need to eval an array index. + if let Some(idx) = &array_index { + let will_be_indexed_array = if let Some((_, existing_value)) = shell.env.get(variable_name) + { + matches!( + existing_value.value(), + ShellValue::IndexedArray(_) | ShellValue::Unset(ShellValueUnsetType::IndexedArray) + ) + } else { + true + }; + + if will_be_indexed_array { + array_index = Some( + arithmetic::expand_and_eval(shell, params, idx.as_str(), false) + .await? + .to_string(), + ); + } + } + + // See if we can find an existing value associated with the variable. + if let Some((existing_value_scope, existing_value)) = shell.env.get_mut(variable_name.as_str()) + { + if required_scope.is_none() || Some(existing_value_scope) == required_scope { + if let Some(array_index) = array_index { + match new_value { + ShellValueLiteral::Scalar(s) => { + existing_value.assign_at_index(array_index, s, assignment.append)?; + } + ShellValueLiteral::Array(_) => { + return error::unimp("replacing an array item with an array"); + } + } + } else { + if !export + && shell.options.export_variables_on_modification + && !matches!(new_value, ShellValueLiteral::Array(_)) + { + export = true; + } + + existing_value.assign(new_value, assignment.append)?; + } + + if export { + existing_value.export(); + } + + // That's it! + return Ok(()); + } + } + + // If we fell down here, then we need to add it. + let new_value = if let Some(array_index) = array_index { + match new_value { + ShellValueLiteral::Scalar(s) => { + ShellValue::indexed_array_from_literals(ArrayLiteral(vec![(Some(array_index), s)])) + } + ShellValueLiteral::Array(_) => { + return error::unimp("cannot assign list to array member"); + } + } + } else { + match new_value { + ShellValueLiteral::Scalar(s) => { + export = export || shell.options.export_variables_on_modification; + ShellValue::String(s) + } + ShellValueLiteral::Array(values) => ShellValue::indexed_array_from_literals(values), + } + }; + + let mut new_var = ShellVariable::new(new_value); + + if export { + new_var.export(); + } + + shell.env.add(variable_name, new_var, creation_scope) +} + +fn setup_pipeline_redirection( + open_files: &mut OpenFiles, + context: &mut PipelineExecutionContext<'_>, +) -> Result<(), error::Error> { + if context.current_pipeline_index > 0 { + // Find the stdout from the preceding process. + if let Some(preceding_output_reader) = context.output_pipes.pop() { + // Set up stdin of this process to take stdout of the preceding process. + open_files.set_fd(OpenFiles::STDIN_FD, preceding_output_reader.into()); + } else { + open_files.set_fd(OpenFiles::STDIN_FD, openfiles::null()?); + } + } + + // If this is a non-last command in a multi-command, then we need to arrange to redirect output + // to a pipe that we can read later. + if context.pipeline_len > 1 && context.current_pipeline_index < context.pipeline_len - 1 { + // Set up stdout of this process to go to stdin of the succeeding process. + let (reader, writer) = std::io::pipe()?; + context.output_pipes.push(reader); + open_files.set_fd(OpenFiles::STDOUT_FD, writer.into()); + } + + Ok(()) +} + +#[expect(clippy::too_many_lines)] +pub(crate) async fn setup_redirect( + shell: &mut Shell, + params: &'_ mut ExecutionParameters, + redirect: &ast::IoRedirect, +) -> Result<(), error::Error> { + match redirect { + ast::IoRedirect::OutputAndError(f, append) => { + let mut expanded_fields = + expansion::full_expand_and_split_word(shell, params, f).await?; + if expanded_fields.len() != 1 { + return Err(error::ErrorKind::InvalidRedirection.into()); + } + + let expanded_file_path: PathBuf = + shell.absolute_path(Path::new(expanded_fields.remove(0).as_str())); + + let mut file_options = std::fs::File::options(); + file_options + .create(true) + .write(true) + .truncate(!*append) + .append(*append); + + let stdout_file = shell + .open_file(&file_options, &expanded_file_path, params) + .map_err(|err| { + error::ErrorKind::RedirectionFailure( + expanded_file_path.to_string_lossy().to_string(), + err.to_string(), + ) + })?; + + let stderr_file = stdout_file.try_clone()?; + + params.open_files.set_fd(OpenFiles::STDOUT_FD, stdout_file); + params.open_files.set_fd(OpenFiles::STDERR_FD, stderr_file); + } + + ast::IoRedirect::File(specified_fd_num, kind, target) => { + match target { + ast::IoFileRedirectTarget::Filename(f) => { + let mut options = std::fs::File::options(); + + let mut expanded_fields = + expansion::full_expand_and_split_word(shell, params, f).await?; + + if expanded_fields.len() != 1 { + return Err(error::ErrorKind::InvalidRedirection.into()); + } + + let expanded_file_path: PathBuf = + shell.absolute_path(Path::new(expanded_fields.remove(0).as_str())); + + let default_fd_if_unspecified = get_default_fd_for_redirect_kind(kind); + match kind { + ast::IoFileRedirectKind::Read => { + options.read(true); + } + ast::IoFileRedirectKind::Write => { + if shell + .options + .disallow_overwriting_regular_files_via_output_redirection + { + // First check to see if the path points to an existing regular + // file. + if !expanded_file_path.is_file() { + options.create(true); + } else { + options.create_new(true); + } + options.write(true); + } else { + options.create(true); + options.write(true); + options.truncate(true); + } + } + ast::IoFileRedirectKind::Append => { + options.create(true); + options.append(true); + } + ast::IoFileRedirectKind::ReadAndWrite => { + options.create(true); + options.read(true); + options.write(true); + } + ast::IoFileRedirectKind::Clobber => { + options.create(true); + options.write(true); + options.truncate(true); + } + ast::IoFileRedirectKind::DuplicateInput => { + options.read(true); + } + ast::IoFileRedirectKind::DuplicateOutput => { + options.create(true); + options.write(true); + } + } + + let fd_num = specified_fd_num.unwrap_or(default_fd_if_unspecified); + + let opened_file = shell + .open_file(&options, &expanded_file_path, params) + .map_err(|err| { + error::ErrorKind::RedirectionFailure( + expanded_file_path.to_string_lossy().to_string(), + err.to_string(), + ) + })?; + + params.open_files.set_fd(fd_num, opened_file); + } + + ast::IoFileRedirectTarget::Fd(fd) => { + let default_fd_if_unspecified = match kind { + ast::IoFileRedirectKind::DuplicateInput => 0, + ast::IoFileRedirectKind::DuplicateOutput => 1, + _ => { + return error::unimp("unexpected redirect kind"); + } + }; + + let fd_num = specified_fd_num.unwrap_or(default_fd_if_unspecified); + + if let Some(f) = params.try_fd(shell, *fd) { + let target_file = f.try_clone()?; + + params.open_files.set_fd(fd_num, target_file); + } else { + return Err(error::ErrorKind::BadFileDescriptor(*fd).into()); + } + } + + ast::IoFileRedirectTarget::Duplicate(word) => { + let default_fd_if_unspecified = match kind { + ast::IoFileRedirectKind::DuplicateInput => 0, + ast::IoFileRedirectKind::DuplicateOutput => 1, + _ => { + return error::unimp("unexpected redirect kind"); + } + }; + + let fd_num = specified_fd_num.unwrap_or(default_fd_if_unspecified); + + let mut expanded_fields = + expansion::full_expand_and_split_word(shell, params, word).await?; + + if expanded_fields.len() != 1 { + return Err(error::ErrorKind::InvalidRedirection.into()); + } + + let mut expanded = expanded_fields.remove(0); + + let dash = if expanded.ends_with('-') { + expanded.pop(); + true + } else { + false + }; + + if expanded.is_empty() { + // Nothing to do + } else if expanded.chars().all(|c: char| c.is_ascii_digit()) { + let source_fd_num = expanded + .parse::() + .map_err(|_| error::ErrorKind::InvalidRedirection)?; + + // Duplicate the fd. + let target_file = if let Some(f) = params.try_fd(shell, source_fd_num) { + f.try_clone()? + } else { + return Err(error::ErrorKind::BadFileDescriptor(source_fd_num).into()); + }; + + params.open_files.set_fd(fd_num, target_file); + } else { + return Err(error::ErrorKind::InvalidRedirection.into()); + } + + if dash { + // Close the specified fd. Ignore it if it's not valid. + params.open_files.remove_fd(fd_num); + } + } + + ast::IoFileRedirectTarget::ProcessSubstitution(substitution_kind, subshell_cmd) => { + match kind { + ast::IoFileRedirectKind::Read + | ast::IoFileRedirectKind::Write + | ast::IoFileRedirectKind::Append + | ast::IoFileRedirectKind::ReadAndWrite + | ast::IoFileRedirectKind::Clobber => { + let (substitution_fd, substitution_file) = setup_process_substitution( + shell, + params, + substitution_kind, + subshell_cmd, + )?; + + let target_file = substitution_file.try_clone()?; + params.open_files.set_fd(substitution_fd, substitution_file); + + let fd_num = specified_fd_num + .unwrap_or_else(|| get_default_fd_for_redirect_kind(kind)); + + params.open_files.set_fd(fd_num, target_file); + } + _ => return error::unimp("invalid process substitution"), + } + } + } + } + + ast::IoRedirect::HereDocument(fd_num, io_here) => { + // If not specified, default to stdin (fd 0). + let fd_num = fd_num.unwrap_or(0); + + // Expand if required. + let io_here_doc = if io_here.requires_expansion { + expansion::basic_expand_word(shell, params, &io_here.doc).await? + } else { + io_here.doc.flatten() + }; + + let f = setup_open_file_with_contents(io_here_doc.as_str())?; + + params.open_files.set_fd(fd_num, f); + } + + ast::IoRedirect::HereString(fd_num, word) => { + // If not specified, default to stdin (fd 0). + let fd_num = fd_num.unwrap_or(0); + + let mut expanded_word = expansion::basic_expand_word(shell, params, word).await?; + expanded_word.push('\n'); + + let f = setup_open_file_with_contents(expanded_word.as_str())?; + + params.open_files.set_fd(fd_num, f); + } + } + + Ok(()) +} + +const fn get_default_fd_for_redirect_kind(kind: &ast::IoFileRedirectKind) -> ShellFd { + match kind { + ast::IoFileRedirectKind::Read => 0, + ast::IoFileRedirectKind::Write => 1, + ast::IoFileRedirectKind::Append => 1, + ast::IoFileRedirectKind::ReadAndWrite => 0, + ast::IoFileRedirectKind::Clobber => 1, + ast::IoFileRedirectKind::DuplicateInput => 0, + ast::IoFileRedirectKind::DuplicateOutput => 1, + } +} + +fn setup_process_substitution( + shell: &Shell, + params: &ExecutionParameters, + kind: &ast::ProcessSubstitutionKind, + subshell_cmd: &ast::SubshellCommand, +) -> Result<(ShellFd, OpenFile), error::Error> { + // TODO: Don't execute synchronously! + // Execute in a subshell. + let mut subshell = shell.clone(); + + // Set up execution parameters for the child execution. + let mut child_params = params.clone(); + child_params.process_group_policy = ProcessGroupPolicy::SameProcessGroup; + + // Set up pipe so we can connect to the command. + let (reader, writer) = std::io::pipe()?; + let (reader, writer) = (reader.into(), writer.into()); + + let target_file = match kind { + ast::ProcessSubstitutionKind::Read => { + child_params.open_files.set_fd(OpenFiles::STDOUT_FD, writer); + reader + } + ast::ProcessSubstitutionKind::Write => { + child_params.open_files.set_fd(OpenFiles::STDIN_FD, reader); + writer + } + }; + + // Asynchronously spawn off the subshell; we intentionally don't block on its + // completion. + let subshell_cmd = subshell_cmd.to_owned(); + tokio::spawn(async move { + // Intentionally ignore the result of the subshell command. + let _ = subshell_cmd + .list + .execute(&mut subshell, &child_params) + .await; + }); + + // Starting at 63 (a.k.a. 64-1)--and decrementing--look for an + // available fd. + let mut candidate_fd_num = 63; + while params.open_files.contains_fd(candidate_fd_num) { + candidate_fd_num -= 1; + if candidate_fd_num == 0 { + return error::unimp("no available file descriptors"); + } + } + + Ok((candidate_fd_num, target_file)) +} + +fn setup_open_file_with_contents(contents: &str) -> Result { + let (reader, mut writer) = std::io::pipe()?; + + let bytes = contents.as_bytes(); + + #[cfg(target_os = "linux")] + { + use std::os::fd::AsFd as _; + + let len = i32::try_from(bytes.len())?; + nix::fcntl::fcntl(reader.as_fd(), nix::fcntl::FcntlArg::F_SETPIPE_SZ(len))?; + } + + writer.write_all(bytes)?; + drop(writer); + + Ok(reader.into()) +} diff --git a/crates/brush-core-vendored/src/jobs.rs b/crates/brush-core-vendored/src/jobs.rs new file mode 100644 index 000000000..14a71f909 --- /dev/null +++ b/crates/brush-core-vendored/src/jobs.rs @@ -0,0 +1,444 @@ +//! Job management + +use std::collections::VecDeque; +use std::fmt::Display; + +use futures::FutureExt; + +use crate::ExecutionResult; +use crate::error; +use crate::processes; +use crate::sys; +use crate::trace_categories; +use crate::traps; + +pub(crate) type JobJoinHandle = tokio::task::JoinHandle>; +pub(crate) type JobResult = (Job, Result); + +/// Manages the jobs that are currently managed by the shell. +#[derive(Default)] +pub struct JobManager { + /// The jobs that are currently managed by the shell. + pub jobs: Vec, +} + +/// Represents a task that is part of a job. +pub enum JobTask { + /// An external process. + External(processes::ChildProcess), + /// An internal asynchronous task. + Internal(JobJoinHandle), +} + +/// Represents the result of waiting on a job task. +pub enum JobTaskWaitResult { + /// The task has completed. + Completed(ExecutionResult), + /// The task was stopped. + Stopped, +} + +impl JobTask { + /// Waits for the task to complete. Returns the result of the wait. + pub async fn wait(&mut self) -> Result { + match self { + Self::External(process) => { + let wait_result = process.wait().await?; + match wait_result { + processes::ProcessWaitResult::Completed(output) => { + Ok(JobTaskWaitResult::Completed(output.into())) + } + processes::ProcessWaitResult::Stopped => Ok(JobTaskWaitResult::Stopped), + } + } + Self::Internal(handle) => Ok(JobTaskWaitResult::Completed(handle.await??)), + } + } + + #[allow(clippy::unwrap_in_result)] + fn poll(&mut self) -> Option> { + match self { + Self::External(process) => { + let check_result = process.poll(); + check_result.map(|polled_result| polled_result.map(|output| output.into())) + } + Self::Internal(handle) => { + let checkable_handle = handle; + checkable_handle.now_or_never().map(|r| r.unwrap()) + } + } + } +} + +impl JobManager { + /// Returns a new job manager. + pub fn new() -> Self { + Self::default() + } + + /// Adds a job to the job manager and marks it as the current job; + /// returns an immutable reference to the job. + /// + /// # Arguments + /// + /// * `job` - The job to add. + pub fn add_as_current(&mut self, mut job: Job) -> &Job { + for j in &mut self.jobs { + if matches!(j.annotation, JobAnnotation::Current) { + j.annotation = JobAnnotation::Previous; + break; + } + } + + let id = self.jobs.len() + 1; + job.id = id; + job.annotation = JobAnnotation::Current; + self.jobs.push(job); + self.jobs.last().unwrap() + } + + /// Returns the current job, if there is one. + pub fn current_job(&self) -> Option<&Job> { + self.jobs + .iter() + .find(|j| matches!(j.annotation, JobAnnotation::Current)) + } + + /// Returns a mutable reference to the current job, if there is one. + pub fn current_job_mut(&mut self) -> Option<&mut Job> { + self.jobs + .iter_mut() + .find(|j| matches!(j.annotation, JobAnnotation::Current)) + } + + /// Returns the previous job, if there is one. + pub fn prev_job(&self) -> Option<&Job> { + self.jobs + .iter() + .find(|j| matches!(j.annotation, JobAnnotation::Previous)) + } + + /// Returns a mutable reference to the previous job, if there is one. + pub fn prev_job_mut(&mut self) -> Option<&mut Job> { + self.jobs + .iter_mut() + .find(|j| matches!(j.annotation, JobAnnotation::Previous)) + } + + /// Tries to resolve the given job specification to a job. + /// + /// # Arguments + /// + /// * `job_spec` - The job specification to resolve. + pub fn resolve_job_spec(&mut self, job_spec: &str) -> Option<&mut Job> { + let remainder = job_spec.strip_prefix('%')?; + + match remainder { + "%" | "+" => self.current_job_mut(), + "-" => self.prev_job_mut(), + s if s.chars().all(char::is_numeric) => { + let id = s.parse::().ok()?; + self.jobs.iter_mut().find(|j| j.id == id) + } + _ => { + tracing::warn!(target: trace_categories::UNIMPLEMENTED, "unimplemented: job spec naming command: '{job_spec}'"); + None + } + } + } + + /// Waits for all managed jobs to complete. + pub async fn wait_all(&mut self) -> Result, error::Error> { + for job in &mut self.jobs { + job.wait().await?; + } + + Ok(self.sweep_completed_jobs()) + } + + /// Polls all managed jobs for completion. + pub fn poll(&mut self) -> Result, error::Error> { + let mut results = vec![]; + + let mut i = 0; + while i != self.jobs.len() { + if let Some(result) = self.jobs[i].poll_done()? { + let job = self.jobs.remove(i); + results.push((job, result)); + } else if matches!(self.jobs[i].state, JobState::Done) { + // TODO: This is a workaround to remove jobs that are done but for which we don't + // know what happened. + results.push((self.jobs.remove(i), Ok(ExecutionResult::success()))); + } else { + i += 1; + } + } + + Ok(results) + } + + fn sweep_completed_jobs(&mut self) -> Vec { + let mut completed_jobs = vec![]; + + let mut i = 0; + while i != self.jobs.len() { + if self.jobs[i].tasks.is_empty() { + completed_jobs.push(self.jobs.remove(i)); + } else { + i += 1; + } + } + + completed_jobs + } +} + +/// Represents the current execution state of a job. +#[derive(Clone)] +pub enum JobState { + /// Unknown state. + Unknown, + /// The job is running. + Running, + /// The job is stopped. + Stopped, + /// The job has completed. + Done, +} + +impl Display for JobState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Unknown => write!(f, "Unknown"), + Self::Running => write!(f, "Running"), + Self::Stopped => write!(f, "Stopped"), + Self::Done => write!(f, "Done"), + } + } +} + +/// Represents an annotation for a job. +#[derive(Clone)] +pub enum JobAnnotation { + /// No annotation. + None, + /// The job is the current job. + Current, + /// The job is the previous job. + Previous, +} + +impl Display for JobAnnotation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::None => write!(f, ""), + Self::Current => write!(f, "+"), + Self::Previous => write!(f, "-"), + } + } +} + +/// Encapsulates a set of processes managed by the shell as a single unit. +pub struct Job { + /// The tasks that make up the job. + tasks: VecDeque, + + /// If available, the process group ID of the job's processes. + pgid: Option, + + /// The annotation of the job (e.g., current, previous). + annotation: JobAnnotation, + + /// The shell-internal ID of the job. + pub id: usize, + + /// The command line of the job. + pub command_line: String, + + /// The current operational state of the job. + pub state: JobState, +} + +impl Display for Job { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "[{}]{:3}{}\t{}", + self.id, + self.annotation.to_string(), + self.state, + self.command_line + ) + } +} + +impl Job { + /// Returns a new job object. + /// + /// # Arguments + /// + /// * `children` - The job's known child processes. + /// * `command_line` - The command line of the job. + /// * `state` - The current operational state of the job. + pub(crate) fn new(tasks: I, command_line: String, state: JobState) -> Self + where + I: IntoIterator, + { + Self { + id: 0, + tasks: tasks.into_iter().collect(), + pgid: None, + annotation: JobAnnotation::None, + command_line, + state, + } + } + + /// Returns a pid-style string for the job. + pub fn to_pid_style_string(&self) -> String { + let display_pid = self + .representative_pid() + .map_or_else(|| String::from(""), |pid| pid.to_string()); + std::format!("[{}]{}\t{}", self.id, self.annotation, display_pid) + } + + /// Returns the annotation of the job. + pub fn annotation(&self) -> JobAnnotation { + self.annotation.clone() + } + + /// Returns the command name of the job. + pub fn command_name(&self) -> &str { + self.command_line + .split_ascii_whitespace() + .next() + .unwrap_or_default() + } + + /// Returns whether the job is the current job. + pub const fn is_current(&self) -> bool { + matches!(self.annotation, JobAnnotation::Current) + } + + /// Returns whether the job is the previous job. + pub const fn is_prev(&self) -> bool { + matches!(self.annotation, JobAnnotation::Previous) + } + + /// Polls whether the job has completed. + pub fn poll_done( + &mut self, + ) -> Result>, error::Error> { + let mut result: Option> = None; + + tracing::debug!(target: trace_categories::JOBS, "Polling job {} for completion...", self.id); + + while !self.tasks.is_empty() { + let task = &mut self.tasks[0]; + match task.poll() { + Some(r) => { + self.tasks.remove(0); + result = Some(r); + } + None => { + return Ok(None); + } + } + } + + tracing::debug!(target: trace_categories::JOBS, "Job {} has completed.", self.id); + + self.state = JobState::Done; + + Ok(result) + } + + /// Waits for the job to complete. + pub async fn wait(&mut self) -> Result { + let mut result = ExecutionResult::success(); + + while let Some(task) = self.tasks.back_mut() { + match task.wait().await? { + JobTaskWaitResult::Completed(execution_result) => { + result = execution_result; + self.tasks.pop_back(); + } + JobTaskWaitResult::Stopped => { + self.state = JobState::Stopped; + return Ok(ExecutionResult::stopped()); + } + } + } + + self.state = JobState::Done; + + Ok(result) + } + + /// Moves the job to execute in the background. + pub fn move_to_background(&mut self) -> Result<(), error::Error> { + if matches!(self.state, JobState::Stopped) { + if let Some(pgid) = self.process_group_id() { + sys::signal::continue_process(pgid)?; + self.state = JobState::Running; + Ok(()) + } else { + Err(error::ErrorKind::FailedToSendSignal.into()) + } + } else { + error::unimp("move job to background") + } + } + + /// Moves the job to execute in the foreground. + pub fn move_to_foreground(&mut self) -> Result<(), error::Error> { + if matches!(self.state, JobState::Stopped) { + if let Some(pgid) = self.process_group_id() { + sys::signal::continue_process(pgid)?; + self.state = JobState::Running; + } else { + return Err(error::ErrorKind::FailedToSendSignal.into()); + } + } + + if let Some(pgid) = self.process_group_id() { + sys::terminal::move_to_foreground(pgid)?; + } + + Ok(()) + } + + /// Kills the job. + /// + /// # Arguments + /// + /// * `signal` - The signal to send to the job. + pub fn kill(&self, signal: traps::TrapSignal) -> Result<(), error::Error> { + if let Some(pid) = self.process_group_id() { + sys::signal::kill_process(pid, signal) + } else { + Err(error::ErrorKind::FailedToSendSignal.into()) + } + } + + /// Tries to retrieve a "representative" pid for the job. + pub fn representative_pid(&self) -> Option { + for task in &self.tasks { + match task { + JobTask::External(p) => { + if let Some(pid) = p.pid() { + return Some(pid); + } + } + JobTask::Internal(_) => (), + } + } + None + } + + /// Tries to retrieve the process group ID (PGID) of the job. + pub fn process_group_id(&self) -> Option { + // TODO: Don't assume that the first PID is the PGID. + self.pgid.or_else(|| self.representative_pid()) + } +} diff --git a/crates/brush-core-vendored/src/keywords.rs b/crates/brush-core-vendored/src/keywords.rs new file mode 100644 index 000000000..ac353553d --- /dev/null +++ b/crates/brush-core-vendored/src/keywords.rs @@ -0,0 +1,37 @@ +use std::collections::HashSet; +use std::sync::LazyLock; + +fn get_keywords(sh_mode_only: bool) -> HashSet { + let mut keywords = HashSet::new(); + keywords.insert(String::from("!")); + keywords.insert(String::from("{")); + keywords.insert(String::from("}")); + keywords.insert(String::from("case")); + keywords.insert(String::from("do")); + keywords.insert(String::from("done")); + keywords.insert(String::from("elif")); + keywords.insert(String::from("else")); + keywords.insert(String::from("esac")); + keywords.insert(String::from("fi")); + keywords.insert(String::from("for")); + keywords.insert(String::from("if")); + keywords.insert(String::from("in")); + keywords.insert(String::from("then")); + keywords.insert(String::from("until")); + keywords.insert(String::from("while")); + + if !sh_mode_only { + keywords.insert(String::from("[[")); + keywords.insert(String::from("]]")); + keywords.insert(String::from("coproc")); + keywords.insert(String::from("function")); + keywords.insert(String::from("select")); + keywords.insert(String::from("time")); + } + + keywords +} + +pub(crate) static SH_MODE_KEYWORDS: LazyLock> = + LazyLock::new(|| get_keywords(true)); +pub(crate) static KEYWORDS: LazyLock> = LazyLock::new(|| get_keywords(false)); diff --git a/crates/brush-core-vendored/src/lib.rs b/crates/brush-core-vendored/src/lib.rs new file mode 100644 index 000000000..d45108e06 --- /dev/null +++ b/crates/brush-core-vendored/src/lib.rs @@ -0,0 +1,46 @@ +//! Core implementation of the brush shell. Implements the shell's abstraction, its interpreter, and +//! various facilities used internally by the shell. + +pub mod arithmetic; +mod braceexpansion; +pub mod builtins; +pub mod commands; +pub mod completion; +pub mod env; +pub mod error; +pub mod escape; +pub mod expansion; +mod extendedtests; +pub mod functions; +pub mod history; +pub mod interfaces; +mod interp; +pub mod jobs; +mod keywords; +pub mod namedoptions; +pub mod openfiles; +pub mod options; +pub mod pathcache; +pub mod pathsearch; +pub mod patterns; +pub mod processes; +mod prompt; +mod regex; +pub mod results; +pub mod scripts; +mod shell; +pub mod sys; +pub mod terminal; +pub mod tests; +pub mod timing; +pub mod trace_categories; +pub mod traps; +pub mod variables; +mod wellknownvars; + +pub use commands::{CommandArg, ExecutionContext}; +pub use error::{BuiltinError, Error, ErrorKind}; +pub use interp::{ExecutionParameters, ProcessGroupPolicy}; +pub use results::{ExecutionControlFlow, ExecutionExitCode, ExecutionResult, ExecutionSpawnResult}; +pub use shell::{CreateOptions, Shell, ShellBuilder, ShellBuilderState, ShellFd}; +pub use variables::{ShellValue, ShellVariable}; diff --git a/crates/brush-core-vendored/src/namedoptions.rs b/crates/brush-core-vendored/src/namedoptions.rs new file mode 100644 index 000000000..5c2954d2c --- /dev/null +++ b/crates/brush-core-vendored/src/namedoptions.rs @@ -0,0 +1,829 @@ +//! Defines shell options. + +use std::collections::HashMap; +use std::sync::LazyLock; + +use crate::options::RuntimeOptions; + +type OptionGetter = fn(shell: &RuntimeOptions) -> bool; +type OptionSetter = fn(shell: &mut RuntimeOptions, value: bool) -> (); + +/// Defines an option. +pub struct ShellOptionDef { + /// Getter function that retrieves the current value of the option. + getter: OptionGetter, + /// Setter function that may be used to set the current value of the option. + setter: OptionSetter, +} + +impl ShellOptionDef { + /// Constructs a new option definition. + /// + /// # Arguments + /// + /// * `getter` - A function that retrieves the current value of the option. + /// * `setter` - A function that sets the current value of the option. + fn new(getter: OptionGetter, setter: OptionSetter) -> Self { + Self { getter, setter } + } + + /// Retrieves the current value of this option from the given runtime options. + /// + /// # Arguments + /// + /// * `options` - The runtime options to retrieve the value from. + pub fn get(&self, options: &RuntimeOptions) -> bool { + (self.getter)(options) + } + + /// Sets the value of this option in the given runtime options. + /// + /// # Arguments + /// + /// * `options` - The runtime options to modify. + /// * `value` - The new value to set for the option. + pub fn set(&self, options: &mut RuntimeOptions, value: bool) { + (self.setter)(options, value); + } +} + +/// Describes a shell option. +pub struct ShellOption { + /// The name of the option. + pub name: &'static str, + /// The definition of the option. + pub definition: &'static ShellOptionDef, +} + +/// Describes a set of shell options. +pub struct ShellOptionSet { + inner: &'static HashMap<&'static str, ShellOptionDef>, +} + +/// Kind of shell option. +#[derive(Clone, Copy)] +pub enum ShellOptionKind { + /// `set` option. + Set, + /// `set -o` option. + SetO, + /// `shopt` option. + Shopt, +} + +/// Returns the options for the given shell option kind. +/// +/// # Arguments +/// +/// * `kind` - The kind of shell options to retrieve. +pub fn options(kind: ShellOptionKind) -> ShellOptionSet { + match kind { + ShellOptionKind::Set => ShellOptionSet { + inner: &SET_OPTIONS, + }, + ShellOptionKind::SetO => ShellOptionSet { + inner: &SET_O_OPTIONS, + }, + ShellOptionKind::Shopt => ShellOptionSet { + inner: &SHOPT_OPTIONS, + }, + } +} + +impl ShellOptionSet { + /// Returns an iterator over the options defined in this set. + pub fn iter(&self) -> impl Iterator { + self.inner + .iter() + .map(|(&name, definition)| ShellOption { name, definition }) + } + + /// Returns the option with the given name, if it exists. + /// + /// # Arguments + /// + /// * `name` - The name of the option to retrieve. + pub fn get(&self, name: &str) -> Option<&'static ShellOptionDef> { + self.inner.get(name) + } +} + +static SET_OPTIONS: LazyLock> = LazyLock::new(|| { + HashMap::from([ + ( + "a", + ShellOptionDef::new( + |options| options.export_variables_on_modification, + |options, value| options.export_variables_on_modification = value, + ), + ), + ( + "b", + ShellOptionDef::new( + |options| options.notify_job_termination_immediately, + |options, value| options.notify_job_termination_immediately = value, + ), + ), + ( + "e", + ShellOptionDef::new( + |options| options.exit_on_nonzero_command_exit, + |options, value| options.exit_on_nonzero_command_exit = value, + ), + ), + ( + "f", + ShellOptionDef::new( + |options| options.disable_filename_globbing, + |options, value| options.disable_filename_globbing = value, + ), + ), + ( + "h", + ShellOptionDef::new( + |options| options.remember_command_locations, + |options, value| options.remember_command_locations = value, + ), + ), + ( + "i", + ShellOptionDef::new( + |options| options.interactive, + |options, value| options.interactive = value, + ), + ), + ( + "k", + ShellOptionDef::new( + |options| options.place_all_assignment_args_in_command_env, + |options, value| options.place_all_assignment_args_in_command_env = value, + ), + ), + ( + "m", + ShellOptionDef::new( + |options| options.enable_job_control, + |options, value| options.enable_job_control = value, + ), + ), + ( + "n", + ShellOptionDef::new( + |options| options.do_not_execute_commands, + |options, value| options.do_not_execute_commands = value, + ), + ), + ( + "p", + ShellOptionDef::new( + |options| options.real_effective_uid_mismatch, + |options, value| options.real_effective_uid_mismatch = value, + ), + ), + ( + "t", + ShellOptionDef::new( + |options| options.exit_after_one_command, + |options, value| options.exit_after_one_command = value, + ), + ), + ( + "u", + ShellOptionDef::new( + |options| options.treat_unset_variables_as_error, + |options, value| options.treat_unset_variables_as_error = value, + ), + ), + ( + "v", + ShellOptionDef::new( + |options| options.print_shell_input_lines, + |options, value| options.print_shell_input_lines = value, + ), + ), + ( + "x", + ShellOptionDef::new( + |options| options.print_commands_and_arguments, + |options, value| options.print_commands_and_arguments = value, + ), + ), + ( + "B", + ShellOptionDef::new( + |options| options.perform_brace_expansion, + |options, value| options.perform_brace_expansion = value, + ), + ), + ( + "C", + ShellOptionDef::new( + |options| options.disallow_overwriting_regular_files_via_output_redirection, + |options, value| { + options.disallow_overwriting_regular_files_via_output_redirection = value; + }, + ), + ), + ( + "E", + ShellOptionDef::new( + |options| options.shell_functions_inherit_err_trap, + |options, value| options.shell_functions_inherit_err_trap = value, + ), + ), + ( + "H", + ShellOptionDef::new( + |options| options.enable_bang_style_history_substitution, + |options, value| options.enable_bang_style_history_substitution = value, + ), + ), + ( + "P", + ShellOptionDef::new( + |options| options.do_not_resolve_symlinks_when_changing_dir, + |options, value| options.do_not_resolve_symlinks_when_changing_dir = value, + ), + ), + ( + "T", + ShellOptionDef::new( + |options| options.shell_functions_inherit_debug_and_return_traps, + |options, value| options.shell_functions_inherit_debug_and_return_traps = value, + ), + ), + ( + "s", + ShellOptionDef::new( + |options| options.read_commands_from_stdin, + |options, value| options.read_commands_from_stdin = value, + ), + ), + ]) +}); + +static SET_O_OPTIONS: LazyLock> = LazyLock::new(|| { + HashMap::from([ + ( + "allexport", + ShellOptionDef::new( + |options| options.export_variables_on_modification, + |options, value| options.export_variables_on_modification = value, + ), + ), + ( + "braceexpand", + ShellOptionDef::new( + |options| options.perform_brace_expansion, + |options, value| options.perform_brace_expansion = value, + ), + ), + ( + "emacs", + ShellOptionDef::new( + |options| options.emacs_mode, + |options, value| options.emacs_mode = value, + ), + ), + ( + "errexit", + ShellOptionDef::new( + |options| options.exit_on_nonzero_command_exit, + |options, value| options.exit_on_nonzero_command_exit = value, + ), + ), + ( + "errtrace", + ShellOptionDef::new( + |options| options.shell_functions_inherit_err_trap, + |options, value| options.shell_functions_inherit_err_trap = value, + ), + ), + ( + "functrace", + ShellOptionDef::new( + |options| options.shell_functions_inherit_debug_and_return_traps, + |options, value| options.shell_functions_inherit_debug_and_return_traps = value, + ), + ), + ( + "hashall", + ShellOptionDef::new( + |options| options.remember_command_locations, + |options, value| options.remember_command_locations = value, + ), + ), + ( + "histexpand", + ShellOptionDef::new( + |options| options.enable_bang_style_history_substitution, + |options, value| options.enable_bang_style_history_substitution = value, + ), + ), + ( + "history", + ShellOptionDef::new( + |options| options.enable_command_history, + |options, value| options.enable_command_history = value, + ), + ), + ( + "ignoreeof", + ShellOptionDef::new( + |options| options.ignore_eof, + |options, value| options.ignore_eof = value, + ), + ), + ( + "interactive-comments", + ShellOptionDef::new( + |options| options.interactive_comments, + |options, value| options.interactive_comments = value, + ), + ), + ( + "keyword", + ShellOptionDef::new( + |options| options.place_all_assignment_args_in_command_env, + |options, value| options.place_all_assignment_args_in_command_env = value, + ), + ), + ( + "monitor", + ShellOptionDef::new( + |options| options.enable_job_control, + |options, value| options.enable_job_control = value, + ), + ), + ( + "noclobber", + ShellOptionDef::new( + |options| options.disallow_overwriting_regular_files_via_output_redirection, + |options, value| { + options.disallow_overwriting_regular_files_via_output_redirection = value; + }, + ), + ), + ( + "noexec", + ShellOptionDef::new( + |options| options.do_not_execute_commands, + |options, value| options.do_not_execute_commands = value, + ), + ), + ( + "noglob", + ShellOptionDef::new( + |options| options.disable_filename_globbing, + |options, value| options.disable_filename_globbing = value, + ), + ), + ("nolog", ShellOptionDef::new(|_| false, |_, _| ())), + ( + "notify", + ShellOptionDef::new( + |options| options.notify_job_termination_immediately, + |options, value| options.notify_job_termination_immediately = value, + ), + ), + ( + "nounset", + ShellOptionDef::new( + |options| options.treat_unset_variables_as_error, + |options, value| options.treat_unset_variables_as_error = value, + ), + ), + ( + "onecmd", + ShellOptionDef::new( + |options| options.exit_after_one_command, + |options, value| options.exit_after_one_command = value, + ), + ), + ( + "physical", + ShellOptionDef::new( + |options| options.do_not_resolve_symlinks_when_changing_dir, + |options, value| options.do_not_resolve_symlinks_when_changing_dir = value, + ), + ), + ( + "pipefail", + ShellOptionDef::new( + |options| options.return_first_failure_from_pipeline, + |options, value| options.return_first_failure_from_pipeline = value, + ), + ), + ( + "posix", + ShellOptionDef::new( + |options| options.posix_mode, + |options, value| options.posix_mode = value, + ), + ), + ( + "privileged", + ShellOptionDef::new( + |options| options.real_effective_uid_mismatch, + |options, value| options.real_effective_uid_mismatch = value, + ), + ), + ( + "verbose", + ShellOptionDef::new( + |options| options.print_shell_input_lines, + |options, value| options.print_shell_input_lines = value, + ), + ), + ( + "vi", + ShellOptionDef::new( + |options| options.vi_mode, + |options, value| options.vi_mode = value, + ), + ), + ( + "xtrace", + ShellOptionDef::new( + |options| options.print_commands_and_arguments, + |options, value| options.print_commands_and_arguments = value, + ), + ), + ]) +}); + +static SHOPT_OPTIONS: LazyLock> = LazyLock::new(|| { + HashMap::from([ + ( + "autocd", + ShellOptionDef::new( + |options| options.auto_cd, + |options, value| options.auto_cd = value, + ), + ), + ( + "assoc_expand_once", + ShellOptionDef::new( + |options| options.assoc_expand_once, + |options, value| options.assoc_expand_once = value, + ), + ), + ( + "cdable_vars", + ShellOptionDef::new( + |options| options.cdable_vars, + |options, value| options.cdable_vars = value, + ), + ), + ( + "cdspell", + ShellOptionDef::new( + |options| options.cd_autocorrect_spelling, + |options, value| options.cd_autocorrect_spelling = value, + ), + ), + ( + "checkhash", + ShellOptionDef::new( + |options| options.check_hashtable_before_command_exec, + |options, value| options.check_hashtable_before_command_exec = value, + ), + ), + ( + "checkjobs", + ShellOptionDef::new( + |options| options.check_jobs_before_exit, + |options, value| options.check_jobs_before_exit = value, + ), + ), + ( + "checkwinsize", + ShellOptionDef::new( + |options| options.check_window_size_after_external_commands, + |options, value| options.check_window_size_after_external_commands = value, + ), + ), + ( + "cmdhist", + ShellOptionDef::new( + |options| options.save_multiline_cmds_in_history, + |options, value| options.save_multiline_cmds_in_history = value, + ), + ), + ( + "compat31", + ShellOptionDef::new( + |options| options.compat31, + |options, value| options.compat31 = value, + ), + ), + ( + "compat32", + ShellOptionDef::new( + |options| options.compat32, + |options, value| options.compat32 = value, + ), + ), + ( + "compat40", + ShellOptionDef::new( + |options| options.compat40, + |options, value| options.compat40 = value, + ), + ), + ( + "compat41", + ShellOptionDef::new( + |options| options.compat41, + |options, value| options.compat41 = value, + ), + ), + ( + "compat42", + ShellOptionDef::new( + |options| options.compat42, + |options, value| options.compat42 = value, + ), + ), + ( + "compat43", + ShellOptionDef::new( + |options| options.compat43, + |options, value| options.compat43 = value, + ), + ), + ( + "compat44", + ShellOptionDef::new( + |options| options.compat44, + |options, value| options.compat44 = value, + ), + ), + ( + "complete_fullquote", + ShellOptionDef::new( + |options| options.quote_all_metachars_in_completion, + |options, value| options.quote_all_metachars_in_completion = value, + ), + ), + ( + "direxpand", + ShellOptionDef::new( + |options| options.expand_dir_names_on_completion, + |options, value| options.expand_dir_names_on_completion = value, + ), + ), + ( + "dirspell", + ShellOptionDef::new( + |options| options.autocorrect_dir_spelling_on_completion, + |options, value| options.autocorrect_dir_spelling_on_completion = value, + ), + ), + ( + "dotglob", + ShellOptionDef::new( + |options| options.glob_matches_dotfiles, + |options, value| options.glob_matches_dotfiles = value, + ), + ), + ( + "execfail", + ShellOptionDef::new( + |options| options.exit_on_exec_fail, + |options, value| options.exit_on_exec_fail = value, + ), + ), + ( + "expand_aliases", + ShellOptionDef::new( + |options| options.expand_aliases, + |options, value| options.expand_aliases = value, + ), + ), + ( + "extdebug", + ShellOptionDef::new( + |options| options.enable_debugger, + |options, value| options.enable_debugger = value, + ), + ), + ( + "extglob", + ShellOptionDef::new( + |options| options.extended_globbing, + |options, value| options.extended_globbing = value, + ), + ), + ( + "extquote", + ShellOptionDef::new( + |options| options.extquote, + |options, value| options.extquote = value, + ), + ), + ( + "failglob", + ShellOptionDef::new( + |options| options.fail_expansion_on_globs_without_match, + |options, value| options.fail_expansion_on_globs_without_match = value, + ), + ), + ( + "force_fignore", + ShellOptionDef::new( + |options| options.force_fignore, + |options, value| options.force_fignore = value, + ), + ), + ( + "globasciiranges", + ShellOptionDef::new( + |options| options.glob_ranges_use_c_locale, + |options, value| options.glob_ranges_use_c_locale = value, + ), + ), + ( + "globstar", + ShellOptionDef::new( + |options| options.enable_star_star_glob, + |options, value| options.enable_star_star_glob = value, + ), + ), + ( + "gnu_errfmt", + ShellOptionDef::new( + |options| options.errors_in_gnu_format, + |options, value| options.errors_in_gnu_format = value, + ), + ), + ( + "histappend", + ShellOptionDef::new( + |options| options.append_to_history_file, + |options, value| options.append_to_history_file = value, + ), + ), + ( + "histreedit", + ShellOptionDef::new( + |options| options.allow_reedit_failed_history_subst, + |options, value| options.allow_reedit_failed_history_subst = value, + ), + ), + ( + "histverify", + ShellOptionDef::new( + |options| options.allow_modifying_history_substitution, + |options, value| options.allow_modifying_history_substitution = value, + ), + ), + ( + "hostcomplete", + ShellOptionDef::new( + |options| options.enable_hostname_completion, + |options, value| options.enable_hostname_completion = value, + ), + ), + ( + "huponexit", + ShellOptionDef::new( + |options| options.send_sighup_to_all_jobs_on_exit, + |options, value| options.send_sighup_to_all_jobs_on_exit = value, + ), + ), + ( + "inherit_errexit", + ShellOptionDef::new( + |options| options.command_subst_inherits_errexit, + |options, value| options.command_subst_inherits_errexit = value, + ), + ), + ( + "interactive_comments", + ShellOptionDef::new( + |options| options.interactive_comments, + |options, value| options.interactive_comments = value, + ), + ), + ( + "lastpipe", + ShellOptionDef::new( + |options| options.run_last_pipeline_cmd_in_current_shell, + |options, value| options.run_last_pipeline_cmd_in_current_shell = value, + ), + ), + ( + "lithist", + ShellOptionDef::new( + |options| options.embed_newlines_in_multiline_cmds_in_history, + |options, value| options.embed_newlines_in_multiline_cmds_in_history = value, + ), + ), + ( + "localvar_inherit", + ShellOptionDef::new( + |options| options.local_vars_inherit_value_and_attrs, + |options, value| options.local_vars_inherit_value_and_attrs = value, + ), + ), + ( + "localvar_unset", + ShellOptionDef::new( + |options| options.localvar_unset, + |options, value| options.localvar_unset = value, + ), + ), + ( + "login_shell", + ShellOptionDef::new( + |options| options.login_shell, + |options, value| options.login_shell = value, + ), + ), + ( + "mailwarn", + ShellOptionDef::new( + |options| options.mail_warn, + |options, value| options.mail_warn = value, + ), + ), + ( + "no_empty_cmd_completion", + ShellOptionDef::new( + |options| options.no_empty_cmd_completion, + |options, value| options.no_empty_cmd_completion = value, + ), + ), + ( + "nocaseglob", + ShellOptionDef::new( + |options| options.case_insensitive_pathname_expansion, + |options, value| options.case_insensitive_pathname_expansion = value, + ), + ), + ( + "nocasematch", + ShellOptionDef::new( + |options| options.case_insensitive_conditionals, + |options, value| options.case_insensitive_conditionals = value, + ), + ), + ( + "nullglob", + ShellOptionDef::new( + |options| options.expand_non_matching_patterns_to_null, + |options, value| options.expand_non_matching_patterns_to_null = value, + ), + ), + ( + "progcomp", + ShellOptionDef::new( + |options| options.programmable_completion, + |options, value| options.programmable_completion = value, + ), + ), + ( + "progcomp_alias", + ShellOptionDef::new( + |options| options.programmable_completion_alias, + |options, value| options.programmable_completion_alias = value, + ), + ), + ( + "promptvars", + ShellOptionDef::new( + |options| options.expand_prompt_strings, + |options, value| options.expand_prompt_strings = value, + ), + ), + ( + "restricted_shell", + ShellOptionDef::new( + |options| options.restricted_shell, + |options, value| options.restricted_shell = value, + ), + ), + ( + "shift_verbose", + ShellOptionDef::new( + |options| options.shift_verbose, + |options, value| options.shift_verbose = value, + ), + ), + ( + "sourcepath", + ShellOptionDef::new( + |options| options.source_builtin_searches_path, + |options, value| options.source_builtin_searches_path = value, + ), + ), + ( + "xpg_echo", + ShellOptionDef::new( + |options| options.echo_builtin_expands_escape_sequences, + |options, value| options.echo_builtin_expands_escape_sequences = value, + ), + ), + ]) +}); diff --git a/crates/brush-core-vendored/src/openfiles.rs b/crates/brush-core-vendored/src/openfiles.rs new file mode 100644 index 000000000..77c071438 --- /dev/null +++ b/crates/brush-core-vendored/src/openfiles.rs @@ -0,0 +1,329 @@ +//! Managing files open within a shell instance. + +use std::collections::HashMap; +use std::io::IsTerminal; +use std::process::Stdio; + +use crate::ShellFd; +use crate::error; +use crate::sys; + +/// Represents a file open in a shell context. +pub enum OpenFile { + /// The original standard input this process was started with. + Stdin(std::io::Stdin), + /// The original standard output this process was started with. + Stdout(std::io::Stdout), + /// The original standard error this process was started with. + Stderr(std::io::Stderr), + /// A file open for reading or writing. + File(std::fs::File), + /// A read end of a pipe. + PipeReader(std::io::PipeReader), + /// A write end of a pipe. + PipeWriter(std::io::PipeWriter), +} + +/// Returns an open file that will discard all I/O. +pub fn null() -> Result { + let file = sys::fs::open_null_file()?; + Ok(OpenFile::File(file)) +} + +impl Clone for OpenFile { + fn clone(&self) -> Self { + self.try_clone().unwrap() + } +} + +impl std::fmt::Display for OpenFile { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Stdin(_) => write!(f, "stdin"), + Self::Stdout(_) => write!(f, "stdout"), + Self::Stderr(_) => write!(f, "stderr"), + Self::File(_) => write!(f, "file"), + Self::PipeReader(_) => write!(f, "pipe reader"), + Self::PipeWriter(_) => write!(f, "pipe writer"), + } + } +} + +impl OpenFile { + /// Tries to duplicate the open file. + pub fn try_clone(&self) -> Result { + let result = match self { + Self::Stdin(_) => Self::Stdin(std::io::stdin()), + Self::Stdout(_) => Self::Stdout(std::io::stdout()), + Self::Stderr(_) => Self::Stderr(std::io::stderr()), + Self::File(f) => Self::File(f.try_clone()?), + Self::PipeReader(f) => Self::PipeReader(f.try_clone()?), + Self::PipeWriter(f) => Self::PipeWriter(f.try_clone()?), + }; + + Ok(result) + } + + /// Converts the open file into an `OwnedFd`. + #[cfg(unix)] + pub(crate) fn into_owned_fd(self) -> Result { + use std::os::fd::AsFd as _; + + match self { + Self::Stdin(f) => Ok(f.as_fd().try_clone_to_owned()?), + Self::Stdout(f) => Ok(f.as_fd().try_clone_to_owned()?), + Self::Stderr(f) => Ok(f.as_fd().try_clone_to_owned()?), + Self::File(f) => Ok(f.into()), + Self::PipeReader(r) => Ok(std::os::fd::OwnedFd::from(r)), + Self::PipeWriter(w) => Ok(std::os::fd::OwnedFd::from(w)), + } + } + + pub(crate) fn is_dir(&self) -> bool { + match self { + Self::Stdin(_) | Self::Stdout(_) | Self::Stderr(_) => false, + Self::File(file) => file.metadata().map(|m| m.is_dir()).unwrap_or(false), + Self::PipeReader(_) | Self::PipeWriter(_) => false, + } + } + + pub(crate) fn is_term(&self) -> bool { + match self { + Self::Stdin(f) => f.is_terminal(), + Self::Stdout(f) => f.is_terminal(), + Self::Stderr(f) => f.is_terminal(), + Self::File(f) => f.is_terminal(), + Self::PipeReader(_) => false, + Self::PipeWriter(_) => false, + } + } +} + +#[cfg(unix)] +impl std::os::fd::AsFd for OpenFile { + fn as_fd(&self) -> std::os::fd::BorrowedFd<'_> { + match self { + Self::Stdin(f) => f.as_fd(), + Self::Stdout(f) => f.as_fd(), + Self::Stderr(f) => f.as_fd(), + Self::File(f) => f.as_fd(), + Self::PipeReader(r) => r.as_fd(), + Self::PipeWriter(w) => w.as_fd(), + } + } +} + +impl From for OpenFile { + fn from(file: std::fs::File) -> Self { + Self::File(file) + } +} + +impl From for OpenFile { + fn from(reader: std::io::PipeReader) -> Self { + Self::PipeReader(reader) + } +} + +impl From for OpenFile { + fn from(writer: std::io::PipeWriter) -> Self { + Self::PipeWriter(writer) + } +} + +impl From for Stdio { + fn from(open_file: OpenFile) -> Self { + match open_file { + OpenFile::Stdin(_) => Self::inherit(), + OpenFile::Stdout(_) => Self::inherit(), + OpenFile::Stderr(_) => Self::inherit(), + OpenFile::File(f) => f.into(), + OpenFile::PipeReader(f) => f.into(), + OpenFile::PipeWriter(f) => f.into(), + } + } +} + +impl std::io::Read for OpenFile { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + match self { + Self::Stdin(f) => f.read(buf), + Self::Stdout(_) => Err(std::io::Error::other( + error::ErrorKind::OpenFileNotReadable("stdout"), + )), + Self::Stderr(_) => Err(std::io::Error::other( + error::ErrorKind::OpenFileNotReadable("stderr"), + )), + Self::File(f) => f.read(buf), + Self::PipeReader(reader) => reader.read(buf), + Self::PipeWriter(_) => Err(std::io::Error::other( + error::ErrorKind::OpenFileNotReadable("pipe writer"), + )), + } + } +} + +impl std::io::Write for OpenFile { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + match self { + Self::Stdin(_) => Err(std::io::Error::other( + error::ErrorKind::OpenFileNotWritable("stdin"), + )), + Self::Stdout(f) => f.write(buf), + Self::Stderr(f) => f.write(buf), + Self::File(f) => f.write(buf), + Self::PipeReader(_) => Err(std::io::Error::other( + error::ErrorKind::OpenFileNotWritable("pipe reader"), + )), + Self::PipeWriter(writer) => writer.write(buf), + } + } + + fn flush(&mut self) -> std::io::Result<()> { + match self { + Self::Stdin(_) => Ok(()), + Self::Stdout(f) => f.flush(), + Self::Stderr(f) => f.flush(), + Self::File(f) => f.flush(), + Self::PipeReader(_) => Ok(()), + Self::PipeWriter(writer) => writer.flush(), + } + } +} + +/// Tristate representing the an `OpenFile` entry in an `OpenFiles` structure. +pub enum OpenFileEntry<'a> { + /// File descriptor is present and has a valid associated `OpenFile`. + Open(&'a OpenFile), + /// File descriptor is explicitly marked as not being mapped to any `OpenFile`. + NotPresent, + /// File descriptor is not specified in any way; it may be provided by a + /// parent context of some kind. + NotSpecified, +} + +/// Represents the open files in a shell context. +#[derive(Clone, Default)] +pub struct OpenFiles { + /// Maps shell file descriptors to open files. + files: HashMap>, +} + +impl OpenFiles { + /// File descriptor used for standard input. + pub const STDIN_FD: ShellFd = 0; + /// File descriptor used for standard output. + pub const STDOUT_FD: ShellFd = 1; + /// File descriptor used for standard error. + pub const STDERR_FD: ShellFd = 2; + + /// Creates a new `OpenFiles` instance populated with stdin, stdout, and stderr + /// from the host environment. + #[allow(unused)] + pub(crate) fn new() -> Self { + Self { + files: HashMap::from([ + (Self::STDIN_FD, Some(OpenFile::Stdin(std::io::stdin()))), + (Self::STDOUT_FD, Some(OpenFile::Stdout(std::io::stdout()))), + (Self::STDERR_FD, Some(OpenFile::Stderr(std::io::stderr()))), + ]), + } + } + + /// Updates the open files from the provided iterator of (fd number, `OpenFile`) pairs. + /// Any existing entries for the provided file descriptors will be overwritten. + /// + /// # Arguments + /// + /// * `files`: An iterator of (fd number, `OpenFile`) pairs to update the open files with. + pub fn update_from(&mut self, files: impl Iterator) { + for (fd, file) in files { + let _ = self.files.insert(fd, Some(file)); + } + } + + /// Retrieves the file backing standard input in this context. + pub fn try_stdin(&self) -> Option<&OpenFile> { + self.files.get(&Self::STDIN_FD).and_then(|f| f.as_ref()) + } + + /// Retrieves the file backing standard output in this context. + pub fn try_stdout(&self) -> Option<&OpenFile> { + self.files.get(&Self::STDOUT_FD).and_then(|f| f.as_ref()) + } + + /// Retrieves the file backing standard error in this context. + pub fn try_stderr(&self) -> Option<&OpenFile> { + self.files.get(&Self::STDERR_FD).and_then(|f| f.as_ref()) + } + + /// Tries to remove an open file by its file descriptor. If the file descriptor + /// is not used, `None` will be returned; otherwise, the removed file will + /// be returned. + /// + /// Arguments: + /// + /// * `fd`: The file descriptor to remove. + pub fn remove_fd(&mut self, fd: ShellFd) -> Option { + self.files.insert(fd, None).and_then(|f| f) + } + + /// Tries to lookup the `OpenFile` associated with a file descriptor. + /// Returns `None` if the file descriptor is not present. + /// + /// Arguments: + /// + /// * `fd`: The file descriptor to lookup. + pub fn try_fd(&self, fd: ShellFd) -> Option<&OpenFile> { + self.files.get(&fd).and_then(|f| f.as_ref()) + } + + /// Tries to lookup the `OpenFile` associated with a file descriptor. Returns + /// an `OpenFileEntry` representing the state of the file descriptor. + /// + /// Arguments: + /// + /// * `fd`: The file descriptor to lookup. + pub fn fd_entry(&self, fd: ShellFd) -> OpenFileEntry<'_> { + self.files + .get(&fd) + .map_or(OpenFileEntry::NotSpecified, |opt_file| match opt_file { + Some(f) => OpenFileEntry::Open(f), + None => OpenFileEntry::NotPresent, + }) + } + + /// Checks if the given file descriptor is in use. + pub fn contains_fd(&self, fd: ShellFd) -> bool { + self.files.contains_key(&fd) + } + + /// Associates the given file descriptor with the provided file. If the file descriptor + /// is already in use, the previous file will be returned; otherwise, `None` + /// will be returned. + /// + /// Arguments: + /// + /// * `fd`: The file descriptor to associate with the file. + /// * `file`: The file to associate with the file descriptor. + pub fn set_fd(&mut self, fd: ShellFd, file: OpenFile) -> Option { + self.files.insert(fd, Some(file)).and_then(|f| f) + } + + /// Iterates over all file descriptors. + pub fn iter_fds(&self) -> impl Iterator { + self.files + .iter() + .filter_map(|(fd, file)| file.as_ref().map(|f| (*fd, f))) + } +} + +impl From for OpenFiles +where + I: Iterator, +{ + fn from(iter: I) -> Self { + let files = iter.map(|(fd, file)| (fd, Some(file))).collect(); + Self { files } + } +} diff --git a/crates/brush-core-vendored/src/options.rs b/crates/brush-core-vendored/src/options.rs new file mode 100644 index 000000000..363e22f1d --- /dev/null +++ b/crates/brush-core-vendored/src/options.rs @@ -0,0 +1,325 @@ +//! Defines runtime options for the shell. + +use itertools::Itertools; + +use crate::{CreateOptions, namedoptions}; + +/// Runtime changeable options for a shell instance. +#[derive(Clone, Default)] +#[expect(clippy::module_name_repetitions)] +pub struct RuntimeOptions { + // + // Single-character options. + /// -a + pub export_variables_on_modification: bool, + /// -b + pub notify_job_termination_immediately: bool, + /// -e + pub exit_on_nonzero_command_exit: bool, + /// -f + pub disable_filename_globbing: bool, + /// -h + pub remember_command_locations: bool, + /// -k + pub place_all_assignment_args_in_command_env: bool, + /// -m + pub enable_job_control: bool, + /// -n + pub do_not_execute_commands: bool, + /// -p + pub real_effective_uid_mismatch: bool, + /// -t + pub exit_after_one_command: bool, + /// -u + pub treat_unset_variables_as_error: bool, + /// -v + pub print_shell_input_lines: bool, + /// -x + pub print_commands_and_arguments: bool, + /// -B + pub perform_brace_expansion: bool, + /// -C + pub disallow_overwriting_regular_files_via_output_redirection: bool, + /// -E + pub shell_functions_inherit_err_trap: bool, + /// -H + pub enable_bang_style_history_substitution: bool, + /// -P + pub do_not_resolve_symlinks_when_changing_dir: bool, + /// -T + pub shell_functions_inherit_debug_and_return_traps: bool, + + // + // Options set through -o. + /// 'emacs' + pub emacs_mode: bool, + /// 'history' + pub enable_command_history: bool, + /// 'ignoreeof' + pub ignore_eof: bool, + /// 'pipefail' + pub return_first_failure_from_pipeline: bool, + /// 'posix' + pub posix_mode: bool, + /// 'vi' + pub vi_mode: bool, + + // + // Options set through shopt. + /// `assoc_expand_once` + pub assoc_expand_once: bool, + /// 'autocd' + pub auto_cd: bool, + /// `cdable_vars` + pub cdable_vars: bool, + /// 'cdspell' + pub cd_autocorrect_spelling: bool, + /// 'checkhash' + pub check_hashtable_before_command_exec: bool, + /// 'checkjobs' + pub check_jobs_before_exit: bool, + /// 'checkwinsize' + pub check_window_size_after_external_commands: bool, + /// 'cmdhist' + pub save_multiline_cmds_in_history: bool, + /// 'compat31' + pub compat31: bool, + /// 'compat32' + pub compat32: bool, + /// 'compat40' + pub compat40: bool, + /// 'compat41' + pub compat41: bool, + /// 'compat42' + pub compat42: bool, + /// 'compat43' + pub compat43: bool, + /// 'compat44' + pub compat44: bool, + /// `complete_fullquote` + pub quote_all_metachars_in_completion: bool, + /// 'direxpand' + pub expand_dir_names_on_completion: bool, + /// 'dirspell' + pub autocorrect_dir_spelling_on_completion: bool, + /// 'dotglob' + pub glob_matches_dotfiles: bool, + /// 'execfail' + pub exit_on_exec_fail: bool, + /// `expand_aliases` + pub expand_aliases: bool, + /// 'extdebug' + pub enable_debugger: bool, + /// 'extglob' + pub extended_globbing: bool, + /// 'extquote' + pub extquote: bool, + /// 'failglob' + pub fail_expansion_on_globs_without_match: bool, + /// `force_fignore` + pub force_fignore: bool, + /// 'globasciiranges' + pub glob_ranges_use_c_locale: bool, + /// 'globstar' + pub enable_star_star_glob: bool, + /// `gnu_errfmt` + pub errors_in_gnu_format: bool, + /// 'histappend' + pub append_to_history_file: bool, + /// 'histreedit' + pub allow_reedit_failed_history_subst: bool, + /// 'histverify' + pub allow_modifying_history_substitution: bool, + /// 'hostcomplete' + pub enable_hostname_completion: bool, + /// 'huponexit' + pub send_sighup_to_all_jobs_on_exit: bool, + /// `inherit_errexit` + pub command_subst_inherits_errexit: bool, + /// `interactive_comments` + pub interactive_comments: bool, + /// 'lastpipe' + pub run_last_pipeline_cmd_in_current_shell: bool, + /// 'lithist' + pub embed_newlines_in_multiline_cmds_in_history: bool, + /// `localvar_inherit` + pub local_vars_inherit_value_and_attrs: bool, + /// `localvar_unset` + pub localvar_unset: bool, + /// `login_shell` + pub login_shell: bool, + /// 'mailwarn' + pub mail_warn: bool, + /// `no_empty_cmd_completion` + pub no_empty_cmd_completion: bool, + /// 'nocaseglob' + pub case_insensitive_pathname_expansion: bool, + /// 'nocasematch' + pub case_insensitive_conditionals: bool, + /// 'nullglob' + pub expand_non_matching_patterns_to_null: bool, + /// 'progcomp' + pub programmable_completion: bool, + /// `progcomp_alias` + pub programmable_completion_alias: bool, + /// 'promptvars' + pub expand_prompt_strings: bool, + /// `restricted_shell` + pub restricted_shell: bool, + /// `shift_verbose` + pub shift_verbose: bool, + /// `sourcepath` + pub source_builtin_searches_path: bool, + /// `xpg_echo` + pub echo_builtin_expands_escape_sequences: bool, + + // + // Options set by the shell. + /// Whether or not the shell is interactive. + pub interactive: bool, + /// Whether or not the shell is reading commands from standard input. + pub read_commands_from_stdin: bool, + /// Whether or not the shell is in maximal `sh` compatibility mode. + pub sh_mode: bool, + /// Maximum function call depth. + pub max_function_call_depth: Option, +} + +impl RuntimeOptions { + /// Creates a default set of runtime options based on the given creation options. + /// + /// # Arguments + /// + /// * `create_options` - The options used to create the shell. + pub fn defaults_from(create_options: &CreateOptions) -> Self { + // There's a set of options enabled by default for all shells. + let mut options = Self { + interactive: create_options.interactive, + disallow_overwriting_regular_files_via_output_redirection: create_options + .disallow_overwriting_regular_files_via_output_redirection, + do_not_execute_commands: create_options.do_not_execute_commands, + enable_command_history: create_options.interactive, + enable_job_control: create_options.interactive, + exit_after_one_command: create_options.exit_after_one_command, + read_commands_from_stdin: create_options.read_commands_from_stdin, + sh_mode: create_options.sh_mode, + posix_mode: create_options.posix, + print_commands_and_arguments: create_options.print_commands_and_arguments, + print_shell_input_lines: create_options.verbose, + remember_command_locations: true, + check_window_size_after_external_commands: true, + save_multiline_cmds_in_history: true, + extquote: true, + force_fignore: true, + enable_hostname_completion: true, + interactive_comments: true, + expand_prompt_strings: true, + source_builtin_searches_path: true, + perform_brace_expansion: true, + quote_all_metachars_in_completion: true, + programmable_completion: true, + glob_ranges_use_c_locale: true, + max_function_call_depth: create_options.max_function_call_depth, + ..Self::default() + }; + + // Additional options are enabled by default for interactive shells. + if create_options.interactive { + options.enable_bang_style_history_substitution = true; + options.emacs_mode = !create_options.no_editing; + options.expand_aliases = true; + } + + // Update any options. + for enabled_option in &create_options.enabled_options { + if let Some(option) = namedoptions::options(namedoptions::ShellOptionKind::SetO) + .get(enabled_option.as_str()) + { + option.set(&mut options, true); + } + } + for disabled_option in &create_options.disabled_options { + if let Some(option) = namedoptions::options(namedoptions::ShellOptionKind::SetO) + .get(disabled_option.as_str()) + { + option.set(&mut options, false); + } + } + + // Update any shopt options. + for enabled_option in &create_options.enabled_shopt_options { + if let Some(shopt_option) = namedoptions::options(namedoptions::ShellOptionKind::Shopt) + .get(enabled_option.as_str()) + { + shopt_option.set(&mut options, true); + } + } + for disabled_option in &create_options.disabled_shopt_options { + if let Some(shopt_option) = namedoptions::options(namedoptions::ShellOptionKind::Shopt) + .get(disabled_option.as_str()) + { + shopt_option.set(&mut options, false); + } + } + + options + } + + /// Returns a string representing the current `set`-style option flags set in the shell. + pub fn option_flags(&self) -> String { + let mut cs = vec![]; + + for o in namedoptions::options(namedoptions::ShellOptionKind::Set).iter() { + if o.definition.get(self) { + cs.push(o.name.chars().next().unwrap()); + } + } + + // Sort the flags in a way that matches what bash does. + cs.sort_by(|a, b| { + if a == b { + std::cmp::Ordering::Equal + } else if *a == 's' { + std::cmp::Ordering::Greater + } else if *b == 's' { + std::cmp::Ordering::Less + } else if a.is_ascii_lowercase() && b.is_ascii_uppercase() { + std::cmp::Ordering::Less + } else if a.is_ascii_uppercase() && b.is_ascii_lowercase() { + std::cmp::Ordering::Greater + } else { + a.cmp(b) + } + }); + + cs.into_iter().collect() + } + + /// Returns a colon-separated list of sorted 'set -o' options enabled. + pub fn seto_optstr(&self) -> String { + let mut cs = vec![]; + + for option in namedoptions::options(namedoptions::ShellOptionKind::SetO).iter() { + if option.definition.get(self) { + cs.push(option.name); + } + } + + cs.sort_unstable(); + cs.into_iter().join(":") + } + + /// Returns a colon-separated list of sorted 'shopt' options enabled. + pub fn shopt_optstr(&self) -> String { + let mut cs = vec![]; + + for option in namedoptions::options(namedoptions::ShellOptionKind::Shopt).iter() { + if option.definition.get(self) { + cs.push(option.name); + } + } + + cs.sort_unstable(); + cs.into_iter().join(":") + } +} diff --git a/crates/brush-core-vendored/src/pathcache.rs b/crates/brush-core-vendored/src/pathcache.rs new file mode 100644 index 000000000..9a0197fcc --- /dev/null +++ b/crates/brush-core-vendored/src/pathcache.rs @@ -0,0 +1,58 @@ +//! Path cache + +use crate::{error, variables}; +use std::path::PathBuf; + +/// A cache of paths associated with names. +#[derive(Clone, Default)] +pub struct PathCache { + /// The cache itself. + cache: std::collections::HashMap, +} + +impl PathCache { + /// Clears all elements from the cache. + pub fn reset(&mut self) { + self.cache.clear(); + } + + /// Returns the path associated with the given name. + /// + /// # Arguments + /// + /// * `name` - The name to lookup. + pub fn get>(&self, name: S) -> Option { + self.cache.get(name.as_ref()).cloned() + } + + /// Sets the path associated with the given name. + /// + /// # Arguments + /// + /// * `name` - The name to set. + /// * `path` - The path to associate with the name. + pub fn set>(&mut self, name: S, path: PathBuf) { + self.cache.insert(name.as_ref().to_string(), path); + } + + /// Projects the cache into a shell value. + pub fn to_value(&self) -> Result { + let pairs = self + .cache + .iter() + .map(|(k, v)| (Some(k.to_owned()), v.to_string_lossy().to_string())) + .collect::>(); + + variables::ShellValue::associative_array_from_literals(variables::ArrayLiteral(pairs)) + } + + /// Removes the path associated with the given name, if there is one. + /// Returns whether or not an entry was removed. + /// + /// # Arguments + /// + /// * `name` - The name to remove. + pub fn unset>(&mut self, name: S) -> bool { + self.cache.remove(name.as_ref()).is_some() + } +} diff --git a/crates/brush-core-vendored/src/pathsearch.rs b/crates/brush-core-vendored/src/pathsearch.rs new file mode 100644 index 000000000..4e3fdefc5 --- /dev/null +++ b/crates/brush-core-vendored/src/pathsearch.rs @@ -0,0 +1,131 @@ +//! Path searching utilities. + +use std::{collections::VecDeque, path::PathBuf}; + +use crate::sys::fs::PathExt; + +/// Encapsulates the result of a path search. +pub struct ExecutablePathSearch +where + PI: AsRef, + N: AsRef, +{ + paths: VecDeque, + filename: N, +} + +impl Iterator for ExecutablePathSearch +where + PI: AsRef, + N: AsRef, +{ + type Item = PathBuf; + + fn next(&mut self) -> Option { + while let Some(path) = self.paths.pop_front() { + let path = PathBuf::from(path.as_ref()).join(self.filename.as_ref()); + if path.is_file() && path.as_path().executable() { + return Some(path); + } + } + + None + } +} + +pub(crate) struct ExecutablePathPrefixSearch +where + PI: AsRef, +{ + paths: VecDeque, + queued_items: VecDeque, + filename_prefix: String, + case_insensitive: bool, +} + +impl Iterator for ExecutablePathPrefixSearch +where + PI: AsRef, +{ + type Item = PathBuf; + + fn next(&mut self) -> Option { + // If we already found some items and queued them, then yield one now. + if let Some(item) = self.queued_items.pop_front() { + return Some(item); + } + + while let Some(path) = self.paths.pop_front() { + let path = PathBuf::from(path.as_ref()); + + if let Ok(readdir) = path.read_dir() { + for entry in readdir.flatten() { + if let Ok(mut filename) = entry.file_name().into_string() { + if self.case_insensitive { + filename = filename.to_ascii_lowercase(); + } + + if !filename.starts_with(&self.filename_prefix) { + continue; + } + } + + let entry_path = entry.path(); + + if let Ok(file_type) = entry.file_type() { + if file_type.is_file() && entry_path.executable() { + self.queued_items.push_back(entry_path); + } + } + } + } + + if let Some(item) = self.queued_items.pop_front() { + return Some(item); + } + } + + None + } +} + +/// Search for the given executable name in the provided paths. +/// +/// # Arguments +/// +/// * `paths` - An iterator over the paths to search. +/// * `filename` - The name of the executable file to search for. +pub fn search_for_executable(paths: P, filename: N) -> ExecutablePathSearch +where + P: Iterator, + PI: AsRef, + N: AsRef, +{ + ExecutablePathSearch { + paths: paths.collect(), + filename, + } +} + +pub(crate) fn search_for_executable_with_prefix( + paths: P, + filename_prefix: &str, + case_insensitive: bool, +) -> ExecutablePathPrefixSearch +where + P: Iterator, + PI: AsRef, +{ + let stored_prefix = if case_insensitive { + filename_prefix.to_ascii_lowercase() + } else { + filename_prefix.into() + }; + + ExecutablePathPrefixSearch { + paths: paths.collect(), + queued_items: VecDeque::new(), + filename_prefix: stored_prefix, + case_insensitive, + } +} diff --git a/crates/brush-core-vendored/src/patterns.rs b/crates/brush-core-vendored/src/patterns.rs new file mode 100644 index 000000000..aa35c1f4d --- /dev/null +++ b/crates/brush-core-vendored/src/patterns.rs @@ -0,0 +1,875 @@ +//! Shell patterns + +use crate::{error, regex, trace_categories}; +use std::{ + collections::VecDeque, + path::{Path, PathBuf}, +}; + +/// Represents a piece of a shell pattern. +#[derive(Clone, Debug)] +pub(crate) enum PatternPiece { + /// A pattern that should be interpreted as a shell pattern. + Pattern(String), + /// A literal string that should be matched exactly. + Literal(String), +} + +impl PatternPiece { + pub fn as_str(&self) -> &str { + match self { + Self::Pattern(s) => s, + Self::Literal(s) => s, + } + } +} + +type PatternWord = Vec; + +/// Options for filename expansion. +#[derive(Clone, Debug, Default)] +pub(crate) struct FilenameExpansionOptions { + pub require_dot_in_pattern_to_match_dot_files: bool, +} + +/// Encapsulates a shell pattern. +#[derive(Clone, Debug)] +pub struct Pattern { + pieces: PatternWord, + enable_extended_globbing: bool, + multiline: bool, + case_insensitive: bool, +} + +impl Default for Pattern { + fn default() -> Self { + Self { + pieces: vec![], + enable_extended_globbing: false, + multiline: true, + case_insensitive: false, + } + } +} + +impl From for Pattern { + fn from(pieces: PatternWord) -> Self { + Self { + pieces, + ..Default::default() + } + } +} + +impl From<&PatternWord> for Pattern { + fn from(value: &PatternWord) -> Self { + Self { + pieces: value.clone(), + ..Default::default() + } + } +} + +impl From<&str> for Pattern { + fn from(value: &str) -> Self { + Self { + pieces: vec![PatternPiece::Pattern(value.to_owned())], + ..Default::default() + } + } +} + +impl From for Pattern { + fn from(value: String) -> Self { + Self { + pieces: vec![PatternPiece::Pattern(value)], + ..Default::default() + } + } +} + +impl Pattern { + /// Enables (or disables) extended globbing support for this pattern. + /// + /// # Arguments + /// + /// * `value` - Whether or not to enable extended globbing (extglob). + #[must_use] + pub const fn set_extended_globbing(mut self, value: bool) -> Self { + self.enable_extended_globbing = value; + self + } + + /// Enables (or disables) multiline support for this pattern. + /// + /// # Arguments + /// + /// * `value` - Whether or not to enable multiline matching. + #[must_use] + pub const fn set_multiline(mut self, value: bool) -> Self { + self.multiline = value; + self + } + + /// Enables (or disables) case-insensitive matching for this pattern. + /// + /// # Arguments + /// + /// * `value` - Whether or not to enable case-insensitive matching. + #[must_use] + pub const fn set_case_insensitive(mut self, value: bool) -> Self { + self.case_insensitive = value; + self + } + + /// Returns whether or not the pattern is empty. + pub fn is_empty(&self) -> bool { + self.pieces.iter().all(|p| p.as_str().is_empty()) + } + + /// Placeholder function that always returns true. + pub(crate) const fn accept_all_expand_filter(_path: &Path) -> bool { + true + } + + /// Expands the pattern into a list of matching file paths. + /// + /// # Arguments + /// + /// * `working_dir` - The current working directory, used for relative paths. + /// * `path_filter` - Optionally provides a function that filters paths after expansion. + #[expect(clippy::too_many_lines)] + #[allow(clippy::unwrap_in_result)] + pub(crate) fn expand( + &self, + working_dir: &Path, + path_filter: Option<&PF>, + options: &FilenameExpansionOptions, + ) -> Result, error::Error> + where + PF: Fn(&Path) -> bool, + { + // If the pattern is completely empty, then short-circuit the function; there's + // no reason to proceed onward when we know there's no expansions. + if self.is_empty() { + return Ok(vec![]); + + // Similarly, if we're *confident* the pattern doesn't require expansion, then we + // know there's a single expansion (before filtering). + } else if !self.pieces.iter().any(|piece| { + matches!(piece, PatternPiece::Pattern(_)) && requires_expansion(piece.as_str()) + }) { + let concatenated: String = self.pieces.iter().map(|piece| piece.as_str()).collect(); + + if let Some(filter) = path_filter { + if !filter(Path::new(&concatenated)) { + return Ok(vec![]); + } + } + + return Ok(vec![concatenated]); + } + + tracing::debug!(target: trace_categories::PATTERN, "expanding pattern: {self:?}"); + + let mut components: Vec = vec![]; + for piece in &self.pieces { + let mut split_result = piece + .as_str() + .split(std::path::MAIN_SEPARATOR) + .map(|s| match piece { + PatternPiece::Pattern(_) => PatternPiece::Pattern(s.to_owned()), + PatternPiece::Literal(_) => PatternPiece::Literal(s.to_owned()), + }) + .collect::>(); + + if let Some(first_piece) = split_result.pop_front() { + if let Some(last_component) = components.last_mut() { + last_component.push(first_piece); + } else { + components.push(vec![first_piece]); + } + } + + while let Some(piece) = split_result.pop_front() { + components.push(vec![piece]); + } + } + + // Check if the path appears to be absolute. + let is_absolute = if let Some(first_component) = components.first() { + first_component + .iter() + .all(|piece| piece.as_str().is_empty()) + } else { + false + }; + + let prefix_to_remove; + let mut paths_so_far = if is_absolute { + prefix_to_remove = None; + // TODO: Figure out appropriate thing to do on non-Unix platforms. + vec![PathBuf::from(std::path::MAIN_SEPARATOR_STR)] + } else { + let mut working_dir_str = working_dir.to_string_lossy().to_string(); + + if !working_dir_str.ends_with(std::path::MAIN_SEPARATOR) { + working_dir_str.push(std::path::MAIN_SEPARATOR); + } + + prefix_to_remove = Some(working_dir_str); + vec![working_dir.to_path_buf()] + }; + + for component in components { + if !component.iter().any(|piece| { + matches!(piece, PatternPiece::Pattern(_)) && requires_expansion(piece.as_str()) + }) { + for p in &mut paths_so_far { + let flattened = component + .iter() + .map(|piece| piece.as_str()) + .collect::(); + p.push(flattened); + } + continue; + } + + let current_paths = std::mem::take(&mut paths_so_far); + for current_path in current_paths { + let subpattern = Self::from(&component) + .set_extended_globbing(self.enable_extended_globbing) + .set_case_insensitive(self.case_insensitive); + + let subpattern_starts_with_dot = subpattern + .pieces + .first() + .is_some_and(|piece| piece.as_str().starts_with('.')); + + let allow_dot_files = !options.require_dot_in_pattern_to_match_dot_files + || subpattern_starts_with_dot; + + let matches_dotfile_policy = |dir_entry: &std::fs::DirEntry| { + !dir_entry.file_name().to_string_lossy().starts_with('.') || allow_dot_files + }; + + let regex = subpattern.to_regex(true, true)?; + let matches_regex = |dir_entry: &std::fs::DirEntry| { + regex + .is_match(dir_entry.file_name().to_string_lossy().as_ref()) + .unwrap_or(false) + }; + + let mut matching_paths_in_dir: Vec<_> = current_path + .read_dir() + .map_or_else(|_| vec![], |dir| dir.into_iter().collect()) + .into_iter() + .filter_map(|result| result.ok()) + .filter(matches_regex) + .filter(matches_dotfile_policy) + .map(|entry| entry.path()) + .collect(); + + matching_paths_in_dir.sort(); + + paths_so_far.append(&mut matching_paths_in_dir); + } + } + + let results: Vec<_> = paths_so_far + .into_iter() + .filter_map(|path| { + if let Some(filter) = path_filter { + if !filter(path.as_path()) { + return None; + } + } + + let path_str = path.to_string_lossy(); + let mut path_ref = path_str.as_ref(); + + if let Some(prefix_to_remove) = &prefix_to_remove { + path_ref = path_ref.strip_prefix(prefix_to_remove).unwrap(); + } + + Some(path_ref.to_string()) + }) + .collect(); + + tracing::debug!(target: trace_categories::PATTERN, " => results: {results:?}"); + + Ok(results) + } + + /// Converts the pattern to a regular expression string. + /// + /// # Arguments + /// + /// * `strict_prefix_match` - Whether or not the pattern should strictly match the beginning of + /// the string. + /// * `strict_suffix_match` - Whether or not the pattern should strictly match the end of the + /// string. + pub(crate) fn to_regex_str( + &self, + strict_prefix_match: bool, + strict_suffix_match: bool, + ) -> Result { + let mut regex_str = String::new(); + + if strict_prefix_match { + regex_str.push('^'); + } + + let mut current_pattern = String::new(); + for piece in &self.pieces { + match piece { + PatternPiece::Pattern(s) => { + current_pattern.push_str(s); + } + PatternPiece::Literal(s) => { + for c in s.chars() { + current_pattern.push('\\'); + current_pattern.push(c); + } + } + } + } + + let regex_piece = + pattern_to_regex_str(current_pattern.as_str(), self.enable_extended_globbing)?; + regex_str.push_str(regex_piece.as_str()); + + if strict_suffix_match { + regex_str.push('$'); + } + + Ok(regex_str) + } + + /// Converts the pattern to a regular expression. + /// + /// # Arguments + /// + /// * `strict_prefix_match` - Whether or not the pattern should strictly match the beginning of + /// the string. + /// * `strict_suffix_match` - Whether or not the pattern should strictly match the end of the + /// string. + pub(crate) fn to_regex( + &self, + strict_prefix_match: bool, + strict_suffix_match: bool, + ) -> Result { + let regex_str = self.to_regex_str(strict_prefix_match, strict_suffix_match)?; + + tracing::debug!(target: trace_categories::PATTERN, "pattern: '{self:?}' => regex: '{regex_str}'"); + + let re = regex::compile_regex(regex_str, self.case_insensitive, self.multiline)?; + Ok(re) + } + + /// Checks if the pattern exactly matches the given string. An error result + /// is returned if the pattern is found to be invalid or malformed + /// during processing. + /// + /// # Arguments + /// + /// * `value` - The string to check for a match. + pub fn exactly_matches(&self, value: &str) -> Result { + let re = self.to_regex(true, true)?; + Ok(re.is_match(value)?) + } +} + +fn requires_expansion(s: &str) -> bool { + // TODO: Make this more accurate. + s.contains(['*', '?', '[', ']', '(', ')']) +} + +fn pattern_to_regex_str( + pattern: &str, + enable_extended_globbing: bool, +) -> Result { + Ok(brush_parser::pattern::pattern_to_regex_str( + pattern, + enable_extended_globbing, + )?) +} + +/// Removes the largest matching prefix from a string that matches the given pattern. +/// +/// # Arguments +/// +/// * `s` - The string to remove the prefix from. +/// * `pattern` - The pattern to match. +#[expect(clippy::ref_option)] +pub(crate) fn remove_largest_matching_prefix<'a>( + s: &'a str, + pattern: &Option, +) -> Result<&'a str, error::Error> { + if let Some(pattern) = pattern { + let indices = s.char_indices().rev(); + let mut last_idx = s.len(); + + #[allow( + clippy::string_slice, + reason = "because we get the indices from char_indices()" + )] + for (idx, _) in indices { + let prefix = &s[0..last_idx]; + if pattern.exactly_matches(prefix)? { + return Ok(&s[last_idx..]); + } + + last_idx = idx; + } + } + Ok(s) +} + +/// Removes the smallest matching prefix from a string that matches the given pattern. +/// +/// # Arguments +/// +/// * `s` - The string to remove the prefix from. +/// * `pattern` - The pattern to match. +#[expect(clippy::ref_option)] +pub(crate) fn remove_smallest_matching_prefix<'a>( + s: &'a str, + pattern: &Option, +) -> Result<&'a str, error::Error> { + if let Some(pattern) = pattern { + let mut indices = s.char_indices(); + + #[allow( + clippy::string_slice, + reason = "because we get the indices from char_indices()" + )] + while indices.next().is_some() { + let next_index = indices.offset(); + let prefix = &s[0..next_index]; + if pattern.exactly_matches(prefix)? { + return Ok(&s[next_index..]); + } + } + } + Ok(s) +} + +/// Removes the largest matching suffix from a string that matches the given pattern. +/// +/// # Arguments +/// +/// * `s` - The string to remove the suffix from. +/// * `pattern` - The pattern to match. +#[expect(clippy::ref_option)] +pub(crate) fn remove_largest_matching_suffix<'a>( + s: &'a str, + pattern: &Option, +) -> Result<&'a str, error::Error> { + if let Some(pattern) = pattern { + #[allow( + clippy::string_slice, + reason = "because we get the indices from char_indices()" + )] + for (idx, _) in s.char_indices() { + let suffix = &s[idx..]; + if pattern.exactly_matches(suffix)? { + return Ok(&s[..idx]); + } + } + } + Ok(s) +} + +/// Removes the smallest matching suffix from a string that matches the given pattern. +/// +/// # Arguments +/// +/// * `s` - The string to remove the suffix from. +/// * `pattern` - The pattern to match. +#[expect(clippy::ref_option)] +pub(crate) fn remove_smallest_matching_suffix<'a>( + s: &'a str, + pattern: &Option, +) -> Result<&'a str, error::Error> { + if let Some(pattern) = pattern { + #[allow( + clippy::string_slice, + reason = "because we get the indices from char_indices()" + )] + for (idx, _) in s.char_indices().rev() { + let suffix = &s[idx..]; + if pattern.exactly_matches(suffix)? { + return Ok(&s[..idx]); + } + } + } + Ok(s) +} + +#[cfg(test)] +#[expect(clippy::panic_in_result_fn)] +mod tests { + use super::*; + use anyhow::Result; + + fn pattern_to_exact_regex_str

(pattern: P) -> Result + where + P: Into, + { + let pattern: Pattern = pattern + .into() + .set_extended_globbing(true) + .set_multiline(false); + + pattern.to_regex_str(true, true) + } + + #[test] + fn test_pattern_translation() -> Result<()> { + assert_eq!(pattern_to_exact_regex_str("a")?.as_str(), "^a$"); + assert_eq!(pattern_to_exact_regex_str("a*")?.as_str(), "^a.*$"); + assert_eq!(pattern_to_exact_regex_str("a?")?.as_str(), "^a.$"); + assert_eq!(pattern_to_exact_regex_str("a@(b|c)")?.as_str(), "^a(b|c)$"); + assert_eq!(pattern_to_exact_regex_str("a?(b|c)")?.as_str(), "^a(b|c)?$"); + assert_eq!( + pattern_to_exact_regex_str("a*(ab|ac)")?.as_str(), + "^a(ab|ac)*$" + ); + assert_eq!( + pattern_to_exact_regex_str("a+(ab|ac)")?.as_str(), + "^a(ab|ac)+$" + ); + assert_eq!(pattern_to_exact_regex_str("[ab]")?.as_str(), "^[ab]$"); + assert_eq!(pattern_to_exact_regex_str("[ab]*")?.as_str(), "^[ab].*$"); + assert_eq!( + pattern_to_exact_regex_str("[<{().[]*")?.as_str(), + r"^[<{().\[].*$" + ); + assert_eq!(pattern_to_exact_regex_str("[a-d]")?.as_str(), "^[a-d]$"); + assert_eq!(pattern_to_exact_regex_str(r"\*")?.as_str(), r"^\*$"); + + Ok(()) + } + + #[test] + fn test_pattern_word_translation() -> Result<()> { + assert_eq!( + pattern_to_exact_regex_str(vec![PatternPiece::Pattern("a*".to_owned())])?.as_str(), + "^a.*$" + ); + assert_eq!( + pattern_to_exact_regex_str(vec![ + PatternPiece::Pattern("a*".to_owned()), + PatternPiece::Literal("b".to_owned()), + ])? + .as_str(), + "^a.*b$" + ); + assert_eq!( + pattern_to_exact_regex_str(vec![ + PatternPiece::Literal("a*".to_owned()), + PatternPiece::Pattern("b".to_owned()), + ])? + .as_str(), + r"^a\*b$" + ); + + Ok(()) + } + + #[test] + fn test_remove_largest_matching_prefix() -> Result<()> { + assert_eq!( + remove_largest_matching_prefix("ooof", &Some(Pattern::from("")))?, + "ooof" + ); + assert_eq!( + remove_largest_matching_prefix("ooof", &Some(Pattern::from("x")))?, + "ooof" + ); + assert_eq!( + remove_largest_matching_prefix("ooof", &Some(Pattern::from("o")))?, + "oof" + ); + assert_eq!( + remove_largest_matching_prefix("ooof", &Some(Pattern::from("o*o")))?, + "f" + ); + assert_eq!( + remove_largest_matching_prefix("ooof", &Some(Pattern::from("o*")))?, + "" + ); + assert_eq!( + remove_largest_matching_prefix("🚀🚀🚀rocket", &Some(Pattern::from("🚀")))?, + "🚀🚀rocket" + ); + Ok(()) + } + + #[test] + fn test_remove_smallest_matching_prefix() -> Result<()> { + assert_eq!( + remove_smallest_matching_prefix("ooof", &Some(Pattern::from("")))?, + "ooof" + ); + assert_eq!( + remove_smallest_matching_prefix("ooof", &Some(Pattern::from("x")))?, + "ooof" + ); + assert_eq!( + remove_smallest_matching_prefix("ooof", &Some(Pattern::from("o")))?, + "oof" + ); + assert_eq!( + remove_smallest_matching_prefix("ooof", &Some(Pattern::from("o*o")))?, + "of" + ); + assert_eq!( + remove_smallest_matching_prefix("ooof", &Some(Pattern::from("o*")))?, + "oof" + ); + assert_eq!( + remove_smallest_matching_prefix("ooof", &Some(Pattern::from("ooof")))?, + "" + ); + assert_eq!( + remove_smallest_matching_prefix("🚀🚀🚀rocket", &Some(Pattern::from("🚀")))?, + "🚀🚀rocket" + ); + Ok(()) + } + + #[test] + fn test_remove_largest_matching_suffix() -> Result<()> { + assert_eq!( + remove_largest_matching_suffix("foo", &Some(Pattern::from("")))?, + "foo" + ); + assert_eq!( + remove_largest_matching_suffix("foo", &Some(Pattern::from("x")))?, + "foo" + ); + assert_eq!( + remove_largest_matching_suffix("foo", &Some(Pattern::from("o")))?, + "fo" + ); + assert_eq!( + remove_largest_matching_suffix("foo", &Some(Pattern::from("o*")))?, + "f" + ); + assert_eq!( + remove_largest_matching_suffix("foo", &Some(Pattern::from("foo")))?, + "" + ); + assert_eq!( + remove_largest_matching_suffix("rocket🚀🚀🚀", &Some(Pattern::from("🚀")))?, + "rocket🚀🚀" + ); + Ok(()) + } + + #[test] + fn test_remove_smallest_matching_suffix() -> Result<()> { + assert_eq!( + remove_smallest_matching_suffix("fooo", &Some(Pattern::from("")))?, + "fooo" + ); + assert_eq!( + remove_smallest_matching_suffix("fooo", &Some(Pattern::from("x")))?, + "fooo" + ); + assert_eq!( + remove_smallest_matching_suffix("fooo", &Some(Pattern::from("o")))?, + "foo" + ); + assert_eq!( + remove_smallest_matching_suffix("fooo", &Some(Pattern::from("o*o")))?, + "fo" + ); + assert_eq!( + remove_smallest_matching_suffix("fooo", &Some(Pattern::from("o*")))?, + "foo" + ); + assert_eq!( + remove_smallest_matching_suffix("fooo", &Some(Pattern::from("fooo")))?, + "" + ); + assert_eq!( + remove_smallest_matching_suffix("rocket🚀🚀🚀", &Some(Pattern::from("🚀")))?, + "rocket🚀🚀" + ); + Ok(()) + } + + #[test] + #[expect(clippy::cognitive_complexity)] + fn test_matching() -> Result<()> { + assert!(Pattern::from("abc").exactly_matches("abc")?); + + assert!(!Pattern::from("abc").exactly_matches("ABC")?); + assert!(!Pattern::from("abc").exactly_matches("xabcx")?); + assert!(!Pattern::from("abc").exactly_matches("")?); + assert!(!Pattern::from("abc").exactly_matches("abcd")?); + assert!(!Pattern::from("abc").exactly_matches("def")?); + + assert!(Pattern::from("*").exactly_matches("")?); + assert!(Pattern::from("*").exactly_matches("abc")?); + assert!(Pattern::from("*").exactly_matches(" ")?); + + assert!(Pattern::from("a*").exactly_matches("a")?); + assert!(Pattern::from("a*").exactly_matches("ab")?); + assert!(Pattern::from("a*").exactly_matches("a ")?); + + assert!(!Pattern::from("a*").exactly_matches("A")?); + assert!(!Pattern::from("a*").exactly_matches("")?); + assert!(!Pattern::from("a*").exactly_matches("bc")?); + assert!(!Pattern::from("a*").exactly_matches("xax")?); + assert!(!Pattern::from("a*").exactly_matches(" a")?); + + assert!(Pattern::from("*a").exactly_matches("a")?); + assert!(Pattern::from("*a").exactly_matches("ba")?); + assert!(Pattern::from("*a").exactly_matches("aa")?); + assert!(Pattern::from("*a").exactly_matches(" a")?); + + assert!(!Pattern::from("*a").exactly_matches("BA")?); + assert!(!Pattern::from("*a").exactly_matches("")?); + assert!(!Pattern::from("*a").exactly_matches("ab")?); + assert!(!Pattern::from("*a").exactly_matches("xax")?); + + Ok(()) + } + + fn make_extglob(s: &str) -> Pattern { + let pattern = Pattern::from(s).set_extended_globbing(true); + let regex_str = pattern.to_regex_str(true, true).unwrap(); + eprintln!("pattern: '{s}' => regex: '{regex_str}'"); + + pattern + } + + #[test] + fn test_extglob_or_matching() -> Result<()> { + assert!(make_extglob("@(a|b)").exactly_matches("a")?); + assert!(make_extglob("@(a|b)").exactly_matches("b")?); + + assert!(!make_extglob("@(a|b)").exactly_matches("")?); + assert!(!make_extglob("@(a|b)").exactly_matches("c")?); + assert!(!make_extglob("@(a|b)").exactly_matches("ab")?); + + assert!(!make_extglob("@(a|b)").exactly_matches("")?); + assert!(make_extglob("@(a*b|b)").exactly_matches("ab")?); + assert!(make_extglob("@(a*b|b)").exactly_matches("axb")?); + assert!(make_extglob("@(a*b|b)").exactly_matches("b")?); + + assert!(!make_extglob("@(a*b|b)").exactly_matches("a")?); + + Ok(()) + } + + #[test] + fn test_extglob_not_matching() -> Result<()> { + // Basic cases. + assert!(make_extglob("!(a)").exactly_matches("")?); + assert!(make_extglob("!(a)").exactly_matches(" ")?); + assert!(make_extglob("!(a)").exactly_matches("x")?); + assert!(make_extglob("!(a)").exactly_matches(" a ")?); + assert!(make_extglob("!(a)").exactly_matches("a ")?); + assert!(make_extglob("!(a)").exactly_matches("aa")?); + assert!(!make_extglob("!(a)").exactly_matches("a")?); + + assert!(make_extglob("a!(a)a").exactly_matches("aa")?); + assert!(make_extglob("a!(a)a").exactly_matches("aaaa")?); + assert!(make_extglob("a!(a)a").exactly_matches("aba")?); + assert!(!make_extglob("a!(a)a").exactly_matches("a")?); + assert!(!make_extglob("a!(a)a").exactly_matches("aaa")?); + assert!(!make_extglob("a!(a)a").exactly_matches("baaa")?); + + // Alternates. + assert!(make_extglob("!(a|b)").exactly_matches("c")?); + assert!(make_extglob("!(a|b)").exactly_matches("ab")?); + assert!(make_extglob("!(a|b)").exactly_matches("aa")?); + assert!(make_extglob("!(a|b)").exactly_matches("bb")?); + assert!(!make_extglob("!(a|b)").exactly_matches("a")?); + assert!(!make_extglob("!(a|b)").exactly_matches("b")?); + + Ok(()) + } + + #[test] + fn test_extglob_advanced_not_matching() -> Result<()> { + assert!(make_extglob("!(a*)").exactly_matches("b")?); + assert!(make_extglob("!(a*)").exactly_matches("")?); + assert!(!make_extglob("!(a*)").exactly_matches("a")?); + assert!(!make_extglob("!(a*)").exactly_matches("abc")?); + assert!(!make_extglob("!(a*)").exactly_matches("aabc")?); + + Ok(()) + } + + #[test] + fn test_extglob_not_degenerate_matching() -> Result<()> { + // Degenerate case. + assert!(make_extglob("!()").exactly_matches("a")?); + assert!(!make_extglob("!()").exactly_matches("")?); + + Ok(()) + } + + #[test] + fn test_extglob_zero_or_more_matching() -> Result<()> { + assert!(make_extglob("x*(a)x").exactly_matches("xx")?); + assert!(make_extglob("x*(a)x").exactly_matches("xax")?); + assert!(make_extglob("x*(a)x").exactly_matches("xaax")?); + + assert!(!make_extglob("x*(a)x").exactly_matches("x")?); + assert!(!make_extglob("x*(a)x").exactly_matches("xa")?); + assert!(!make_extglob("x*(a)x").exactly_matches("xxx")?); + + assert!(make_extglob("*(a|b)").exactly_matches("")?); + assert!(make_extglob("*(a|b)").exactly_matches("a")?); + assert!(make_extglob("*(a|b)").exactly_matches("b")?); + assert!(make_extglob("*(a|b)").exactly_matches("aba")?); + assert!(make_extglob("*(a|b)").exactly_matches("aaa")?); + + assert!(!make_extglob("*(a|b)").exactly_matches("c")?); + assert!(!make_extglob("*(a|b)").exactly_matches("ca")?); + + Ok(()) + } + + #[test] + fn test_extglob_one_or_more_matching() -> Result<()> { + fn make_extglob(s: &str) -> Pattern { + Pattern::from(s).set_extended_globbing(true) + } + + assert!(make_extglob("x+(a)x").exactly_matches("xax")?); + assert!(make_extglob("x+(a)x").exactly_matches("xaax")?); + + assert!(!make_extglob("x+(a)x").exactly_matches("xx")?); + assert!(!make_extglob("x+(a)x").exactly_matches("x")?); + assert!(!make_extglob("x+(a)x").exactly_matches("xa")?); + assert!(!make_extglob("x+(a)x").exactly_matches("xxx")?); + + assert!(make_extglob("+(a|b)").exactly_matches("a")?); + assert!(make_extglob("+(a|b)").exactly_matches("b")?); + assert!(make_extglob("+(a|b)").exactly_matches("aba")?); + assert!(make_extglob("+(a|b)").exactly_matches("aaa")?); + + assert!(!make_extglob("+(a|b)").exactly_matches("")?); + assert!(!make_extglob("+(a|b)").exactly_matches("c")?); + assert!(!make_extglob("+(a|b)").exactly_matches("ca")?); + + assert!(make_extglob("+(x+(ab)y)").exactly_matches("xaby")?); + assert!(make_extglob("+(x+(ab)y)").exactly_matches("xababy")?); + assert!(make_extglob("+(x+(ab)y)").exactly_matches("xabababy")?); + assert!(make_extglob("+(x+(ab)y)").exactly_matches("xabababyxabababyxabababy")?); + + assert!(!make_extglob("+(x+(ab)y)").exactly_matches("xy")?); + assert!(!make_extglob("+(x+(ab)y)").exactly_matches("xay")?); + assert!(!make_extglob("+(x+(ab)y)").exactly_matches("xyxy")?); + + Ok(()) + } +} diff --git a/crates/brush-core-vendored/src/processes.rs b/crates/brush-core-vendored/src/processes.rs new file mode 100644 index 000000000..2d7258632 --- /dev/null +++ b/crates/brush-core-vendored/src/processes.rs @@ -0,0 +1,78 @@ +//! Process management + +use futures::FutureExt; + +use crate::{error, sys}; + +/// A waitable future that will yield the results of a child process's execution. +pub(crate) type WaitableChildProcess = std::pin::Pin< + Box> + Send + Sync>, +>; + +/// Tracks a child process being awaited. +pub struct ChildProcess { + /// If available, the process ID of the child. + pid: Option, + /// A waitable future that will yield the results of a child process's execution. + exec_future: WaitableChildProcess, +} + +impl ChildProcess { + /// Wraps a child process and its future. + pub fn new(pid: Option, child: sys::process::Child) -> Self { + Self { + pid, + exec_future: Box::pin(child.wait_with_output()), + } + } + + /// Returns the process's ID. + pub const fn pid(&self) -> Option { + self.pid + } + + /// Waits for the process to exit. + pub async fn wait(&mut self) -> Result { + #[allow(unused_mut, reason = "only mutated on some platforms")] + let mut sigtstp = sys::signal::tstp_signal_listener()?; + #[allow(unused_mut, reason = "only mutated on some platforms")] + let mut sigchld = sys::signal::chld_signal_listener()?; + + #[allow(clippy::ignored_unit_patterns)] + loop { + tokio::select! { + output = &mut self.exec_future => { + break Ok(ProcessWaitResult::Completed(output?)) + }, + _ = sigtstp.recv() => { + break Ok(ProcessWaitResult::Stopped) + }, + _ = sigchld.recv() => { + if sys::signal::poll_for_stopped_children()? { + break Ok(ProcessWaitResult::Stopped); + } + }, + _ = sys::signal::await_ctrl_c() => { + // SIGINT got thrown. Handle it and continue looping. The child should + // have received it as well, and either handled it or ended up getting + // terminated (in which case we'll see the child exit). + }, + } + } + } + + pub(crate) fn poll(&mut self) -> Option> { + let checkable_future = &mut self.exec_future; + checkable_future + .now_or_never() + .map(|result| result.map_err(Into::into)) + } +} + +/// Represents the result of waiting for an executing process. +pub enum ProcessWaitResult { + /// The process completed. + Completed(std::process::Output), + /// The process stopped and has not yet completed. + Stopped, +} diff --git a/crates/brush-core-vendored/src/prompt.rs b/crates/brush-core-vendored/src/prompt.rs new file mode 100644 index 000000000..31d735e1e --- /dev/null +++ b/crates/brush-core-vendored/src/prompt.rs @@ -0,0 +1,250 @@ +use crate::{ + ExecutionParameters, error, expansion, + shell::Shell, + sys::{self, users}, +}; +use std::path::Path; + +const VERSION_MAJOR: &str = env!("CARGO_PKG_VERSION_MAJOR"); +const VERSION_MINOR: &str = env!("CARGO_PKG_VERSION_MINOR"); +const VERSION_PATCH: &str = env!("CARGO_PKG_VERSION_PATCH"); + +pub(crate) async fn expand_prompt( + shell: &mut Shell, + params: &ExecutionParameters, + spec: String, +) -> Result { + // Parse the prompt spec into its pieces. + let prompt_pieces = parse_prompt(spec)?; + + // Now, render each piece. + let mut formatted_prompt = String::new(); + for piece in prompt_pieces { + let needs_escaping = matches!( + piece, + brush_parser::prompt::PromptPiece::EscapedSequence(_) + | brush_parser::prompt::PromptPiece::DollarOrPound + ); + + let formatted_piece = format_prompt_piece(shell, piece)?; + + if shell.options.expand_prompt_strings && needs_escaping { + formatted_prompt.push('\\'); + } + + formatted_prompt.push_str(&formatted_piece); + } + + if shell.options.expand_prompt_strings { + // Now expand any remaining escape sequences. + formatted_prompt = expansion::basic_expand_str(shell, params, &formatted_prompt).await?; + } + + Ok(formatted_prompt) +} + +#[cached::proc_macro::cached(size = 64, result = true)] +fn parse_prompt( + spec: String, +) -> Result, brush_parser::WordParseError> { + brush_parser::prompt::parse(spec.as_str()) +} + +fn format_prompt_piece( + shell: &Shell, + piece: brush_parser::prompt::PromptPiece, +) -> Result { + let formatted = match piece { + brush_parser::prompt::PromptPiece::EscapedSequence(s) => s, + brush_parser::prompt::PromptPiece::Literal(l) => l, + brush_parser::prompt::PromptPiece::AsciiCharacter(c) => { + char::from_u32(c).map_or_else(String::new, |c| c.to_string()) + } + brush_parser::prompt::PromptPiece::Backslash => "\\".to_owned(), + brush_parser::prompt::PromptPiece::BellCharacter => "\x07".to_owned(), + brush_parser::prompt::PromptPiece::CarriageReturn => "\r".to_owned(), + brush_parser::prompt::PromptPiece::CurrentCommandNumber => { + return error::unimp("prompt: current command number"); + } + brush_parser::prompt::PromptPiece::CurrentHistoryNumber => { + return error::unimp("prompt: current history number"); + } + brush_parser::prompt::PromptPiece::CurrentUser => users::get_current_username()?, + brush_parser::prompt::PromptPiece::CurrentWorkingDirectory { + tilde_replaced, + basename, + } => format_current_working_directory(shell, tilde_replaced, basename), + brush_parser::prompt::PromptPiece::Date(format) => { + format_date(&chrono::Local::now(), &format) + } + brush_parser::prompt::PromptPiece::DollarOrPound => { + if users::is_root() { + "#".to_owned() + } else { + "$".to_owned() + } + } + brush_parser::prompt::PromptPiece::EndNonPrintingSequence => String::new(), + brush_parser::prompt::PromptPiece::EscapeCharacter => "\x1b".to_owned(), + brush_parser::prompt::PromptPiece::Hostname { + only_up_to_first_dot, + } => { + let hn = sys::network::get_hostname() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + if only_up_to_first_dot { + if let Some((first, _)) = hn.split_once('.') { + return Ok(first.to_owned()); + } + } + hn + } + brush_parser::prompt::PromptPiece::Newline => "\n".to_owned(), + brush_parser::prompt::PromptPiece::NumberOfManagedJobs => shell.jobs.jobs.len().to_string(), + brush_parser::prompt::PromptPiece::ShellBaseName => { + if let Some(shell_name) = &shell.shell_name { + Path::new(shell_name) + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_default() + } else { + String::new() + } + } + brush_parser::prompt::PromptPiece::ShellRelease => { + std::format!("{VERSION_MAJOR}.{VERSION_MINOR}.{VERSION_PATCH}") + } + brush_parser::prompt::PromptPiece::ShellVersion => { + std::format!("{VERSION_MAJOR}.{VERSION_MINOR}") + } + brush_parser::prompt::PromptPiece::StartNonPrintingSequence => String::new(), + brush_parser::prompt::PromptPiece::TerminalDeviceBaseName => { + return error::unimp("prompt: terminal device base name"); + } + brush_parser::prompt::PromptPiece::Time(time_fmt) => { + format_time(&chrono::Local::now(), &time_fmt) + } + }; + + Ok(formatted) +} + +fn format_current_working_directory(shell: &Shell, tilde_replaced: bool, basename: bool) -> String { + let mut working_dir_str = shell.working_dir().to_string_lossy().to_string(); + + if tilde_replaced { + working_dir_str = shell.tilde_shorten(working_dir_str); + } + + if basename { + if let Some(filename) = Path::new(&working_dir_str).file_name() { + working_dir_str = filename.to_string_lossy().to_string(); + } + } + + if cfg!(windows) { + working_dir_str = working_dir_str.replace('\\', "/"); + } + + working_dir_str +} + +fn format_time( + datetime: &chrono::DateTime, + format: &brush_parser::prompt::PromptTimeFormat, +) -> String +where + Tz::Offset: std::fmt::Display, +{ + let formatted = match format { + brush_parser::prompt::PromptTimeFormat::TwelveHourAM => datetime.format("%I:%M %p"), + brush_parser::prompt::PromptTimeFormat::TwelveHourHHMMSS => datetime.format("%I:%M:%S"), + brush_parser::prompt::PromptTimeFormat::TwentyFourHourHHMM => datetime.format("%H:%M"), + brush_parser::prompt::PromptTimeFormat::TwentyFourHourHHMMSS => datetime.format("%H:%M:%S"), + }; + + formatted.to_string() +} + +fn format_date( + datetime: &chrono::DateTime, + format: &brush_parser::prompt::PromptDateFormat, +) -> String +where + Tz::Offset: std::fmt::Display, +{ + match format { + brush_parser::prompt::PromptDateFormat::WeekdayMonthDate => { + datetime.format("%a %b %d").to_string() + } + brush_parser::prompt::PromptDateFormat::Custom(fmt) => { + let fmt_items = chrono::format::StrftimeItems::new(fmt); + datetime.format_with_items(fmt_items).to_string() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_time() { + // Create a well-known test date/time. + let dt = chrono::DateTime::parse_from_rfc3339("2024-12-25T13:34:56.789Z").unwrap(); + + assert_eq!( + format_time(&dt, &brush_parser::prompt::PromptTimeFormat::TwelveHourAM), + "01:34 PM" + ); + + assert_eq!( + format_time( + &dt, + &brush_parser::prompt::PromptTimeFormat::TwentyFourHourHHMMSS + ), + "13:34:56" + ); + + assert_eq!( + format_time( + &dt, + &brush_parser::prompt::PromptTimeFormat::TwelveHourHHMMSS + ), + "01:34:56" + ); + } + + #[test] + fn test_format_date() { + // Create a well-known test date/time. + let dt = chrono::DateTime::parse_from_rfc3339("2024-12-25T12:34:56.789Z").unwrap(); + + assert_eq!( + format_date( + &dt, + &brush_parser::prompt::PromptDateFormat::WeekdayMonthDate + ), + "Wed Dec 25" + ); + + assert_eq!( + format_date( + &dt, + &brush_parser::prompt::PromptDateFormat::Custom(String::from("%Y-%m-%d")) + ), + "2024-12-25" + ); + + assert_eq!( + format_date( + &dt, + &brush_parser::prompt::PromptDateFormat::Custom(String::from( + "%Y-%m-%d %H:%M:%S.%f" + )) + ), + "2024-12-25 12:34:56.789000000" + ); + } +} diff --git a/crates/brush-core-vendored/src/regex.rs b/crates/brush-core-vendored/src/regex.rs new file mode 100644 index 000000000..cc11ae3e4 --- /dev/null +++ b/crates/brush-core-vendored/src/regex.rs @@ -0,0 +1,197 @@ +#![allow(clippy::needless_pass_by_value)] + +use std::borrow::Cow; + +use crate::error; + +/// Represents a piece of a regular expression. +#[derive(Clone, Debug)] +pub(crate) enum RegexPiece { + /// A pattern that should be interpreted as a regular expression. + Pattern(String), + /// A literal string that should be matched exactly. + Literal(String), +} + +impl RegexPiece { + fn to_regex_str(&self) -> Cow<'_, str> { + match self { + Self::Pattern(s) => Cow::Borrowed(s.as_str()), + Self::Literal(s) => escape_literal_regex_piece(s.as_str()), + } + } +} + +type RegexWord = Vec; + +/// Encapsulates a regular expression usable in the shell. +#[derive(Clone, Debug)] +pub struct Regex { + pieces: RegexWord, + case_insensitive: bool, + multiline: bool, +} + +impl From for Regex { + fn from(pieces: RegexWord) -> Self { + Self { + pieces, + case_insensitive: false, + multiline: false, + } + } +} + +impl Regex { + /// Sets the regular expression's case sensitivity. + /// + /// # Arguments + /// + /// * `value` - The new case sensitivity value. + pub const fn set_case_insensitive(mut self, value: bool) -> Self { + self.case_insensitive = value; + self + } + + /// Enables (or disables) multiline support for this pattern. + /// This enables matching across lines as well as enables `.` + /// to match newline characters. + /// + /// # Arguments + /// + /// * `value` - The new multiline value. + pub const fn set_multiline(mut self, value: bool) -> Self { + self.multiline = value; + self + } + + /// Computes if the regular expression matches the given string. + /// + /// # Arguments + /// + /// * `value` - The string to check for a match. + pub fn matches(&self, value: &str) -> Result>>, error::Error> { + let regex_pattern: String = self + .pieces + .iter() + .map(|piece| piece.to_regex_str()) + .collect(); + + let re = compile_regex(regex_pattern, self.case_insensitive, self.multiline)?; + + Ok(re.captures(value)?.map(|captures| { + captures + .iter() + .map(|c| c.map(|m| m.as_str().to_owned())) + .collect() + })) + } +} + +#[cached::proc_macro::cached(size = 64, result = true)] +pub(crate) fn compile_regex( + regex_str: String, + case_insensitive: bool, + multiline: bool, +) -> Result { + // Handle identified cases where a shell-supported regex isn't supported directly by + // `fancy_regex` -- specifically, adding missing escape characters. + let mut regex_str = add_missing_escape_chars_to_regex(regex_str.as_str()); + + // Handle multiline enablement. + if multiline { + // The fancy_regex crate internally seems to have flags that can be used + // to enable multiline support, but they're not exposed via its + // RegexBuilder. We instead just prefix with the right flags. + let updated_str = std::format!("(?ms){regex_str}"); + regex_str = updated_str.into(); + } + + let mut builder = fancy_regex::RegexBuilder::new(regex_str.as_ref()); + builder.case_insensitive(case_insensitive); + + match builder.build() { + Ok(re) => Ok(re), + Err(e) => Err(error::ErrorKind::InvalidRegexError(e, regex_str.to_string()).into()), + } +} + +fn add_missing_escape_chars_to_regex(s: &str) -> Cow<'_, str> { + // We may see a character class with an unescaped '[' (open bracket) character. We need + // to escape that character. + let mut in_escape = false; + let mut in_brackets = false; + let mut insertion_positions = vec![]; + + let mut peekable = s.char_indices().peekable(); + while let Some((byte_offset, c)) = peekable.next() { + let next_is_colon = peekable.peek().is_some_and(|(_, c)| *c == ':'); + + match c { + '[' if !in_escape && !in_brackets => { + in_brackets = true; + } + '[' if !in_escape && in_brackets && !next_is_colon => { + // Need to escape. + insertion_positions.push(byte_offset); + } + ']' if !in_escape && in_brackets => { + in_brackets = false; + } + _ => (), + } + + in_escape = !in_escape && c == '\\'; + } + + if insertion_positions.is_empty() { + return s.into(); + } + + let mut updated = s.to_owned(); + for pos in insertion_positions.iter().rev() { + updated.insert(*pos, '\\'); + } + + updated.into() +} + +fn escape_literal_regex_piece(s: &str) -> Cow<'_, str> { + let mut result = String::new(); + + for c in s.chars() { + match c { + c if regex_char_is_special(c) => { + result.push('\\'); + result.push(c); + } + c => result.push(c), + } + } + + result.into() +} + +const fn regex_char_is_special(c: char) -> bool { + matches!( + c, + '\\' | '^' | '$' | '.' | '|' | '?' | '*' | '+' | '(' | ')' | '[' | ']' | '{' | '}' + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_add_missing_escape_chars_to_regex() { + // Negative cases -- where we don't need to escape. + assert_eq!(add_missing_escape_chars_to_regex("a[b]"), "a[b]"); + assert_eq!(add_missing_escape_chars_to_regex(r"a\[b\]"), r"a\[b\]"); + assert_eq!(add_missing_escape_chars_to_regex(r"a[b\[]"), r"a[b\[]"); + + // Positive case -- where we need to escape. + assert_eq!(add_missing_escape_chars_to_regex(r"a[b[]"), r"a[b\[]"); + assert_eq!(add_missing_escape_chars_to_regex(r"a[[]"), r"a[\[]"); + } +} diff --git a/crates/brush-core-vendored/src/results.rs b/crates/brush-core-vendored/src/results.rs new file mode 100644 index 000000000..92c6cff99 --- /dev/null +++ b/crates/brush-core-vendored/src/results.rs @@ -0,0 +1,260 @@ +//! Encapsulation of execution results. + +use crate::{error, processes}; + +/// Represents the result of executing a command or similar item. +#[derive(Default)] +pub struct ExecutionResult { + /// The control flow transition to apply after execution. + pub next_control_flow: ExecutionControlFlow, + /// The exit code resulting from execution. + pub exit_code: ExecutionExitCode, +} + +impl ExecutionResult { + /// Returns a new `ExecutionResult` with the given exit code. + /// + /// # Arguments + /// + /// * `exit_code` - The exit code of the command. + pub fn new(exit_code: u8) -> Self { + Self { + exit_code: exit_code.into(), + ..Self::default() + } + } + + /// Returns a new `ExecutionResult` reflecting a process that was stopped. + pub fn stopped() -> Self { + // TODO: Decide how to sort this out in a platform-independent way. + const SIGTSTP: std::os::raw::c_int = 20; + + #[expect(clippy::cast_possible_truncation)] + Self::new(128 + SIGTSTP as u8) + } + + /// Returns a new `ExecutionResult` with an exit code of 0. + pub const fn success() -> Self { + Self { + next_control_flow: ExecutionControlFlow::Normal, + exit_code: ExecutionExitCode::Success, + } + } + + /// Returns a new `ExecutionResult` with a general error exit code. + pub const fn general_error() -> Self { + Self { + next_control_flow: ExecutionControlFlow::Normal, + exit_code: ExecutionExitCode::GeneralError, + } + } + + /// Returns whether the command was successful. + pub const fn is_success(&self) -> bool { + self.exit_code.is_success() + } + + /// Returns whether the execution result indicates normal control flow. + /// Returns `false` if there is any control flow transition requested. + pub const fn is_normal_flow(&self) -> bool { + matches!(self.next_control_flow, ExecutionControlFlow::Normal) + } + + /// Returns whether the execution result indicates a loop break. + pub const fn is_break(&self) -> bool { + matches!( + self.next_control_flow, + ExecutionControlFlow::BreakLoop { .. } + ) + } + + /// Returns whether the execution result indicates a loop continue. + pub const fn is_continue(&self) -> bool { + matches!( + self.next_control_flow, + ExecutionControlFlow::ContinueLoop { .. } + ) + } + + /// Returns whether the execution result indicates an early return + /// from a function or script, or an exit from the shell. Returns `false` + /// otherwise, including loop breaks or continues. + pub const fn is_return_or_exit(&self) -> bool { + matches!( + self.next_control_flow, + ExecutionControlFlow::ReturnFromFunctionOrScript | ExecutionControlFlow::ExitShell + ) + } +} + +impl From for ExecutionResult { + fn from(exit_code: ExecutionExitCode) -> Self { + Self { + next_control_flow: ExecutionControlFlow::Normal, + exit_code, + } + } +} + +/// Represents an exit code from execution. +#[derive(Clone, Copy, Default)] +pub enum ExecutionExitCode { + /// Indicates successful execution. + #[default] + Success, + /// Indicates a general error. + GeneralError, + /// Indicates invalid usage. + InvalidUsage, + /// Cannot execute the command. + CannotExecute, + /// Indicates a command or similar item was not found. + NotFound, + /// Indicates execution was interrupted. + Interrupted, + /// Indicates unimplemented functionality was encountered. + Unimplemented, + /// A custom exit code. + Custom(u8), +} + +impl ExecutionExitCode { + /// Returns whether the exit code indicates success. + pub const fn is_success(&self) -> bool { + matches!(self, Self::Success) + } +} + +impl From for ExecutionExitCode { + fn from(code: u8) -> Self { + match code { + 0 => Self::Success, + 1 => Self::GeneralError, + 2 => Self::InvalidUsage, + 99 => Self::Unimplemented, + 126 => Self::CannotExecute, + 127 => Self::NotFound, + 130 => Self::Interrupted, + code => Self::Custom(code), + } + } +} + +impl From for u8 { + fn from(code: ExecutionExitCode) -> Self { + Self::from(&code) + } +} + +impl From<&ExecutionExitCode> for u8 { + fn from(code: &ExecutionExitCode) -> Self { + match code { + ExecutionExitCode::Success => 0, + ExecutionExitCode::GeneralError => 1, + ExecutionExitCode::InvalidUsage => 2, + ExecutionExitCode::Unimplemented => 99, + ExecutionExitCode::CannotExecute => 126, + ExecutionExitCode::NotFound => 127, + ExecutionExitCode::Interrupted => 130, + ExecutionExitCode::Custom(code) => *code, + } + } +} + +/// Represents a control flow transition to apply. +#[derive(Clone, Copy, Default)] +pub enum ExecutionControlFlow { + /// Continue normal execution. + #[default] + Normal, + /// Break out of an enclosing loop. + BreakLoop { + /// Identifies which level of nested loops to break out of. 0 indicates the innermost loop, + /// 1 indicates the next outer loop, and so on. + levels: usize, + }, + /// Continue to the next iteration of an enclosing loop. + ContinueLoop { + /// Identifies which level of nested loops to continue. 0 indicates the innermost loop, + /// 1 indicates the next outer loop, and so on. + levels: usize, + }, + /// Return from the current function or script. + ReturnFromFunctionOrScript, + /// Exit the shell. + ExitShell, +} + +impl ExecutionControlFlow { + /// Attempts to decrement the loop levels for `BreakLoop` or `ContinueLoop`. + /// If the levels reach zero, transitions to `Normal`. If the control flow is not + /// a loop break or continue, no changes are made. + #[must_use] + pub const fn try_decrement_loop_levels(&self) -> Self { + match self { + Self::BreakLoop { levels: 0 } | Self::ContinueLoop { levels: 0 } => Self::Normal, + Self::BreakLoop { levels } => Self::BreakLoop { + levels: *levels - 1, + }, + Self::ContinueLoop { levels } => Self::ContinueLoop { + levels: *levels - 1, + }, + control_flow => *control_flow, + } + } +} + +/// Represents the result of spawning an execution; captures both execution +/// that immediately returns as well as execution that starts a process +/// asynchronously. +pub enum ExecutionSpawnResult { + /// Indicates that the execution completed. + Completed(ExecutionResult), + /// Indicates that a process was started and had not yet completed. + StartedProcess(processes::ChildProcess), +} + +impl From for ExecutionSpawnResult { + fn from(result: ExecutionResult) -> Self { + Self::Completed(result) + } +} + +impl ExecutionSpawnResult { + /// Waits for the command to complete. + /// + /// # Arguments + /// + /// * `no_wait` - If true, do not wait for the command to complete; return immediately. + pub async fn wait(self, no_wait: bool) -> Result { + match self { + Self::StartedProcess(mut child) => { + let process_wait_result = if !no_wait { + // Wait for the process to exit or for a relevant signal, whichever happens + // first. + child.wait().await? + } else { + processes::ProcessWaitResult::Stopped + }; + + let wait_result = match process_wait_result { + processes::ProcessWaitResult::Completed(output) => { + ExecutionWaitResult::Completed(ExecutionResult::from(output)) + } + processes::ProcessWaitResult::Stopped => ExecutionWaitResult::Stopped(child), + }; + + Ok(wait_result) + } + Self::Completed(result) => Ok(ExecutionWaitResult::Completed(result)), + } + } +} + +/// Represents the result of waiting for an execution to complete. +pub enum ExecutionWaitResult { + /// Indicates that the execution completed. + Completed(ExecutionResult), + /// Indicates that the execution was stopped. + Stopped(processes::ChildProcess), +} diff --git a/crates/brush-core-vendored/src/scripts.rs b/crates/brush-core-vendored/src/scripts.rs new file mode 100644 index 000000000..936d85393 --- /dev/null +++ b/crates/brush-core-vendored/src/scripts.rs @@ -0,0 +1,246 @@ +//! Call stack representations. + +use std::collections::VecDeque; + +/// Represents an executing script. +#[derive(Clone, Debug)] +pub enum CallType { + /// The script was sourced. + Sourced, + /// The script was executed. + Executed, +} + +impl std::fmt::Display for CallType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Sourced => write!(f, "sourced"), + Self::Executed => write!(f, "executed"), + } + } +} + +/// Represents a single frame in a script call stack. +#[derive(Clone, Debug)] +pub struct CallFrame { + /// The type of script call that resulted in this frame. + pub call_type: CallType, + /// The source of the script (e.g., file path). + pub source: String, +} + +/// Encapsulates a script 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, "Script call stack (most recent first):")?; + + for (index, frame) in self.iter().enumerate() { + writeln!(f, " #{}| {} ({})", index, frame.source, frame.call_type)?; + } + + Ok(()) + } +} + +impl CallStack { + /// Creates a new empty script call stack. + pub fn new() -> Self { + Self::default() + } + + /// Removes the top from from the stack. If the stack is empty, does nothing and + /// returns `None`; otherwise, returns the removed call frame. + pub fn pop(&mut self) -> Option { + self.frames.pop_front() + } + + /// Pushes a new frame onto the stack. + /// + /// # Arguments + /// + /// * `call_type` - The type of script call (sourced or executed). + /// * `source` - The source of the script (e.g., file path). + pub fn push(&mut self, call_type: CallType, source: impl Into) { + self.frames.push_front(CallFrame { + call_type, + source: source.into(), + }); + } + + /// Returns whether or not the current script stack frame is a sourced script. + pub fn in_sourced_script(&self) -> bool { + self.frames + .front() + .is_some_and(|frame| matches!(frame.call_type, CallType::Sourced)) + } + + /// Returns the current depth of the script call stack. + pub fn depth(&self) -> usize { + self.frames.len() + } + + /// Returns whether or not the script call stack is empty. + pub fn is_empty(&self) -> bool { + self.frames.is_empty() + } + + /// Returns an iterator over the script call frames, starting from the most + /// recent. + pub fn iter(&self) -> impl Iterator { + self.frames.iter() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_call_type_display() { + assert_eq!(CallType::Sourced.to_string(), "sourced"); + assert_eq!(CallType::Executed.to_string(), "executed"); + } + + #[test] + fn test_call_stack_new() { + let stack = CallStack::new(); + assert!(stack.is_empty()); + assert_eq!(stack.depth(), 0); + } + + #[test] + fn test_call_stack_default() { + let stack = CallStack::default(); + assert!(stack.is_empty()); + assert_eq!(stack.depth(), 0); + } + + #[test] + fn test_call_stack_push_pop() { + let mut stack = CallStack::new(); + + stack.push(CallType::Sourced, "script1.sh"); + assert!(!stack.is_empty()); + assert_eq!(stack.depth(), 1); + + stack.push(CallType::Executed, "script2.sh"); + assert_eq!(stack.depth(), 2); + + let frame = stack.pop().unwrap(); + assert_eq!(frame.source, "script2.sh"); + assert!(matches!(frame.call_type, CallType::Executed)); + assert_eq!(stack.depth(), 1); + + let frame = stack.pop().unwrap(); + assert_eq!(frame.source, "script1.sh"); + assert!(matches!(frame.call_type, CallType::Sourced)); + assert_eq!(stack.depth(), 0); + assert!(stack.is_empty()); + } + + #[test] + fn test_call_stack_pop_empty() { + let mut stack = CallStack::new(); + assert!(stack.pop().is_none()); + } + + #[test] + fn test_in_sourced_script() { + let mut stack = CallStack::new(); + assert!(!stack.in_sourced_script()); + + stack.push(CallType::Executed, "script1.sh"); + assert!(!stack.in_sourced_script()); + + stack.push(CallType::Sourced, "script2.sh"); + assert!(stack.in_sourced_script()); + + stack.pop(); + assert!(!stack.in_sourced_script()); + } + + #[test] + fn test_call_stack_iter() { + let mut stack = CallStack::new(); + stack.push(CallType::Sourced, "script1.sh"); + stack.push(CallType::Executed, "script2.sh"); + stack.push(CallType::Sourced, "script3.sh"); + + let frames: Vec<_> = stack.iter().collect(); + assert_eq!(frames.len(), 3); + assert_eq!(frames[0].source, "script3.sh"); + assert_eq!(frames[1].source, "script2.sh"); + assert_eq!(frames[2].source, "script1.sh"); + } + + #[test] + fn test_call_stack_display_empty() { + let stack = CallStack::new(); + assert_eq!(stack.to_string(), ""); + } + + #[test] + fn test_call_stack_display_with_frames() { + let mut stack = CallStack::new(); + stack.push(CallType::Sourced, "script1.sh"); + stack.push(CallType::Executed, "script2.sh"); + + let output = stack.to_string(); + assert!(output.contains("Script call stack (most recent first):")); + assert!(output.contains("#0| script2.sh (executed)")); + assert!(output.contains("#1| script1.sh (sourced)")); + } + + #[test] + fn test_call_frame_clone() { + let frame1 = CallFrame { + call_type: CallType::Sourced, + source: "test.sh".to_string(), + }; + let frame2 = frame1.clone(); + + assert_eq!(frame1.source, frame2.source); + assert!(matches!(frame1.call_type, CallType::Sourced)); + assert!(matches!(frame2.call_type, CallType::Sourced)); + } + + #[test] + fn test_call_stack_clone() { + let mut stack1 = CallStack::new(); + stack1.push(CallType::Sourced, "script1.sh"); + stack1.push(CallType::Executed, "script2.sh"); + + let stack2 = stack1.clone(); + assert_eq!(stack1.depth(), stack2.depth()); + + let frames1: Vec<_> = stack1.iter().map(|f| &f.source).collect(); + let frames2: Vec<_> = stack2.iter().map(|f| &f.source).collect(); + assert_eq!(frames1, frames2); + } + + #[test] + fn test_push_with_string_types() { + let mut stack = CallStack::new(); + + // Test with &str + stack.push(CallType::Sourced, "script1.sh"); + + // Test with String + stack.push(CallType::Executed, String::from("script2.sh")); + + // Test with owned string reference + let owned = "script3.sh".to_string(); + stack.push(CallType::Sourced, &owned); + + assert_eq!(stack.depth(), 3); + } +} diff --git a/crates/brush-core-vendored/src/shell.rs b/crates/brush-core-vendored/src/shell.rs new file mode 100644 index 000000000..9813a8cf5 --- /dev/null +++ b/crates/brush-core-vendored/src/shell.rs @@ -0,0 +1,1693 @@ +use std::borrow::Cow; +use std::collections::HashMap; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use normalize_path::NormalizePath; +use tokio::sync::Mutex; + +use crate::arithmetic::Evaluatable; +use crate::env::{EnvironmentLookup, EnvironmentScope, ShellEnvironment}; +use crate::interp::{self, Execute, ExecutionParameters}; +use crate::options::RuntimeOptions; +use crate::results::ExecutionSpawnResult; +use crate::sys::fs::PathExt; +use crate::variables::{self, ShellVariable}; +use crate::{ + ExecutionControlFlow, ExecutionExitCode, ExecutionResult, ProcessGroupPolicy, history, + interfaces, pathcache, pathsearch, scripts, trace_categories, wellknownvars, +}; +use crate::{ + builtins, commands, completion, env, error, expansion, functions, jobs, keywords, openfiles, + prompt, sys::users, traps, +}; + +/// Type for storing a key bindings helper. +pub type KeyBindingsHelper = Arc>; + +/// Type for storing an error formatter. +pub type ErrorFormatterHelper = Arc>; + +/// Type alias for shell file descriptors. +pub type ShellFd = i32; + +/// Represents an instance of a shell. +pub struct Shell { + /// Trap handler configuration for the shell. + pub traps: traps::TrapHandlerConfig, + + /// Manages files opened and accessible via redirection operators. + open_files: openfiles::OpenFiles, + + /// The current working directory. + working_dir: PathBuf, + + /// The shell environment, containing shell variables. + pub env: ShellEnvironment, + + /// Shell function definitions. + funcs: functions::FunctionEnv, + + /// Runtime shell options. + pub options: RuntimeOptions, + + /// State of managed jobs. + pub jobs: jobs::JobManager, + + /// Shell aliases. + pub aliases: HashMap, + + /// The status of the last completed command. + last_exit_status: u8, + + /// The status of each of the commands in the last pipeline. + pub last_pipeline_statuses: Vec, + + /// Clone depth from the original ancestor shell. + depth: usize, + + /// Shell name (a.k.a. $0) + pub shell_name: Option, + + /// Shell version + version: Option, + + /// Positional parameters stack ($1 and beyond) + pub positional_parameters: Vec, + + /// Detailed display string for the shell + product_display_str: Option, + + /// Script call stack. + script_call_stack: scripts::CallStack, + + /// Function call stack. + function_call_stack: functions::CallStack, + + /// Directory stack used by pushd et al. + pub directory_stack: Vec, + + /// Current line number being processed. + current_line_number: u32, + + /// Completion configuration. + pub completion_config: completion::Config, + + /// Shell built-in commands. + builtins: HashMap, + + /// Shell program location cache. + pub program_location_cache: pathcache::PathCache, + + /// Last "SECONDS" captured time. + last_stopwatch_time: std::time::SystemTime, + + /// Last "SECONDS" offset requested. + last_stopwatch_offset: u32, + + /// Key bindings for the shell, optionally implemented by an interactive shell. + key_bindings: Option, + + /// History of commands executed in the shell. + history: Option, + + /// Error formatter for customizing error display. + error_formatter: ErrorFormatterHelper, +} + +impl Clone for Shell { + fn clone(&self) -> Self { + Self { + traps: self.traps.clone(), + open_files: self.open_files.clone(), + working_dir: self.working_dir.clone(), + env: self.env.clone(), + funcs: self.funcs.clone(), + options: self.options.clone(), + jobs: jobs::JobManager::new(), + aliases: self.aliases.clone(), + last_exit_status: self.last_exit_status, + last_pipeline_statuses: self.last_pipeline_statuses.clone(), + positional_parameters: self.positional_parameters.clone(), + shell_name: self.shell_name.clone(), + version: self.version.clone(), + product_display_str: self.product_display_str.clone(), + function_call_stack: self.function_call_stack.clone(), + script_call_stack: self.script_call_stack.clone(), + directory_stack: self.directory_stack.clone(), + current_line_number: self.current_line_number, + completion_config: self.completion_config.clone(), + builtins: self.builtins.clone(), + program_location_cache: self.program_location_cache.clone(), + last_stopwatch_time: self.last_stopwatch_time, + last_stopwatch_offset: self.last_stopwatch_offset, + key_bindings: self.key_bindings.clone(), + history: self.history.clone(), + error_formatter: self.error_formatter.clone(), + depth: self.depth + 1, + } + } +} + +impl AsRef for Shell { + fn as_ref(&self) -> &Self { + self + } +} + +impl AsMut for Shell { + fn as_mut(&mut self) -> &mut Self { + self + } +} + +pub use shell_builder::State as ShellBuilderState; + +impl ShellBuilder { + /// Returns a new shell instance created with the options provided + pub async fn build(self) -> Result { + let options = self.build_settings(); + + Shell::new(options).await + } +} + +impl ShellBuilder { + /// Add a disabled option + pub fn disable_option(mut self, option: impl Into) -> Self { + self.disabled_options.push(option.into()); + self + } + + /// Add an enabled option + pub fn enable_option(mut self, option: impl Into) -> Self { + self.enabled_options.push(option.into()); + self + } + + /// Add many disabled options + pub fn disable_options(mut self, options: impl IntoIterator>) -> Self { + self.disabled_options + .extend(options.into_iter().map(Into::into)); + self + } + + /// Add many enabled options + pub fn enable_options(mut self, options: impl IntoIterator>) -> Self { + self.enabled_options + .extend(options.into_iter().map(Into::into)); + self + } + + /// Add a disabled shopt option + pub fn disable_shopt_option(mut self, option: impl Into) -> Self { + self.disabled_shopt_options.push(option.into()); + self + } + + /// Add an enabled shopt option + pub fn enable_shopt_option(mut self, option: impl Into) -> Self { + self.enabled_shopt_options.push(option.into()); + self + } + + /// Add many disabled shopt options + pub fn disable_shopt_options(mut self, options: impl IntoIterator>) -> Self { + self.disabled_shopt_options + .extend(options.into_iter().map(Into::into)); + self + } + + /// Add many enabled shopt options + pub fn enable_shopt_options(mut self, options: impl IntoIterator>) -> Self { + self.enabled_shopt_options + .extend(options.into_iter().map(Into::into)); + self + } + + /// Add a single builtin registration + pub fn builtin(mut self, name: impl Into, reg: builtins::Registration) -> Self { + self.builtins.insert(name.into(), reg); + self + } + + /// Add many builtin registrations + pub fn builtins( + mut self, + builtins: impl IntoIterator, + ) -> Self { + self.builtins.extend(builtins); + self + } +} + +/// Options for creating a new shell. +#[derive(Default, bon::Builder)] +#[builder( + builder_type( + name = ShellBuilder, + doc { + /// Builder for [Shell] + }), + finish_fn( + name = build_settings, + vis = "pub(self)", + ), + start_fn( + vis = "pub(self)" + ) +)] +pub struct CreateOptions { + /// Disabled options. + #[builder(field)] + pub disabled_options: Vec, + /// Enabled options. + #[builder(field)] + pub enabled_options: Vec, + /// Disabled shopt options. + #[builder(field)] + pub disabled_shopt_options: Vec, + /// Enabled shopt options. + #[builder(field)] + pub enabled_shopt_options: Vec, + /// Registered builtins. + #[builder(field)] + pub builtins: HashMap, + /// Disallow overwriting regular files via output redirection. + #[builder(default)] + pub disallow_overwriting_regular_files_via_output_redirection: bool, + /// Do not execute commands. + #[builder(default)] + pub do_not_execute_commands: bool, + /// Exit after one command. + #[builder(default)] + pub exit_after_one_command: bool, + /// Whether the shell is interactive. + #[builder(default)] + pub interactive: bool, + /// Whether the shell is a login shell. + #[builder(default)] + pub login: bool, + /// Whether to skip using a readline-like interface for input. + #[builder(default)] + pub no_editing: bool, + /// Whether to skip sourcing the system profile. + #[builder(default)] + pub no_profile: bool, + /// Whether to skip sourcing the user's rc file. + #[builder(default)] + pub no_rc: bool, + /// Explicit override of rc file to load in interactive mode. + pub rc_file: Option, + /// Whether to skip inheriting environment variables from the calling process. + #[builder(default)] + pub do_not_inherit_env: bool, + /// Provides a set of initial open files to be tracked by the shell. + pub fds: Option>, + /// Whether the shell is in POSIX compliance mode. + #[builder(default)] + pub posix: bool, + /// Whether to print commands and arguments as they are read. + #[builder(default)] + pub print_commands_and_arguments: bool, + /// Whether commands are being read from stdin. + #[builder(default)] + pub read_commands_from_stdin: bool, + /// The name of the shell. + pub shell_name: Option, + /// Optionally provides a display string describing the version and variant of the shell. + pub shell_product_display_str: Option, + /// Whether to run in maximal POSIX sh compatibility mode. + #[builder(default)] + pub sh_mode: bool, + /// Whether to print verbose output. + #[builder(default)] + pub verbose: bool, + /// Maximum function call depth. + pub max_function_call_depth: Option, + /// Key bindings helper for the shell to use. + pub key_bindings: Option, + /// Error formatter helper for the shell to use. + pub error_formatter: Option, + /// Brush implementation version. + pub shell_version: Option, +} + +impl Shell { + /// Create an instance of [Shell] using the builder syntax + pub fn builder() -> ShellBuilder { + CreateOptions::builder() + } + + /// Returns a new shell instance created with the given options. + /// + /// # Arguments + /// + /// * `options` - The options to use when creating the shell. + pub async fn new(options: CreateOptions) -> Result { + // Instantiate the shell with some defaults. + let mut shell = Self { + traps: traps::TrapHandlerConfig::default(), + open_files: openfiles::OpenFiles::new(), + // Populate working directory from the host environment. + working_dir: std::env::current_dir()?, + env: env::ShellEnvironment::new(), + funcs: functions::FunctionEnv::default(), + options: RuntimeOptions::defaults_from(&options), + jobs: jobs::JobManager::new(), + aliases: HashMap::default(), + last_exit_status: 0, + last_pipeline_statuses: vec![0], + positional_parameters: vec![], + shell_name: options.shell_name, + version: options.shell_version, + product_display_str: options.shell_product_display_str, + function_call_stack: functions::CallStack::new(), + script_call_stack: scripts::CallStack::new(), + directory_stack: vec![], + current_line_number: 0, + completion_config: completion::Config::default(), + builtins: options.builtins, + program_location_cache: pathcache::PathCache::default(), + last_stopwatch_time: std::time::SystemTime::now(), + last_stopwatch_offset: 0, + key_bindings: options.key_bindings, + history: None, + error_formatter: options + .error_formatter + .unwrap_or_else(|| Arc::new(Mutex::new(error::DefaultErrorFormatter::new()))), + depth: 0, + }; + + // Add in any open files provided. + if let Some(fds) = options.fds { + shell.open_files.update_from(fds.into_iter()); + } + + // TODO: Without this a script that sets extglob will fail because we + // parse the entire script with the same settings. + shell.options.extended_globbing = true; + + // Initialize environment. + wellknownvars::initialize_vars(&mut shell, options.do_not_inherit_env)?; + + // Set up history, if relevant. + if shell.options.enable_command_history { + if let Some(history_path) = shell.history_file_path() { + let mut options = std::fs::File::options(); + options.read(true); + + if let Ok(history_file) = + shell.open_file(&options, history_path, &shell.default_exec_params()) + { + shell.history = Some(history::History::import(history_file)?); + } + } + + if shell.history.is_none() { + shell.history = Some(history::History::default()); + } + } + + // Load profiles/configuration. + shell + .load_config( + options.no_profile, + options.no_rc, + options.rc_file.as_deref(), + ) + .await?; + + Ok(shell) + } + + /// Returns the current source line number being processed. + pub const fn current_line_number(&self) -> u32 { + self.current_line_number + } + + /// Returns the shell's official version string (if available). + pub const fn version(&self) -> &Option { + &self.version + } + + /// Returns the exit status of the last command executed in this shell. + pub const fn last_result(&self) -> u8 { + self.last_exit_status + } + + /// Returns a reference to the current function call stack for the shell. + pub const fn function_call_stack(&self) -> &functions::CallStack { + &self.function_call_stack + } + + /// Returns a reference to the current script call stack for the shell. + pub const fn script_call_stack(&self) -> &scripts::CallStack { + &self.script_call_stack + } + + /// Returns a mutable reference to the last exit status. + pub const fn last_exit_status_mut(&mut self) -> &mut u8 { + &mut self.last_exit_status + } + + /// Returns the key bindings helper for the shell. + pub const fn key_bindings(&self) -> &Option { + &self.key_bindings + } + + /// Returns the registered builtins for the shell. + pub const fn builtins(&self) -> &HashMap { + &self.builtins + } + + /// Returns the shell's current working directory. + pub fn working_dir(&self) -> &Path { + &self.working_dir + } + + /// Returns a mutable reference to the shell's current working directory. + /// This is only accessible within the crate. + pub(crate) const fn working_dir_mut(&mut self) -> &mut PathBuf { + &mut self.working_dir + } + + /// Returns the product display name for this shell. + pub const fn product_display_str(&self) -> &Option { + &self.product_display_str + } + + /// Returns the function definition environment for this shell. + pub const fn funcs(&self) -> &functions::FunctionEnv { + &self.funcs + } + + /// Tries to undefine a function in the shell's environment. Returns whether or + /// not a definition was removed. + /// + /// # Arguments + /// + /// * `name` - The name of the function to undefine. + pub fn undefine_func(&mut self, name: &str) -> bool { + self.funcs.remove(name).is_some() + } + + /// Defines a function in the shell's environment. If a function already exists + /// with the given name, it is replaced with the new definition. + /// + /// # Arguments + /// + /// * `name` - The name of the function to define. + /// * `definition` - The function's definition. + pub fn define_func( + &mut self, + name: impl Into, + definition: brush_parser::ast::FunctionDefinition, + ) { + self.funcs.update(name.into(), definition.into()); + } + + /// Tries to return a mutable reference to the registration for a named function. + /// Returns `None` if no such function was found. + /// + /// # Arguments + /// + /// * `name` - The name of the function to lookup + pub fn func_mut(&mut self, name: &str) -> Option<&mut functions::Registration> { + self.funcs.get_mut(name) + } + + /// Tries to define a function in the shell's environment using the given + /// string as its body. + /// + /// # Arguments + /// + /// * `name` - The name of the function + /// * `body_text` - The body of the function, expected to start with "()". + pub fn define_func_from_str( + &mut self, + name: impl Into, + body_text: &str, + ) -> Result<(), error::Error> { + let name = name.into(); + + let mut parser = create_parser(body_text.as_bytes(), &self.parser_options()); + let func_body = parser.parse_function_parens_and_body().map_err(|e| { + error::Error::from(error::ErrorKind::FunctionParseError(name.clone(), e)) + })?; + + let def = brush_parser::ast::FunctionDefinition { + fname: name.clone().into(), + body: func_body, + source: String::new(), + }; + + self.define_func(name, def); + + Ok(()) + } + + /// Returns the last "SECONDS" captured time. + pub const fn last_stopwatch_time(&self) -> std::time::SystemTime { + self.last_stopwatch_time + } + + /// Returns the last "SECONDS" offset requested. + pub const fn last_stopwatch_offset(&self) -> u32 { + self.last_stopwatch_offset + } + + async fn load_config( + &mut self, + skip_profile: bool, + skip_rc: bool, + rc_file: Option<&Path>, + ) -> Result<(), error::Error> { + let mut params = self.default_exec_params(); + params.process_group_policy = interp::ProcessGroupPolicy::SameProcessGroup; + + if self.options.login_shell { + // --noprofile means skip this. + if skip_profile { + return Ok(()); + } + + // + // Source /etc/profile if it exists. + // + // Next source the first of these that exists and is readable (if any): + // * ~/.bash_profile + // * ~/.bash_login + // * ~/.profile + // + self.source_if_exists(Path::new("/etc/profile"), ¶ms) + .await?; + if let Some(home_path) = self.home_dir() { + if self.options.sh_mode { + self.source_if_exists(home_path.join(".profile").as_path(), ¶ms) + .await?; + } else { + if !self + .source_if_exists(home_path.join(".bash_profile").as_path(), ¶ms) + .await? + { + if !self + .source_if_exists(home_path.join(".bash_login").as_path(), ¶ms) + .await? + { + self.source_if_exists(home_path.join(".profile").as_path(), ¶ms) + .await?; + } + } + } + } + } else { + if self.options.interactive { + // --norc means skip this. Also skip in sh mode. + if skip_rc || self.options.sh_mode { + return Ok(()); + } + + // If an rc file was specified, then source it. + if let Some(rc_file) = rc_file { + // If an explicit rc file is provided, source it. + self.source_if_exists(rc_file, ¶ms).await?; + } else { + // + // Otherwise, for non-login interactive shells, load in this order: + // + // /etc/bash.bashrc + // ~/.bashrc + // + self.source_if_exists(Path::new("/etc/bash.bashrc"), ¶ms) + .await?; + if let Some(home_path) = self.home_dir() { + self.source_if_exists(home_path.join(".bashrc").as_path(), ¶ms) + .await?; + self.source_if_exists(home_path.join(".brushrc").as_path(), ¶ms) + .await?; + } + } + } else { + let env_var_name = if self.options.sh_mode { + "ENV" + } else { + "BASH_ENV" + }; + + if self.env.is_set(env_var_name) { + // + // TODO: look at $ENV/BASH_ENV; source its expansion if that file exists + // + return error::unimp( + "load config from $ENV/BASH_ENV for non-interactive, non-login shell", + ); + } + } + } + + Ok(()) + } + + async fn source_if_exists( + &mut self, + path: impl AsRef, + params: &ExecutionParameters, + ) -> Result { + let path = path.as_ref(); + if path.exists() { + self.source_script(path, std::iter::empty::(), params) + .await?; + Ok(true) + } else { + tracing::debug!("skipping non-existent file: {}", path.display()); + Ok(false) + } + } + + /// Source the given file as a shell script, returning the execution result. + /// + /// # Arguments + /// + /// * `path` - The path to the file to source. + /// * `args` - The arguments to pass to the script as positional parameters. + /// * `params` - Execution parameters. + pub async fn source_script, P: AsRef, I: Iterator>( + &mut self, + path: P, + args: I, + params: &ExecutionParameters, + ) -> Result { + self.parse_and_execute_script_file(path.as_ref(), args, params, scripts::CallType::Sourced) + .await + } + + /// Parse and execute the given file as a shell script, returning the execution result. + /// + /// # Arguments + /// + /// * `path` - The path to the file to source. + /// * `args` - The arguments to pass to the script as positional parameters. + /// * `params` - Execution parameters. + /// * `call_type` - The type of script call being made. + async fn parse_and_execute_script_file, P: AsRef, I: Iterator>( + &mut self, + path: P, + args: I, + params: &ExecutionParameters, + call_type: scripts::CallType, + ) -> Result { + let path = path.as_ref(); + tracing::debug!("sourcing: {}", path.display()); + + let mut options = std::fs::File::options(); + options.read(true); + + let opened_file: openfiles::OpenFile = self + .open_file(&options, path, params) + .map_err(|e| error::ErrorKind::FailedSourcingFile(path.to_owned(), e))?; + + if opened_file.is_dir() { + return Err(error::ErrorKind::FailedSourcingFile( + path.to_owned(), + std::io::Error::from(std::io::ErrorKind::IsADirectory), + ) + .into()); + } + + let source_info = brush_parser::SourceInfo { + source: path.to_string_lossy().to_string(), + }; + + let mut result = self + .source_file(opened_file, &source_info, args, params, call_type) + .await?; + + // Handle control flow at script execution boundary. If execution completed + // with a `return`, we need to clear it since it's already been "used". All + // other control flow types are preserved. + if matches!( + result.next_control_flow, + ExecutionControlFlow::ReturnFromFunctionOrScript + ) { + result.next_control_flow = ExecutionControlFlow::Normal; + } + + Ok(result) + } + + /// Source the given file as a shell script, returning the execution result. + /// + /// # Arguments + /// + /// * `file` - The file to source. + /// * `source_info` - Information about the source of the script. + /// * `args` - The arguments to pass to the script as positional parameters. + /// * `params` - Execution parameters. + /// * `call_type` - The type of script call being made. + async fn source_file, I: Iterator>( + &mut self, + file: F, + source_info: &brush_parser::SourceInfo, + args: I, + params: &ExecutionParameters, + call_type: scripts::CallType, + ) -> Result { + let mut reader = std::io::BufReader::new(file); + let mut parser = + brush_parser::Parser::new(&mut reader, &self.parser_options(), source_info); + + tracing::debug!(target: trace_categories::PARSE, "Parsing sourced file: {}", source_info.source); + let parse_result = parser.parse_program(); + + let mut other_positional_parameters: Vec<_> = args.map(|s| s.as_ref().to_owned()).collect(); + let mut other_shell_name = Some(source_info.source.clone()); + let positional_params_given = !other_positional_parameters.is_empty(); + + // TODO: Find a cleaner way to change args. + std::mem::swap(&mut self.shell_name, &mut other_shell_name); + + // NOTE: We only shadow the original positional parameters if any were explicitly given + // for the script sourcing. + if positional_params_given { + std::mem::swap( + &mut self.positional_parameters, + &mut other_positional_parameters, + ); + } + + self.script_call_stack + .push(call_type, source_info.source.clone()); + + let result = self + .run_parsed_result(parse_result, source_info, params) + .await; + + self.script_call_stack.pop(); + + // Restore. + std::mem::swap(&mut self.shell_name, &mut other_shell_name); + + // We only restore the original positional parameters if we needed to shadow them. + if positional_params_given { + std::mem::swap( + &mut self.positional_parameters, + &mut other_positional_parameters, + ); + } + + result + } + + /// Invokes a function defined in this shell, returning the resulting exit status. + /// + /// # Arguments + /// + /// * `name` - The name of the function to invoke. + /// * `args` - The arguments to pass to the function. + /// * `params` - Execution parameters to use for the invocation. + pub async fn invoke_function, I: IntoIterator, A: AsRef>( + &mut self, + name: N, + args: I, + params: &ExecutionParameters, + ) -> Result { + let name = name.as_ref(); + let command_name = String::from(name); + + let func_registration = self + .funcs + .get(name) + .ok_or_else(|| error::ErrorKind::FunctionNotFound(name.to_owned()))?; + + let func = func_registration.definition.clone(); + + let context = commands::ExecutionContext { + shell: self, + command_name, + params: params.clone(), + }; + + let command_args = args + .into_iter() + .map(|s| commands::CommandArg::String(String::from(s.as_ref()))) + .collect::>(); + + match commands::invoke_shell_function(func, context, &command_args).await? { + ExecutionSpawnResult::StartedProcess(_) => { + error::unimp("child spawned from function invocation") + } + ExecutionSpawnResult::Completed(result) => Ok(result.exit_code.into()), + } + } + + /// Executes the given string as a shell program, returning the resulting exit status. + /// + /// # Arguments + /// + /// * `command` - The command to execute. + /// * `params` - Execution parameters. + pub async fn run_string>( + &mut self, + command: S, + params: &ExecutionParameters, + ) -> Result { + // TODO: Actually track line numbers; this is something of a hack, assuming each time + // this function is invoked we are on the next line of the input. For one thing, + // each string we run could be multiple lines. + self.current_line_number += 1; + + let parse_result = self.parse_string(command.into()); + let source_info = brush_parser::SourceInfo { + source: String::from("main"), + }; + self.run_parsed_result(parse_result, &source_info, params) + .await + } + + /// Parses the given reader as a shell program, returning the resulting Abstract Syntax Tree + /// for the program. + pub fn parse( + &self, + reader: R, + ) -> Result { + let mut parser = create_parser(reader, &self.parser_options()); + + tracing::debug!(target: trace_categories::PARSE, "Parsing reader as program..."); + parser.parse_program() + } + + /// Parses the given string as a shell program, returning the resulting Abstract Syntax Tree + /// for the program. + /// + /// # Arguments + /// + /// * `s` - The string to parse as a program. + pub fn parse_string>( + &self, + s: S, + ) -> Result { + parse_string_impl(s.into(), self.parser_options()) + } + + /// Applies basic shell expansion to the provided string. + /// + /// # Arguments + /// + /// * `s` - The string to expand. + pub async fn basic_expand_string>( + &mut self, + params: &ExecutionParameters, + s: S, + ) -> Result { + let result = expansion::basic_expand_str(self, params, s.as_ref()).await?; + Ok(result) + } + + /// Applies full shell expansion and field splitting to the provided string; returns + /// a sequence of fields. + /// + /// # Arguments + /// + /// * `s` - The string to expand and split. + pub async fn full_expand_and_split_string>( + &mut self, + params: &ExecutionParameters, + s: S, + ) -> Result, error::Error> { + let result = expansion::full_expand_and_split_str(self, params, s.as_ref()).await?; + Ok(result) + } + + /// Returns the default execution parameters for this shell. + pub fn default_exec_params(&self) -> ExecutionParameters { + ExecutionParameters::default() + } + + /// Executes the given script file, returning the resulting exit status. + /// + /// # Arguments + /// + /// * `script_path` - The path to the script file to execute. + /// * `args` - The arguments to pass to the script as positional parameters. + pub async fn run_script, P: AsRef, I: Iterator>( + &mut self, + script_path: P, + args: I, + ) -> Result { + let params = self.default_exec_params(); + let result = self + .parse_and_execute_script_file( + script_path.as_ref(), + args, + ¶ms, + scripts::CallType::Executed, + ) + .await?; + + let _ = self.on_exit().await; + + Ok(result) + } + + /// Runs any exit steps for the shell. + pub async fn on_exit(&mut self) -> Result<(), error::Error> { + self.invoke_exit_trap_handler_if_registered().await?; + + Ok(()) + } + + async fn invoke_exit_trap_handler_if_registered( + &mut self, + ) -> Result { + let Some(handler) = self.traps.handlers.get(&traps::TrapSignal::Exit).cloned() else { + return Ok(ExecutionResult::success()); + }; + + // TODO: Confirm whether trap handlers should be executed in the same process group. + let mut params = self.default_exec_params(); + params.process_group_policy = ProcessGroupPolicy::SameProcessGroup; + + let orig_last_exit_status = self.last_exit_status; + self.traps.handler_depth += 1; + + let result = self.run_string(handler, ¶ms).await; + + self.traps.handler_depth -= 1; + self.last_exit_status = orig_last_exit_status; + + result + } + + pub(crate) async fn run_parsed_result( + &mut self, + parse_result: Result, + source_info: &brush_parser::SourceInfo, + params: &ExecutionParameters, + ) -> Result { + // If parsing succeeded, run the program. + let result = match parse_result { + Ok(prog) => self.run_program(prog, params).await, + Err(parse_err) => Err(error::Error::from(error::ErrorKind::ParseError( + parse_err, + source_info.clone(), + ))), + }; + + // Report any errors. + match result { + Ok(result) => Ok(result), + Err(err) => { + let _ = self.display_error(&mut params.stderr(self), &err).await; + let exit_code = ExecutionExitCode::from(&err); + *self.last_exit_status_mut() = exit_code.into(); + Ok(exit_code.into()) + } + } + } + + /// Executes the given parsed shell program, returning the resulting exit status. + /// + /// # Arguments + /// + /// * `program` - The program to execute. + /// * `params` - Execution parameters. + pub async fn run_program( + &mut self, + program: brush_parser::ast::Program, + params: &ExecutionParameters, + ) -> Result { + program.execute(self, params).await + } + + const fn default_prompt(&self) -> &'static str { + if self.options.sh_mode { + "$ " + } else { + "brush$ " + } + } + + /// Composes the shell's post-input, pre-command prompt, applying all appropriate expansions. + pub async fn compose_precmd_prompt(&mut self) -> Result { + self.expand_prompt_var("PS0", "").await + } + + /// Composes the shell's prompt, applying all appropriate expansions. + pub async fn compose_prompt(&mut self) -> Result { + self.expand_prompt_var("PS1", self.default_prompt()).await + } + + /// Compose's the shell's alternate-side prompt, applying all appropriate expansions. + pub async fn compose_alt_side_prompt(&mut self) -> Result { + // This is a brush extension. + self.expand_prompt_var("BRUSH_PS_ALT", "").await + } + + /// Composes the shell's continuation prompt. + pub async fn compose_continuation_prompt(&mut self) -> Result { + self.expand_prompt_var("PS2", "> ").await + } + + async fn expand_prompt_var( + &mut self, + var_name: &str, + default: &str, + ) -> Result { + // + // TODO(prompt): bash appears to do this in a subshell; we need to investigate + // if that's required. + // + + // Retrieve the spec. + let prompt_spec = self.parameter_or_default(var_name, default); + if prompt_spec.is_empty() { + return Ok(String::new()); + } + + // Expand it. + let params = self.default_exec_params(); + prompt::expand_prompt(self, ¶ms, prompt_spec.into_owned()).await + } + + fn parameter_or_default<'a>(&'a self, name: &str, default: &'a str) -> Cow<'a, str> { + self.env_str(name).unwrap_or_else(|| default.into()) + } + + /// Returns the options that should be used for parsing shell programs; reflects + /// the current configuration state of the shell and may change over time. + pub const fn parser_options(&self) -> brush_parser::ParserOptions { + brush_parser::ParserOptions { + enable_extended_globbing: self.options.extended_globbing, + posix_mode: self.options.posix_mode, + sh_mode: self.options.sh_mode, + tilde_expansion: true, + } + } + + /// Returns whether or not the shell is actively executing in a sourced script. + pub fn in_sourced_script(&self) -> bool { + self.script_call_stack.in_sourced_script() + } + + /// Returns whether or not the shell is actively executing in a shell function. + pub fn in_function(&self) -> bool { + !self.function_call_stack.is_empty() + } + + /// Updates the shell's internal tracking state to reflect that a new shell + /// function is being entered. + /// + /// # Arguments + /// + /// * `name` - The name of the function being entered. + /// * `function_def` - The definition of the function being entered. + pub(crate) fn enter_function( + &mut self, + name: &str, + function_def: &Arc, + ) -> Result<(), error::Error> { + if let Some(max_call_depth) = self.options.max_function_call_depth { + if self.function_call_stack.depth() >= max_call_depth { + return Err(error::ErrorKind::MaxFunctionCallDepthExceeded.into()); + } + } + + if tracing::enabled!(target: trace_categories::FUNCTIONS, tracing::Level::DEBUG) { + let depth = self.function_call_stack.depth(); + let prefix = repeated_char_str(' ', depth); + tracing::debug!(target: trace_categories::FUNCTIONS, "Entering func [depth={depth}]: {prefix}{name}"); + } + + self.function_call_stack.push(name, function_def); + self.env.push_scope(env::EnvironmentScope::Local); + + Ok(()) + } + + /// Updates the shell's internal tracking state to reflect that the shell + /// has exited the top-most function on its call stack. + pub(crate) fn leave_function(&mut self) -> Result<(), error::Error> { + self.env.pop_scope(env::EnvironmentScope::Local)?; + + if let Some(exited_call) = self.function_call_stack.pop() { + if tracing::enabled!(target: trace_categories::FUNCTIONS, tracing::Level::DEBUG) { + let depth = self.function_call_stack.depth(); + let prefix = repeated_char_str(' ', depth); + tracing::debug!(target: trace_categories::FUNCTIONS, "Exiting func [depth={depth}]: {prefix}{}", exited_call.function_name); + } + } + + Ok(()) + } + + /// Returns the path to the history file used by the shell, if one is set. + pub fn history_file_path(&self) -> Option { + self.env_str("HISTFILE") + .map(|s| PathBuf::from(s.into_owned())) + } + + /// Returns the path to the history file used by the shell, if one is set. + pub fn history_time_format(&self) -> Option { + self.env_str("HISTTIMEFORMAT").map(|s| s.into_owned()) + } + + /// Saves history back to any backing storage. + pub fn save_history(&mut self) -> Result<(), error::Error> { + if let Some(history_file_path) = self.history_file_path() { + if let Some(history) = &mut self.history { + // See if there's *any* time format configured. That triggers writing out timestamps. + let write_timestamps = self.env.is_set("HISTTIMEFORMAT"); + + // TODO: Observe options.append_to_history_file + history.flush( + history_file_path, + true, /*append?*/ + true, /*unsaved items only?*/ + write_timestamps, + )?; + } + } + + Ok(()) + } + + /// Adds a command to history. + pub fn add_to_history(&mut self, command: &str) -> Result<(), error::Error> { + if let Some(history) = &mut self.history { + // Trim. + let command = command.trim(); + + // For now, discard empty commands. + if command.is_empty() { + return Ok(()); + } + + // Add it to history. + history.add(history::Item { + id: 0, + command_line: command.to_owned(), + timestamp: Some(chrono::Utc::now()), + dirty: true, + })?; + } + + Ok(()) + } + + /// Tries to retrieve a variable from the shell's environment, converting it into its + /// string form. + /// + /// # Arguments + /// + /// * `name` - The name of the variable to retrieve. + pub fn env_str(&self, name: &str) -> Option> { + self.env.get_str(name, self) + } + + /// Tries to retrieve a variable from the shell's environment. + /// + /// # Arguments + /// + /// * `name` - The name of the variable to retrieve. + pub fn env_var(&self, name: &str) -> Option<&ShellVariable> { + self.env.get(name).map(|(_, var)| var) + } + + /// Tries to set a global variable in the shell's environment. + /// + /// # Arguments + /// + /// * `name` - The name of the variable to add. + /// * `var` - The variable contents to add. + pub fn set_env_global(&mut self, name: &str, var: ShellVariable) -> Result<(), error::Error> { + self.env.set_global(name, var) + } + + /// Register a builtin to the shell's environment. + /// + /// # Arguments + /// + /// * `name` - The in-shell name of the builtin. + /// * `registration` - The registration handle for the builtin. + pub fn register_builtin>( + &mut self, + name: S, + registration: builtins::Registration, + ) { + self.builtins.insert(name.into(), registration); + } + + /// Tries to retrieve a mutable reference to an existing builtin registration. + /// Returns `None` if no such registration exists. + /// + /// # Arguments + /// + /// * `name` - The name of the builtin to lookup. + pub fn builtin_mut(&mut self, name: &str) -> Option<&mut builtins::Registration> { + self.builtins.get_mut(name) + } + + /// Returns the current value of the IFS variable, or the default value if it is not set. + pub fn ifs(&self) -> Cow<'_, str> { + self.env_str("IFS").unwrap_or_else(|| " \t\n".into()) + } + + /// Returns the first character of the IFS variable, or a space if it is not set. + pub(crate) fn get_ifs_first_char(&self) -> char { + self.ifs().chars().next().unwrap_or(' ') + } + + /// Generates command completions for the shell. + /// + /// # Arguments + /// + /// * `input` - The input string to generate completions for. + /// * `position` - The position in the input string to generate completions at. + pub async fn complete( + &mut self, + input: &str, + position: usize, + ) -> Result { + let completion_config = self.completion_config.clone(); + completion_config + .get_completions(self, input, position) + .await + } + + /// Finds executables in the shell's current default PATH, matching the given glob pattern. + /// + /// # Arguments + /// + /// * `required_glob_pattern` - The glob pattern to match against. + pub fn find_executables_in_path<'a>( + &'a self, + filename: &'a str, + ) -> impl Iterator + 'a { + let path_var = self.env.get_str("PATH", self).unwrap_or_default(); + let paths = path_var.split(':').map(|s| s.to_owned()); + + pathsearch::search_for_executable(paths.into_iter(), filename) + } + + /// Finds executables in the shell's current default PATH, with filenames matching the + /// given prefix. + /// + /// # Arguments + /// + /// * `filename_prefix` - The prefix to match against executable filenames. + pub fn find_executables_in_path_with_prefix( + &self, + filename_prefix: &str, + case_insensitive: bool, + ) -> impl Iterator { + let path_var = self.env.get_str("PATH", self).unwrap_or_default(); + let paths = path_var.split(':').map(|s| s.to_owned()); + + pathsearch::search_for_executable_with_prefix( + paths.into_iter(), + filename_prefix, + case_insensitive, + ) + } + + /// Determines whether the given filename is the name of an executable in one of the + /// directories in the shell's current PATH. If found, returns the path. + /// + /// # Arguments + /// + /// * `candidate_name` - The name of the file to look for. + pub fn find_first_executable_in_path>( + &self, + candidate_name: S, + ) -> Option { + for dir_str in self.env_str("PATH").unwrap_or_default().split(':') { + let candidate_path = Path::new(dir_str).join(candidate_name.as_ref()); + if candidate_path.executable() { + return Some(candidate_path); + } + } + None + } + + /// Uses the shell's hash-based path cache to check whether the given filename is the name + /// of an executable in one of the directories in the shell's current PATH. If found, + /// ensures the path is in the cache and returns it. + /// + /// # Arguments + /// + /// * `candidate_name` - The name of the file to look for. + pub fn find_first_executable_in_path_using_cache>( + &mut self, + candidate_name: S, + ) -> Option { + if let Some(cached_path) = self.program_location_cache.get(&candidate_name) { + Some(cached_path) + } else if let Some(found_path) = self.find_first_executable_in_path(&candidate_name) { + self.program_location_cache + .set(&candidate_name, found_path.clone()); + Some(found_path) + } else { + None + } + } + + /// Gets the absolute form of the given path. + /// + /// # Arguments + /// + /// * `path` - The path to get the absolute form of. + pub fn absolute_path(&self, path: impl AsRef) -> PathBuf { + let path = path.as_ref(); + if path.as_os_str().is_empty() || path.is_absolute() { + path.to_owned() + } else { + self.working_dir().join(path) + } + } + + /// Opens the given file, using the context of this shell and the provided execution parameters. + /// + /// # Arguments + /// + /// * `options` - The options to use opening the file. + /// * `path` - The path to the file to open; may be relative to the shell's working directory. + /// * `params` - Execution parameters. + pub(crate) fn open_file( + &self, + options: &std::fs::OpenOptions, + path: impl AsRef, + params: &ExecutionParameters, + ) -> Result { + let path_to_open = self.absolute_path(path.as_ref()); + + // See if this is a reference to a file descriptor, in which case the actual + // /dev/fd* file path for this process may not match with what's in the execution + // parameters. + if let Some(parent) = path_to_open.parent() { + if parent == Path::new("/dev/fd") { + if let Some(filename) = path_to_open.file_name() { + if let Ok(fd_num) = filename.to_string_lossy().to_string().parse::() { + if let Some(open_file) = params.try_fd(self, fd_num) { + return open_file.try_clone(); + } + } + } + } + } + + Ok(options.open(path_to_open)?.into()) + } + + /// Sets the shell's current working directory to the given path. + /// + /// # Arguments + /// + /// * `target_dir` - The path to set as the working directory. + pub fn set_working_dir(&mut self, target_dir: impl AsRef) -> Result<(), error::Error> { + let abs_path = self.absolute_path(target_dir.as_ref()); + + match std::fs::metadata(&abs_path) { + Ok(m) => { + if !m.is_dir() { + return Err(error::ErrorKind::NotADirectory(abs_path).into()); + } + } + Err(e) => { + return Err(e.into()); + } + } + + // Normalize the path (but don't canonicalize it). + let cleaned_path = abs_path.normalize(); + + let pwd = cleaned_path.to_string_lossy().to_string(); + + self.env.update_or_add( + "PWD", + variables::ShellValueLiteral::Scalar(pwd), + |var| { + var.export(); + Ok(()) + }, + EnvironmentLookup::Anywhere, + EnvironmentScope::Global, + )?; + let oldpwd = std::mem::replace(self.working_dir_mut(), cleaned_path); + + self.env.update_or_add( + "OLDPWD", + variables::ShellValueLiteral::Scalar(oldpwd.to_string_lossy().to_string()), + |var| { + var.export(); + Ok(()) + }, + EnvironmentLookup::Anywhere, + EnvironmentScope::Global, + )?; + + Ok(()) + } + + /// Tilde-shortens the given string, replacing the user's home directory with a tilde. + /// + /// # Arguments + /// + /// * `s` - The string to shorten. + pub fn tilde_shorten(&self, s: String) -> String { + if let Some(home_dir) = self.home_dir() { + if let Some(stripped) = s.strip_prefix(home_dir.to_string_lossy().as_ref()) { + return format!("~{stripped}"); + } + } + s + } + + /// Returns the shell's current home directory, if available. + pub(crate) fn home_dir(&self) -> Option { + if let Some(home) = self.env.get_str("HOME", self) { + Some(PathBuf::from(home.to_string())) + } else { + // HOME isn't set, so let's sort it out ourselves. + users::get_current_user_home_dir() + } + } + + /// Replaces the shell's currently configured open files with the given set. + /// Typically only used by exec-like builtins. + /// + /// # Arguments + /// + /// * `open_files` - The new set of open files to use. + pub fn replace_open_files( + &mut self, + open_fds: impl Iterator, + ) { + self.open_files = openfiles::OpenFiles::from(open_fds); + } + + pub(crate) const fn persistent_open_files(&self) -> &openfiles::OpenFiles { + &self.open_files + } + + /// Returns a value that can be used to write to the shell's currently configured + /// standard output stream using `write!` at al. + pub fn stdout(&self) -> impl std::io::Write { + self.open_files.try_stdout().cloned().unwrap() + } + + /// Returns a value that can be used to write to the shell's currently configured + /// standard error stream using `write!` et al. + pub fn stderr(&self) -> impl std::io::Write { + self.open_files.try_stderr().cloned().unwrap() + } + + /// Outputs `set -x` style trace output for a command. + /// + /// # Arguments + /// + /// * `command` - The command to trace. + pub(crate) async fn trace_command>( + &mut self, + params: &ExecutionParameters, + command: S, + ) -> Result<(), error::Error> { + // Expand the PS4 prompt variable to get our prefix. + let ps4 = self.as_mut().expand_prompt_var("PS4", "").await?; + let mut prefix = ps4; + + // Add additional depth-based prefixes using the first character of PS4. + let additional_depth = self.script_call_stack.depth() + self.depth; + if let Some(c) = prefix.chars().next() { + for _ in 0..additional_depth { + prefix.insert(0, c); + } + } + + // Resolve which file descriptor to use for tracing. We default to stderr. + let mut trace_file = params.try_stderr(self); + + // If BASH_XTRACEFD is set and refers to a valid file descriptor, use that instead. + if let Some((_, xtracefd_var)) = self.env.get("BASH_XTRACEFD") { + let xtracefd_value = xtracefd_var.value().to_cow_str(self); + if let Ok(fd) = xtracefd_value.parse::() { + if let Some(file) = self.open_files.try_fd(fd) { + trace_file = Some(file.clone()); + } + } + } + + // If we have a valid trace file, write to it. + if let Some(trace_file) = trace_file { + let mut trace_file = trace_file.try_clone()?; + writeln!(trace_file, "{prefix}{}", command.as_ref())?; + } + + Ok(()) + } + + /// Returns the keywords that are reserved by the shell. + pub(crate) fn get_keywords(&self) -> Vec { + if self.options.sh_mode { + keywords::SH_MODE_KEYWORDS.iter().cloned().collect() + } else { + keywords::KEYWORDS.iter().cloned().collect() + } + } + + /// Checks if the given string is a keyword reserved in this shell. + /// + /// # Arguments + /// + /// * `s` - The string to check. + pub fn is_keyword(&self, s: &str) -> bool { + if self.options.sh_mode { + keywords::SH_MODE_KEYWORDS.contains(s) + } else { + keywords::KEYWORDS.contains(s) + } + } + + /// Checks for completed jobs in the shell, reporting any changes found. + pub fn check_for_completed_jobs(&mut self) -> Result<(), error::Error> { + let results = self.jobs.poll()?; + + if self.options.enable_job_control { + for (job, _result) in results { + writeln!(self.stderr(), "{job}")?; + } + } + + Ok(()) + } + + /// Evaluate the given arithmetic expression, returning the result. + pub fn eval_arithmetic( + &mut self, + expr: &brush_parser::ast::ArithmeticExpr, + ) -> Result { + Ok(expr.eval(self)?) + } + + /// Updates the shell state to reflect the given edit buffer contents. + /// + /// # Arguments + /// + /// * `contents` - The contents of the edit buffer. + /// * `cursor` - The cursor position in the edit buffer. + pub fn set_edit_buffer(&mut self, contents: String, cursor: usize) -> Result<(), error::Error> { + self.env + .set_global("READLINE_LINE", ShellVariable::new(contents))?; + + self.env + .set_global("READLINE_POINT", ShellVariable::new(cursor.to_string()))?; + + Ok(()) + } + + /// Returns the contents of the shell's edit buffer, if any. The buffer + /// state is cleared from the shell. + pub fn pop_edit_buffer(&mut self) -> Result, error::Error> { + let line = self + .env + .unset("READLINE_LINE")? + .map(|line| line.value().to_cow_str(self).to_string()); + + let point = self + .env + .unset("READLINE_POINT")? + .and_then(|point| point.value().to_cow_str(self).parse::().ok()) + .unwrap_or(0); + + if let Some(line) = line { + Ok(Some((line, point))) + } else { + Ok(None) + } + } + + /// Returns the shell's history, if it exists. + pub const fn history(&self) -> Option<&history::History> { + self.history.as_ref() + } + + /// Returns a mutable reference to the shell's history, if it exists. + pub const fn history_mut(&mut self) -> Option<&mut history::History> { + self.history.as_mut() + } + + /// Returns whether or not this shell is a subshell. + pub const fn is_subshell(&self) -> bool { + self.depth > 0 + } + + /// Returns the current subshell depth; 0 is returned if this shell is not a subshell. + pub const fn depth(&self) -> usize { + self.depth + } + + /// Displays the given error to the user, using the shell's error display mechanisms. + /// + /// # Arguments + /// + /// * `file_table` - The open file table to use for any file descriptor references. + /// * `err` - The error to display. + pub async fn display_error( + &self, + file: &mut impl std::io::Write, + err: &error::Error, + ) -> Result<(), error::Error> { + let str = self.error_formatter.lock().await.format_error(err, self); + write!(file, "{str}")?; + + Ok(()) + } +} + +#[cached::proc_macro::cached(size = 64, result = true)] +fn parse_string_impl( + s: String, + parser_options: brush_parser::ParserOptions, +) -> Result { + let mut parser = create_parser(s.as_bytes(), &parser_options); + + tracing::debug!(target: trace_categories::PARSE, "Parsing string as program..."); + parser.parse_program() +} + +fn create_parser( + r: R, + parser_options: &brush_parser::ParserOptions, +) -> brush_parser::Parser> { + let reader = std::io::BufReader::new(r); + let source_info = brush_parser::SourceInfo { + source: String::from("main"), + }; + + brush_parser::Parser::new(reader, parser_options, &source_info) +} + +fn repeated_char_str(c: char, count: usize) -> String { + (0..count).map(|_| c).collect() +} diff --git a/crates/brush-core-vendored/src/sys.rs b/crates/brush-core-vendored/src/sys.rs new file mode 100644 index 000000000..96f28d47e --- /dev/null +++ b/crates/brush-core-vendored/src/sys.rs @@ -0,0 +1,40 @@ +//! Platform abstraction facilities + +#![allow(unused)] + +#[cfg(unix)] +pub(crate) mod unix; +#[cfg(unix)] +pub(crate) use unix as platform; + +#[cfg(windows)] +pub(crate) mod windows; +#[cfg(windows)] +pub(crate) use windows as platform; + +#[cfg(target_family = "wasm")] +pub(crate) mod wasm; +#[cfg(target_family = "wasm")] +pub(crate) use wasm as platform; + +#[cfg(not(unix))] +pub(crate) mod stubs; + +#[cfg(any(unix, windows))] +pub(crate) mod hostname; +#[cfg(any(unix, windows))] +pub mod tokio_process; + +pub mod fs; + +pub use platform::commands; +pub use platform::fd; +pub use platform::input; +pub(crate) use platform::network; +pub use platform::process; +pub use platform::resource; +pub use platform::signal; +pub use platform::terminal; +pub(crate) use platform::users; + +pub use platform::PlatformError; diff --git a/crates/brush-core-vendored/src/sys/fs.rs b/crates/brush-core-vendored/src/sys/fs.rs new file mode 100644 index 000000000..56f92900a --- /dev/null +++ b/crates/brush-core-vendored/src/sys/fs.rs @@ -0,0 +1,31 @@ +//! Filesystem utilities + +pub use super::platform::fs::*; + +/// Extension trait for path-related filesystem operations. +pub trait PathExt { + /// Returns true if the path exists and is readable by the current user. + fn readable(&self) -> bool; + /// Returns true if the path exists and is writable by the current user. + fn writable(&self) -> bool; + /// Returns true if the path exists and is executable by the current user. + fn executable(&self) -> bool; + + /// Returns true if the path exists and is a block device. + fn exists_and_is_block_device(&self) -> bool; + /// Returns true if the path exists and is a character device. + fn exists_and_is_char_device(&self) -> bool; + /// Returns true if the path exists and is a FIFO (named pipe). + fn exists_and_is_fifo(&self) -> bool; + /// Returns true if the path exists and is a socket. + fn exists_and_is_socket(&self) -> bool; + /// Returns true if the path exists and has the setgid bit set. + fn exists_and_is_setgid(&self) -> bool; + /// Returns true if the path exists and has the setuid bit set. + fn exists_and_is_setuid(&self) -> bool; + /// Returns true if the path exists and has the sticky bit set. + fn exists_and_is_sticky_bit(&self) -> bool; + + /// Returns the device ID and inode number for the path. + fn get_device_and_inode(&self) -> Result<(u64, u64), crate::error::Error>; +} diff --git a/crates/brush-core-vendored/src/sys/hostname.rs b/crates/brush-core-vendored/src/sys/hostname.rs new file mode 100644 index 000000000..422b880c0 --- /dev/null +++ b/crates/brush-core-vendored/src/sys/hostname.rs @@ -0,0 +1,3 @@ +pub(crate) fn get() -> std::io::Result { + hostname::get() +} diff --git a/crates/brush-core-vendored/src/sys/stubs.rs b/crates/brush-core-vendored/src/sys/stubs.rs new file mode 100644 index 000000000..49e2ddd92 --- /dev/null +++ b/crates/brush-core-vendored/src/sys/stubs.rs @@ -0,0 +1,19 @@ +#![allow(dead_code)] +#![allow(clippy::missing_const_for_fn)] +#![allow(clippy::needless_pass_by_ref_mut)] +#![allow(clippy::needless_pass_by_value)] +#![allow(clippy::unnecessary_wraps)] +#![allow(clippy::unused_async)] +#![allow(clippy::unused_self)] + +pub mod commands; +pub mod fd; +pub mod fs; +pub mod input; +pub(crate) mod network; +pub(crate) mod pipes; +pub mod process; +pub mod resource; +pub mod signal; +pub mod terminal; +pub(crate) mod users; diff --git a/crates/brush-core-vendored/src/sys/stubs/commands.rs b/crates/brush-core-vendored/src/sys/stubs/commands.rs new file mode 100644 index 000000000..2cca39aee --- /dev/null +++ b/crates/brush-core-vendored/src/sys/stubs/commands.rs @@ -0,0 +1,89 @@ +//! Command execution utilities. + +use std::ffi::OsStr; + +use crate::{ShellFd, error, openfiles}; + +/// Extension trait for Unix-like command extensions. +pub trait CommandExt { + /// Sets the zeroth argument (argv[0]) of the command. + /// + /// # Arguments + /// + /// * `arg` - The argument to set as argv[0]. + fn arg0(&mut self, arg: S) -> &mut Self + where + S: AsRef; + + /// Sets the process group ID of the command. + /// + /// # Arguments + /// + /// * `pgroup` - The process group ID to set. + fn process_group(&mut self, pgroup: i32) -> &mut Self; +} + +impl CommandExt for std::process::Command { + fn arg0(&mut self, _arg: S) -> &mut Self + where + S: AsRef, + { + // NOTE: no-op. + self + } + + fn process_group(&mut self, _pgroup: i32) -> &mut Self { + // NOTE: no-op. + self + } +} + +/// Extension trait for Unix-like exit status extensions. +pub trait ExitStatusExt { + /// Returns the signal that terminated the process, if any. + fn signal(&self) -> Option; +} + +impl ExitStatusExt for std::process::ExitStatus { + fn signal(&self) -> Option { + None + } +} + +/// Extension trait for injecting file descriptors into commands. +pub trait CommandFdInjectionExt { + /// Injects the given open files as file descriptors into the command. + /// + /// # Arguments + /// + /// * `open_files` - A mapping of child file descriptors to open files. + fn inject_fds( + &mut self, + open_files: impl Iterator, + ) -> Result<(), error::Error>; +} + +impl CommandFdInjectionExt for std::process::Command { + fn inject_fds( + &mut self, + mut open_files: impl Iterator, + ) -> Result<(), error::Error> { + if open_files.next().is_some() { + return Err(error::ErrorKind::NotSupportedOnThisPlatform("fd redirections").into()); + } + + Ok(()) + } +} + +/// Extension trait for arranging for commands to take the foreground. +pub trait CommandFgControlExt { + /// Arranges for the command to take the foreground when it is executed. + fn take_foreground(&mut self); +} + +impl CommandFgControlExt for std::process::Command { + fn take_foreground(&mut self) { + // NOTE: This is a no-op. + } +} diff --git a/crates/brush-core-vendored/src/sys/stubs/fd.rs b/crates/brush-core-vendored/src/sys/stubs/fd.rs new file mode 100644 index 000000000..88ad54177 --- /dev/null +++ b/crates/brush-core-vendored/src/sys/stubs/fd.rs @@ -0,0 +1,13 @@ +//! File descriptor utilities. + +use crate::{ShellFd, error, openfiles}; + +/// Stub implementation for platforms that do not support enumerating file descriptors. +pub fn try_iter_open_fds() -> impl Iterator { + std::iter::empty() +} + +/// Stub implementation for platforms that do not support opening file descriptors. +pub fn try_get_file_for_open_fd(_fd: ShellFd) -> Option { + None +} diff --git a/crates/brush-core-vendored/src/sys/stubs/fs.rs b/crates/brush-core-vendored/src/sys/stubs/fs.rs new file mode 100644 index 000000000..74fcd6f49 --- /dev/null +++ b/crates/brush-core-vendored/src/sys/stubs/fs.rs @@ -0,0 +1,78 @@ +//! Filesystem utilities. + +use crate::error; + +impl crate::sys::fs::PathExt for std::path::Path { + fn readable(&self) -> bool { + true + } + + fn writable(&self) -> bool { + true + } + + fn executable(&self) -> bool { + true + } + + fn exists_and_is_block_device(&self) -> bool { + false + } + + fn exists_and_is_char_device(&self) -> bool { + false + } + + fn exists_and_is_fifo(&self) -> bool { + false + } + + fn exists_and_is_socket(&self) -> bool { + false + } + + fn exists_and_is_setgid(&self) -> bool { + false + } + + fn exists_and_is_setuid(&self) -> bool { + false + } + + fn exists_and_is_sticky_bit(&self) -> bool { + false + } + + fn get_device_and_inode(&self) -> Result<(u64, u64), crate::error::Error> { + Ok((0, 0)) + } +} + +pub(crate) trait MetadataExt { + fn gid(&self) -> u32 { + 0 + } + + fn uid(&self) -> u32 { + 0 + } +} + +impl MetadataExt for std::fs::Metadata {} + +pub(crate) fn get_default_executable_search_paths() -> Vec { + vec![] +} + +/// Returns the default paths where standard Unix utilities are typically installed. +/// This is a stub implementation that returns an empty vector. +pub fn get_default_standard_utils_paths() -> Vec { + vec![] +} + +/// Opens a null file that will discard all I/O. +/// +/// This is a stub implementation that returns an error. +pub fn open_null_file() -> Result { + Err(error::ErrorKind::NotSupportedOnThisPlatform("opening null file").into()) +} diff --git a/crates/brush-core-vendored/src/sys/stubs/input.rs b/crates/brush-core-vendored/src/sys/stubs/input.rs new file mode 100644 index 000000000..26b4dbe8f --- /dev/null +++ b/crates/brush-core-vendored/src/sys/stubs/input.rs @@ -0,0 +1,11 @@ +//! Terminal input utilities + +use crate::{error, interfaces}; + +/// Translates a key code (byte sequence) into a `Key` enum value. Returns `None` +/// if the key code is not recognized. +/// +/// This is a stub implementation that always returns `None`. +pub fn try_get_key_from_key_code(_key_code: &[u8]) -> Option { + None +} diff --git a/crates/brush-core-vendored/src/sys/stubs/network.rs b/crates/brush-core-vendored/src/sys/stubs/network.rs new file mode 100644 index 000000000..73ed3156f --- /dev/null +++ b/crates/brush-core-vendored/src/sys/stubs/network.rs @@ -0,0 +1,3 @@ +pub(crate) fn get_hostname() -> std::io::Result { + Ok("".into()) +} diff --git a/crates/brush-core-vendored/src/sys/stubs/pipes.rs b/crates/brush-core-vendored/src/sys/stubs/pipes.rs new file mode 100644 index 000000000..afe62c79b --- /dev/null +++ b/crates/brush-core-vendored/src/sys/stubs/pipes.rs @@ -0,0 +1,55 @@ +/// Stub implementation of a pipe reader. +#[derive(Clone)] +pub(crate) struct PipeReader {} + +impl PipeReader { + /// Tries to clone the reader. + pub fn try_clone(&self) -> std::io::Result { + Ok((*self).clone()) + } +} + +impl From for std::process::Stdio { + fn from(_reader: PipeReader) -> Self { + Self::null() + } +} + +impl std::io::Read for PipeReader { + fn read(&mut self, _buf: &mut [u8]) -> std::io::Result { + // TODO: implement + Ok(0) + } +} + +/// Stub implementation o a pipe writer. +#[derive(Clone)] +pub(crate) struct PipeWriter {} + +impl PipeWriter { + /// Tries to clone the writer. + pub fn try_clone(&self) -> std::io::Result { + Ok((*self).clone()) + } +} + +impl From for std::process::Stdio { + fn from(_writer: PipeWriter) -> Self { + Self::null() + } +} + +impl std::io::Write for PipeWriter { + fn write(&mut self, _buf: &[u8]) -> std::io::Result { + // TODO: implement + Ok(0) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +pub(crate) fn pipe() -> std::io::Result<(PipeReader, PipeWriter)> { + Ok((PipeReader {}, PipeWriter {})) +} diff --git a/crates/brush-core-vendored/src/sys/stubs/process.rs b/crates/brush-core-vendored/src/sys/stubs/process.rs new file mode 100644 index 000000000..ac44aad07 --- /dev/null +++ b/crates/brush-core-vendored/src/sys/stubs/process.rs @@ -0,0 +1,34 @@ +//! Process management utilities + +pub(crate) type ProcessId = i32; + +/// Provides access to a child process. +pub struct Child { + inner: std::process::Child, +} + +pub(crate) use std::process::ExitStatus; +pub(crate) use std::process::Output; + +impl Child { + /// Returns the process ID of the child process, if available. + pub fn id(&self) -> Option { + None + } + + /// Asynchronously waits for the child process to exit. + pub async fn wait(&mut self) -> std::io::Result { + self.inner.wait() + } + + /// Asynchronously waits for the child process to exit and collects its + /// output. + pub async fn wait_with_output(self) -> std::io::Result { + self.inner.wait_with_output() + } +} + +pub(crate) fn spawn(mut command: std::process::Command) -> std::io::Result { + let child = command.spawn()?; + Ok(Child { inner: child }) +} diff --git a/crates/brush-core-vendored/src/sys/stubs/resource.rs b/crates/brush-core-vendored/src/sys/stubs/resource.rs new file mode 100644 index 000000000..756e0230f --- /dev/null +++ b/crates/brush-core-vendored/src/sys/stubs/resource.rs @@ -0,0 +1,19 @@ +//! Signal processing utilities + +use crate::error; + +/// Returns the user and system CPU time used by the current process. +/// +/// This is a stub implementation that returns zero durations. +pub fn get_self_user_and_system_time() +-> Result<(std::time::Duration, std::time::Duration), error::Error> { + Ok((std::time::Duration::ZERO, std::time::Duration::ZERO)) +} + +/// Returns the user and system CPU time used by child processes. +/// +/// This is a stub implementation that returns zero durations. +pub fn get_children_user_and_system_time() +-> Result<(std::time::Duration, std::time::Duration), error::Error> { + Ok((std::time::Duration::ZERO, std::time::Duration::ZERO)) +} diff --git a/crates/brush-core-vendored/src/sys/stubs/signal.rs b/crates/brush-core-vendored/src/sys/stubs/signal.rs new file mode 100644 index 000000000..892e45294 --- /dev/null +++ b/crates/brush-core-vendored/src/sys/stubs/signal.rs @@ -0,0 +1,84 @@ +//! Signal processing utilities + +use crate::{error, sys, traps}; + +/// A stub enum representing system signals on unsupported platforms. +#[allow(unnameable_types)] +#[derive(Clone, Copy, Eq, Hash, PartialEq)] +pub enum Signal {} + +impl Signal { + /// Returns an iterator over all possible signals. + pub fn iterator() -> impl Iterator { + std::iter::empty() + } + + /// Converts the signal into its corresponding name as a `&'static str`. + pub const fn as_str(self) -> &'static str { + "" + } + + /// Creates a `Signal` from a string representation. + pub fn from_str(s: &str) -> Result { + Err(error::ErrorKind::InvalidSignal(s.into()).into()) + } +} + +impl TryFrom for Signal { + type Error = error::Error; + + fn try_from(value: i32) -> Result { + Err(error::ErrorKind::InvalidSignal(std::format!("{value}")).into()) + } +} + +pub(crate) fn continue_process(_pid: sys::process::ProcessId) -> Result<(), error::Error> { + Err(error::ErrorKind::NotSupportedOnThisPlatform("continuing process").into()) +} + +/// Sends a signal to a specific process. +/// +/// This is a stub implementation that returns an error. +pub fn kill_process( + _pid: sys::process::ProcessId, + _signal: traps::TrapSignal, +) -> Result<(), error::Error> { + Err(error::ErrorKind::NotSupportedOnThisPlatform("killing process").into()) +} + +pub(crate) fn lead_new_process_group() -> Result<(), error::Error> { + Ok(()) +} + +pub(crate) struct FakeSignal {} + +impl FakeSignal { + fn new() -> Self { + Self {} + } + + pub async fn recv(&self) { + futures::future::pending::<()>().await; + } +} + +pub(crate) fn tstp_signal_listener() -> Result { + Ok(FakeSignal::new()) +} + +pub(crate) fn chld_signal_listener() -> Result { + Ok(FakeSignal::new()) +} + +pub(crate) async fn await_ctrl_c() -> std::io::Result<()> { + FakeSignal::new().recv().await; + Ok(()) +} + +pub(crate) fn mask_sigttou() -> Result<(), error::Error> { + Ok(()) +} + +pub(crate) fn poll_for_stopped_children() -> Result { + Ok(false) +} diff --git a/crates/brush-core-vendored/src/sys/stubs/terminal.rs b/crates/brush-core-vendored/src/sys/stubs/terminal.rs new file mode 100644 index 000000000..9b11eda32 --- /dev/null +++ b/crates/brush-core-vendored/src/sys/stubs/terminal.rs @@ -0,0 +1,72 @@ +//! Terminal utilities. + +use crate::{error, sys, terminal}; + +/// Terminal configuration. +#[derive(Clone, Debug)] +pub struct Config; + +#[allow(clippy::unused_self)] +impl Config { + /// Creates a new `Config` from the actual terminal attributes of the terminal associated + /// with the given file descriptor. + /// + /// # Arguments + /// + /// * `_fd` - The file descriptor of the terminal. + pub fn from_term(_fd: Fd) -> Result { + Ok(Self) + } + + /// Applies the terminal settings to the terminal associated with the given file descriptor. + /// + /// # Arguments + /// + /// * `_fd` - The file descriptor of the terminal. + pub fn apply_to_term(&self, _fd: Fd) -> Result<(), error::Error> { + Ok(()) + } + + /// Applies the given high-level terminal settings to this configuration. Does not modify any + /// terminal itself. + /// + /// # Arguments + /// + /// * `_settings` - The high-level terminal settings to apply to this configuration. + pub fn update(&mut self, _settings: &terminal::Settings) {} +} + +/// Get the process ID of this process's parent. +/// +/// This is a stub implementation that returns `None`. +pub fn get_parent_process_id() -> Option { + None +} + +/// Get the process group ID for this process's process group. +/// +/// This is a stub implementation that returns `None`. +pub fn get_process_group_id() -> Option { + None +} + +/// Get the foreground process ID of the attached terminal. +/// +/// This is a stub implementation that returns `None`. +pub fn get_foreground_pid() -> Option { + None +} + +/// Move the specified process to the foreground of the attached terminal. +/// +/// This is a stub implementation that takes no action. +pub fn move_to_foreground(_pid: sys::process::ProcessId) -> Result<(), error::Error> { + Ok(()) +} + +/// Moves the current process to the foreground of the attached terminal. +/// +/// This is a stub implementation that returns `None`. +pub fn move_self_to_foreground() -> Result<(), error::Error> { + Ok(()) +} diff --git a/crates/brush-core-vendored/src/sys/stubs/users.rs b/crates/brush-core-vendored/src/sys/stubs/users.rs new file mode 100644 index 000000000..56965f374 --- /dev/null +++ b/crates/brush-core-vendored/src/sys/stubs/users.rs @@ -0,0 +1,46 @@ +use crate::error; +use std::path::PathBuf; + +pub(crate) fn get_user_home_dir(_username: &str) -> Option { + None +} + +pub(crate) fn get_current_user_home_dir() -> Option { + None +} + +pub(crate) fn is_root() -> bool { + false +} + +pub(crate) fn get_current_uid() -> Result { + Err(error::ErrorKind::NotSupportedOnThisPlatform("getting current uid").into()) +} + +pub(crate) fn get_current_gid() -> Result { + Err(error::ErrorKind::NotSupportedOnThisPlatform("getting current gid").into()) +} + +pub(crate) fn get_effective_uid() -> Result { + Err(error::ErrorKind::NotSupportedOnThisPlatform("getting effective uid").into()) +} + +pub(crate) fn get_effective_gid() -> Result { + Err(error::ErrorKind::NotSupportedOnThisPlatform("getting effective gid").into()) +} + +pub(crate) fn get_current_username() -> Result { + Err(error::ErrorKind::NotSupportedOnThisPlatform("getting current username").into()) +} + +pub(crate) fn get_user_group_ids() -> Result, error::Error> { + Ok(vec![]) +} + +pub(crate) fn get_all_users() -> Result, error::Error> { + Ok(vec![]) +} + +pub(crate) fn get_all_groups() -> Result, error::Error> { + Ok(vec![]) +} diff --git a/crates/brush-core-vendored/src/sys/tokio_process.rs b/crates/brush-core-vendored/src/sys/tokio_process.rs new file mode 100644 index 000000000..3de0e38f6 --- /dev/null +++ b/crates/brush-core-vendored/src/sys/tokio_process.rs @@ -0,0 +1,9 @@ +//! Process management utilities + +pub(crate) type ProcessId = i32; +pub(crate) use tokio::process::Child; + +pub(crate) fn spawn(command: std::process::Command) -> std::io::Result { + let mut command = tokio::process::Command::from(command); + command.spawn() +} diff --git a/crates/brush-core-vendored/src/sys/unix.rs b/crates/brush-core-vendored/src/sys/unix.rs new file mode 100644 index 000000000..2234a8697 --- /dev/null +++ b/crates/brush-core-vendored/src/sys/unix.rs @@ -0,0 +1,25 @@ +pub mod commands; +pub mod fd; +pub mod fs; +pub mod input; +pub(crate) mod network; +use crate::error; +pub use crate::sys::tokio_process as process; +pub mod resource; +pub mod signal; +pub mod terminal; +pub(crate) mod users; + +/// Platform-specific errors. +#[derive(Debug, thiserror::Error)] +pub enum PlatformError { + /// A system error occurred. + #[error("system error: {0}")] + ErrnoError(#[from] nix::errno::Errno), +} + +impl From for error::ErrorKind { + fn from(err: nix::errno::Errno) -> Self { + PlatformError::ErrnoError(err).into() + } +} diff --git a/crates/brush-core-vendored/src/sys/unix/commands.rs b/crates/brush-core-vendored/src/sys/unix/commands.rs new file mode 100644 index 000000000..c284b4e16 --- /dev/null +++ b/crates/brush-core-vendored/src/sys/unix/commands.rs @@ -0,0 +1,66 @@ +//! Command execution utilities. + +pub use std::os::unix::process::CommandExt; +pub use std::os::unix::process::ExitStatusExt; + +use command_fds::{CommandFdExt, FdMapping}; + +use crate::ShellFd; +use crate::error; +use crate::openfiles; + +/// Extension trait for injecting file descriptors into commands. +pub trait CommandFdInjectionExt { + /// Injects the given open files as file descriptors into the command. + /// + /// # Arguments + /// + /// * `open_files` - A mapping of child file descriptors to open files. + fn inject_fds( + &mut self, + open_files: impl Iterator, + ) -> Result<(), error::Error>; +} + +impl CommandFdInjectionExt for std::process::Command { + fn inject_fds( + &mut self, + open_files: impl Iterator, + ) -> Result<(), error::Error> { + let fd_mappings = open_files + .map(|(child_fd, open_file)| FdMapping { + child_fd, + parent_fd: open_file.into_owned_fd().unwrap(), + }) + .collect(); + self.fd_mappings(fd_mappings) + .map_err(|_e| error::ErrorKind::ChildCreationFailure)?; + + Ok(()) + } +} + +/// Extension trait for arranging for commands to take the foreground. +pub trait CommandFgControlExt { + /// Arranges for the command to take the foreground when it is executed. + fn take_foreground(&mut self); +} + +impl CommandFgControlExt for std::process::Command { + fn take_foreground(&mut self) { + // SAFETY: + // This arranges for a provided function to run in the context of + // the forked process before it exec's the target command. In general, + // rust can't guarantee safety of code running in such a context. + unsafe { + self.pre_exec(setup_process_before_exec); + } + } +} + +fn setup_process_before_exec() -> Result<(), std::io::Error> { + use crate::sys; + + sys::terminal::move_self_to_foreground().map_err(std::io::Error::other)?; + Ok(()) +} diff --git a/crates/brush-core-vendored/src/sys/unix/fd.rs b/crates/brush-core-vendored/src/sys/unix/fd.rs new file mode 100644 index 000000000..19033c878 --- /dev/null +++ b/crates/brush-core-vendored/src/sys/unix/fd.rs @@ -0,0 +1,75 @@ +//! File descriptor utilities. + +use std::os::fd::RawFd; + +use crate::{ShellFd, error, openfiles}; + +#[cfg(target_os = "linux")] +const FD_DIR_PATH: &str = "/proc/self/fd"; + +#[cfg(target_os = "macos")] +const FD_DIR_PATH: &str = "/dev/fd"; + +/// Makes a best-effort attempt to iterate over all open file descriptors +/// for the current process. +/// +/// If the platform does not support enumerating file descriptors, an empty iterator +/// is returned. This function will skip any file descriptors that cannot be opened. +pub fn try_iter_open_fds() -> impl Iterator { + let mut opened_entries = vec![]; + + if let Ok(fd_dir) = std::fs::read_dir(FD_DIR_PATH) { + for entry in fd_dir.into_iter().flatten() { + if let Ok(filename) = entry.file_name().into_string() { + if let Ok(fd_num) = filename.parse::() { + // SAFETY: + // We are trying to open the file descriptor we found listed + // in the filesystem, but there's a risk that it's not the same one + // that we enumerated or that it's since been closed. For the purposes + // of this function, either of those outcomes are acceptable. We + // simply skip any fds that we can't open, and the function's purpose + // is to make a best-effort attempt to open all available fds. + if let Ok(file) = unsafe { open_file_by_fd(fd_num) } { + opened_entries.push((fd_num, file)); + } + } + } + } + } + + opened_entries.into_iter() +} + +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +pub fn iter_fds() -> Result, error::Error> { + Ok(std::iter::empty()) +} + +/// Attempts to retrieve an `OpenFile` representation for the given already-open file descriptor. +/// +/// If the file descriptor cannot be opened, `None` is returned. Note that there is no guarantee +/// that the returned file matches the original file descriptor, as the fd may have been closed +/// and potentially re-used in the meantime. +/// +/// # Arguments +/// +/// * `fd` - The file descriptor to open. +pub fn try_get_file_for_open_fd(fd: RawFd) -> Option { + // SAFETY: + // We are trying to open the file descriptor provided by the caller. There's a risk that the fd + // is invalid or has been closed since it was enumerated. For the purposes of this function, + // we simply return None if we can't open it. There's also a risk that the fd has been closed + // and re-used for a different file; again, for the purposes of this function, we accept that + // risk and document it as part of the function's contract. + unsafe { open_file_by_fd(fd).ok() } +} + +unsafe fn open_file_by_fd(fd: RawFd) -> Result { + // SAFETY: We are creating a BorrowedFd from a file descriptor. Callers typically + // enumerate available file descriptors from procfs, devfs, or similar, but there's + // still a risk that the fd has become invalid or closed since then -- or that this + // function gets used incorrectly. + let borrowed_fd = unsafe { std::os::fd::BorrowedFd::borrow_raw(fd) }; + let owned_fd = borrowed_fd.try_clone_to_owned()?; + Ok(std::fs::File::from(owned_fd).into()) +} diff --git a/crates/brush-core-vendored/src/sys/unix/fs.rs b/crates/brush-core-vendored/src/sys/unix/fs.rs new file mode 100644 index 000000000..0632c8228 --- /dev/null +++ b/crates/brush-core-vendored/src/sys/unix/fs.rs @@ -0,0 +1,191 @@ +//! Filesystem utilities. + +use std::os::unix::ffi::OsStringExt; +use std::os::unix::fs::FileTypeExt; +use std::path::Path; + +use crate::error; + +pub use std::os::unix::fs::MetadataExt; + +const DEFAULT_EXECUTABLE_SEARCH_PATHS: &[&str] = &[ + "/usr/local/sbin", + "/usr/local/bin", + "/usr/sbin", + "/usr/bin", + "/sbin", + "/bin", +]; + +const DEFAULT_STANDARD_UTILS_PATHS: &[&str] = + &["/bin", "/usr/bin", "/sbin", "/usr/sbin", "/etc", "/usr/etc"]; + +impl crate::sys::fs::PathExt for Path { + fn readable(&self) -> bool { + nix::unistd::access(self, nix::unistd::AccessFlags::R_OK).is_ok() + } + + fn writable(&self) -> bool { + nix::unistd::access(self, nix::unistd::AccessFlags::W_OK).is_ok() + } + + fn executable(&self) -> bool { + nix::unistd::access(self, nix::unistd::AccessFlags::X_OK).is_ok() + } + + fn exists_and_is_block_device(&self) -> bool { + try_get_file_type(self).is_some_and(|ft| ft.is_block_device()) + } + + fn exists_and_is_char_device(&self) -> bool { + try_get_file_type(self).is_some_and(|ft| ft.is_char_device()) + } + + fn exists_and_is_fifo(&self) -> bool { + try_get_file_type(self).is_some_and(|ft: std::fs::FileType| ft.is_fifo()) + } + + fn exists_and_is_socket(&self) -> bool { + try_get_file_type(self).is_some_and(|ft| ft.is_socket()) + } + + fn exists_and_is_setgid(&self) -> bool { + const S_ISGID: u32 = 0o2000; + let file_mode = try_get_file_mode(self); + file_mode.is_some_and(|mode| mode & S_ISGID != 0) + } + + fn exists_and_is_setuid(&self) -> bool { + const S_ISUID: u32 = 0o4000; + let file_mode = try_get_file_mode(self); + file_mode.is_some_and(|mode| mode & S_ISUID != 0) + } + + fn exists_and_is_sticky_bit(&self) -> bool { + const S_ISVTX: u32 = 0o1000; + let file_mode = try_get_file_mode(self); + file_mode.is_some_and(|mode| mode & S_ISVTX != 0) + } + + fn get_device_and_inode(&self) -> Result<(u64, u64), crate::error::Error> { + let metadata = self.metadata()?; + Ok((metadata.dev(), metadata.ino())) + } +} + +fn try_get_file_type(path: &Path) -> Option { + path.metadata().map(|metadata| metadata.file_type()).ok() +} + +fn try_get_file_mode(path: &Path) -> Option { + path.metadata().map(|metadata| metadata.mode()).ok() +} + +pub(crate) fn get_default_executable_search_paths() -> Vec { + DEFAULT_EXECUTABLE_SEARCH_PATHS + .iter() + .map(|s| (*s).to_owned()) + .collect() +} + +/// Retrieves the platform-specific set of paths that should contain standard system +/// utilities. Used by `command -p`, for example. +pub fn get_default_standard_utils_paths() -> Vec { + // + // Try to call confstr(_CS_PATH). If that fails, can't find a string value, or + // finds an empty string, then we'll fall back to hard-coded defaults. + // + + if let Ok(Some(cs_path)) = confstr_cs_path() { + if !cs_path.is_empty() { + return cs_path.split(':').map(|s| s.to_string()).collect(); + } + } + + DEFAULT_STANDARD_UTILS_PATHS + .iter() + .map(|s| (*s).to_owned()) + .collect() +} + +fn confstr_cs_path() -> Result, std::io::Error> { + let value = confstr(nix::libc::_CS_PATH)?; + + if let Some(value) = value { + let value_str = value + .into_string() + .map_err(|_err| std::io::Error::new(std::io::ErrorKind::InvalidData, "Invalid data"))?; + Ok(Some(value_str)) + } else { + Ok(None) + } +} + +/// A wrapper for [`nix::libc::confstr`]. Returns a value for the default PATH variable which +/// indicates where all the POSIX.2 standard utilities can be found. +/// +/// N.B. We would strongly prefer to use a safe API exposed (in an idiomatic way) by nix +/// or similar. Until that exists, we accept the need to make the unsafe call directly. +fn confstr(name: nix::libc::c_int) -> Result, std::io::Error> { + // SAFETY: + // Calling `confstr` with a null pointer and size 0 is a documented way to query + // the required size of the buffer to hold the value associated with `name`. It + // should not end up causing any undefined behavior. + let required_size = unsafe { nix::libc::confstr(name, std::ptr::null_mut(), 0) }; + + // When confstr returns 0, it either means there's no value associated with _CS_PATH, or + // _CS_PATH is considered invalid (and not present) on this platform. In both cases, we + // treat it as a non-existent value and return None. + if required_size == 0 { + return Ok(None); + } + + let mut buffer = Vec::::with_capacity(required_size); + + // SAFETY: + // We are calling `confstr` with a valid pointer and size that we obtained from the + // allocated buffer. Writing `c_char` (i8 or u8 depending on the platform) into + // `Vec` is fine, as i8 and u8 have compatible representations, and Rust does + // not support platforms where `c_char` is not 8-bit wide. + let final_size = + unsafe { nix::libc::confstr(name, buffer.as_mut_ptr().cast(), buffer.capacity()) }; + + if final_size == 0 { + return Err(std::io::Error::last_os_error()); + } + + // Per the docs on `confstr`, it *may* return a size larger than the provided buffer. + // In our usage we wouldn't expect to see this, as we've first queried the required size. + // However, we defensively check for this case and return an error if it happens. + if final_size > buffer.capacity() { + return Err(std::io::Error::other( + "confstr needed more space than advertised", + )); + } + + // SAFETY: + // We are trusting `confstr` to have written exactly `final_size` bytes into the buffer. + // We have checked above that it didn't return a value *larger* than the capacity of + // the buffer, and also checked for known error cases. Note that the returned length + // should include the null terminator. + unsafe { buffer.set_len(final_size) }; + + // The last byte is a null terminator. We assert that it is. + if !matches!(buffer.pop(), Some(0)) { + return Err(std::io::Error::other( + "confstr did not null-terminate the returned string", + )); + } + + Ok(Some(std::ffi::OsString::from_vec(buffer))) +} + +/// Opens a null file that will discard all I/O. +pub fn open_null_file() -> Result { + let f = std::fs::File::options() + .read(true) + .write(true) + .open("/dev/null")?; + + Ok(f) +} diff --git a/crates/brush-core-vendored/src/sys/unix/input.rs b/crates/brush-core-vendored/src/sys/unix/input.rs new file mode 100644 index 000000000..d0dcfdc12 --- /dev/null +++ b/crates/brush-core-vendored/src/sys/unix/input.rs @@ -0,0 +1,84 @@ +//! Terminal input utilities + +use std::collections::HashMap; +use std::sync::LazyLock; +use terminfo::capability as cap; + +use crate::{error, interfaces}; + +macro_rules! key { + ( $terminfo:expr , $our_key:expr, $terminfo_key:ty ) => {{ + ( + $our_key, + $terminfo + .get::<$terminfo_key>() + .map(|k| k.expand().to_vec()), + ) + }}; +} + +fn build_terminfo_key_map() -> HashMap, interfaces::Key> { + let mut map: HashMap, interfaces::Key> = HashMap::new(); + + if let Ok(ti) = terminfo::Database::from_env() { + // Iterate over key capabilities and populate the map + let key_capabilities = [ + key!(ti, interfaces::Key::F(1), cap::KeyF1<'_>), + key!(ti, interfaces::Key::F(2), cap::KeyF2<'_>), + key!(ti, interfaces::Key::F(3), cap::KeyF3<'_>), + key!(ti, interfaces::Key::F(4), cap::KeyF4<'_>), + key!(ti, interfaces::Key::F(5), cap::KeyF5<'_>), + key!(ti, interfaces::Key::F(6), cap::KeyF6<'_>), + key!(ti, interfaces::Key::F(7), cap::KeyF7<'_>), + key!(ti, interfaces::Key::F(8), cap::KeyF8<'_>), + key!(ti, interfaces::Key::F(9), cap::KeyF9<'_>), + key!(ti, interfaces::Key::F(10), cap::KeyF10<'_>), + key!(ti, interfaces::Key::F(11), cap::KeyF11<'_>), + key!(ti, interfaces::Key::F(12), cap::KeyF12<'_>), + key!(ti, interfaces::Key::Backspace, cap::KeyBackspace<'_>), + key!(ti, interfaces::Key::Enter, cap::KeyEnter<'_>), + key!(ti, interfaces::Key::Left, cap::KeyLeft<'_>), + key!(ti, interfaces::Key::Right, cap::KeyRight<'_>), + key!(ti, interfaces::Key::Up, cap::KeyUp<'_>), + key!(ti, interfaces::Key::Down, cap::KeyDown<'_>), + key!(ti, interfaces::Key::Home, cap::KeyHome<'_>), + key!(ti, interfaces::Key::End, cap::KeyEnd<'_>), + key!(ti, interfaces::Key::PageUp, cap::KeyPPage<'_>), + key!(ti, interfaces::Key::PageDown, cap::KeyNPage<'_>), + key!(ti, interfaces::Key::BackTab, cap::BackTab<'_>), + // It's not clear if these belong here, because they're not + // strictly "key" capabilities. + key!(ti, interfaces::Key::Up, cap::CursorUp<'_>), + key!(ti, interfaces::Key::Down, cap::CursorDown<'_>), + key!(ti, interfaces::Key::Left, cap::CursorLeft<'_>), + key!(ti, interfaces::Key::Right, cap::CursorRight<'_>), + ]; + + for (key, v) in key_capabilities { + if let Some(Ok(v)) = v { + map.insert(v.clone(), key.clone()); + } + } + } + + map +} + +pub(crate) static TERMINFO_KEY_MAP: LazyLock, interfaces::Key>> = + LazyLock::new(build_terminfo_key_map); + +/// Translates a key code (byte sequence) into a `Key` enum value. Returns `None` +/// if the key code is not recognized. +/// +/// # Arguments +/// +/// * `key_code`: The byte sequence representing the key code. +pub fn try_get_key_from_key_code(key_code: &[u8]) -> Option { + if let Some(key) = TERMINFO_KEY_MAP.get(key_code) { + Some(key.clone()) + } else if key_code.len() == 1 && !key_code[0].is_ascii_control() { + Some(interfaces::Key::Character(key_code[0] as char)) + } else { + None + } +} diff --git a/crates/brush-core-vendored/src/sys/unix/network.rs b/crates/brush-core-vendored/src/sys/unix/network.rs new file mode 100644 index 000000000..b2ae829ec --- /dev/null +++ b/crates/brush-core-vendored/src/sys/unix/network.rs @@ -0,0 +1,3 @@ +pub(crate) fn get_hostname() -> std::io::Result { + crate::sys::hostname::get() +} diff --git a/crates/brush-core-vendored/src/sys/unix/resource.rs b/crates/brush-core-vendored/src/sys/unix/resource.rs new file mode 100644 index 000000000..7b9b8a267 --- /dev/null +++ b/crates/brush-core-vendored/src/sys/unix/resource.rs @@ -0,0 +1,31 @@ +//! Resource utilities + +use crate::error; + +/// Returns the user and system CPU time used by the current process; +/// expressed as a tuple containing user time and system time, in that order. +pub fn get_self_user_and_system_time() +-> Result<(std::time::Duration, std::time::Duration), error::Error> { + let usage = nix::sys::resource::getrusage(nix::sys::resource::UsageWho::RUSAGE_SELF)?; + Ok(( + convert_rusage_time(usage.user_time()), + convert_rusage_time(usage.system_time()), + )) +} + +/// Returns the user and system CPU time used by child processes; expressed +/// as a tuple containing user time and system time, in that order. +pub fn get_children_user_and_system_time() +-> Result<(std::time::Duration, std::time::Duration), error::Error> { + let usage = nix::sys::resource::getrusage(nix::sys::resource::UsageWho::RUSAGE_CHILDREN)?; + Ok(( + convert_rusage_time(usage.user_time()), + convert_rusage_time(usage.system_time()), + )) +} + +const fn convert_rusage_time(time: nix::sys::time::TimeVal) -> std::time::Duration { + #[allow(clippy::cast_sign_loss)] + #[allow(clippy::cast_possible_truncation)] + std::time::Duration::new(time.tv_sec() as u64, time.tv_usec() as u32 * 1000) +} diff --git a/crates/brush-core-vendored/src/sys/unix/signal.rs b/crates/brush-core-vendored/src/sys/unix/signal.rs new file mode 100644 index 000000000..881ba598e --- /dev/null +++ b/crates/brush-core-vendored/src/sys/unix/signal.rs @@ -0,0 +1,162 @@ +//! Signal processing utilities + +use crate::{error, sys, traps}; + +pub(crate) use nix::sys::signal::Signal; + +pub(crate) fn continue_process(pid: sys::process::ProcessId) -> Result<(), error::Error> { + nix::sys::signal::kill(nix::unistd::Pid::from_raw(pid), nix::sys::signal::SIGCONT) + .map_err(|_errno| error::ErrorKind::FailedToSendSignal)?; + Ok(()) +} + +/// Sends a signal to a specific process. +/// +/// # Arguments +/// * `pid` - The process ID to send the signal to +/// * `signal` - The signal to send (must be a real signal, not a trap signal) +pub fn kill_process( + pid: sys::process::ProcessId, + signal: traps::TrapSignal, +) -> Result<(), error::Error> { + let translated_signal = match signal { + traps::TrapSignal::Signal(signal) => signal, + traps::TrapSignal::Debug + | traps::TrapSignal::Err + | traps::TrapSignal::Exit + | traps::TrapSignal::Return => { + return Err(error::ErrorKind::InvalidSignal(signal.to_string()).into()); + } + }; + + nix::sys::signal::kill(nix::unistd::Pid::from_raw(pid), translated_signal) + .map_err(|_errno| error::ErrorKind::FailedToSendSignal)?; + + Ok(()) +} + +pub(crate) fn lead_new_process_group() -> Result<(), error::Error> { + nix::unistd::setpgid(nix::unistd::Pid::from_raw(0), nix::unistd::Pid::from_raw(0))?; + Ok(()) +} + +pub(crate) fn tstp_signal_listener() -> Result { + let signal = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::from_raw( + nix::libc::SIGTSTP, + ))?; + Ok(signal) +} + +pub(crate) fn chld_signal_listener() -> Result { + let signal = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::child())?; + Ok(signal) +} + +pub(crate) use tokio::signal::ctrl_c as await_ctrl_c; + +pub(crate) fn mask_sigttou() -> Result<(), error::Error> { + let ignore = nix::sys::signal::SigAction::new( + nix::sys::signal::SigHandler::SigIgn, + nix::sys::signal::SaFlags::empty(), + nix::sys::signal::SigSet::empty(), + ); + + // SAFETY: + // Setting the signal action should be safe here. The unsafe concerns + // for calling `sigaction` are primarily around ensuring that any provided + // signal handler functions are only performing operations that are + // safe to do in a signal handler context. Here we are not providing + // a custom handler, just asking the OS to ignore the signal. + unsafe { nix::sys::signal::sigaction(nix::sys::signal::Signal::SIGTTOU, &ignore) }?; + + Ok(()) +} + +pub(crate) fn poll_for_stopped_children() -> Result { + let mut found_stopped = false; + + loop { + let wait_status = waitid_all( + nix::sys::wait::WaitPidFlag::WUNTRACED | nix::sys::wait::WaitPidFlag::WNOHANG, + ); + match wait_status { + Ok(nix::sys::wait::WaitStatus::Stopped(_stopped_pid, _signal)) => { + found_stopped = true; + } + Ok(_) => break, + Err(nix::errno::Errno::ECHILD) => break, + Err(e) => return Err(e.into()), + } + } + + Ok(found_stopped) +} + +#[cfg(not(target_os = "macos"))] +fn waitid_all( + flags: nix::sys::wait::WaitPidFlag, +) -> Result { + nix::sys::wait::waitid(nix::sys::wait::Id::All, flags) +} + +// +// N.B. These functions were mostly copied from nix::sys::wait (https://github.com/nix-rust/nix, MIT license) +// to enable use of the `waitid` call on macOS. Ideally nix would expose it on macOS and we would +// remove this code. +// + +#[cfg(target_os = "macos")] +fn waitid_all( + flags: nix::sys::wait::WaitPidFlag, +) -> Result { + // SAFETY: + // Code copied from nix::sys::wait implementation of waitid for other platforms. + let siginfo = unsafe { + // Memory is zeroed rather than uninitialized, as not all platforms + // initialize the memory in the StillAlive case + let mut siginfo: nix::libc::siginfo_t = std::mem::zeroed(); + nix::errno::Errno::result(nix::libc::waitid( + nix::libc::P_ALL, + 0, + &raw mut siginfo, + flags.bits(), + ))?; + siginfo + }; + + siginfo_to_wait_status(siginfo) +} + +#[cfg(target_os = "macos")] +fn siginfo_to_wait_status( + siginfo: nix::libc::siginfo_t, +) -> Result { + // SAFETY: + // Code copied from nix::sys::wait implementation of waitid for other platforms. + let si_pid = unsafe { siginfo.si_pid() }; + if si_pid == 0 { + return Ok(nix::sys::wait::WaitStatus::StillAlive); + } + + let pid = nix::unistd::Pid::from_raw(si_pid); + + // SAFETY: + // Code copied from nix::sys::wait implementation of waitid for other platforms. + let si_status = unsafe { siginfo.si_status() }; + + let status = match siginfo.si_code { + nix::libc::CLD_EXITED => nix::sys::wait::WaitStatus::Exited(pid, si_status), + nix::libc::CLD_KILLED | nix::libc::CLD_DUMPED => nix::sys::wait::WaitStatus::Signaled( + pid, + nix::sys::signal::Signal::try_from(si_status)?, + siginfo.si_code == nix::libc::CLD_DUMPED, + ), + nix::libc::CLD_STOPPED => { + nix::sys::wait::WaitStatus::Stopped(pid, nix::sys::signal::Signal::try_from(si_status)?) + } + nix::libc::CLD_CONTINUED => nix::sys::wait::WaitStatus::Continued(pid), + _ => return Err(nix::errno::Errno::EINVAL), + }; + + Ok(status) +} diff --git a/crates/brush-core-vendored/src/sys/unix/terminal.rs b/crates/brush-core-vendored/src/sys/unix/terminal.rs new file mode 100644 index 000000000..7e6f575d4 --- /dev/null +++ b/crates/brush-core-vendored/src/sys/unix/terminal.rs @@ -0,0 +1,110 @@ +//! Terminal utilities. + +use crate::{error, sys, terminal}; +use std::{io::IsTerminal, os::fd::AsFd}; + +/// Terminal configuration. +#[derive(Clone, Debug)] +pub struct Config { + termios: nix::sys::termios::Termios, +} + +impl Config { + /// Creates a new `Config` from the actual terminal attributes of the terminal associated + /// with the given file descriptor. + /// + /// # Arguments + /// + /// * `fd` - The file descriptor of the terminal. + pub fn from_term(fd: impl AsFd) -> Result { + let termios = nix::sys::termios::tcgetattr(fd)?; + Ok(Self { termios }) + } + + /// Applies the terminal settings to the terminal associated with the given file descriptor. + /// + /// # Arguments + /// + /// * `fd` - The file descriptor of the terminal. + pub fn apply_to_term(&self, fd: impl AsFd) -> Result<(), error::Error> { + nix::sys::termios::tcsetattr(fd, nix::sys::termios::SetArg::TCSANOW, &self.termios)?; + Ok(()) + } + + /// Applies the given high-level terminal settings to this configuration. Does not modify any + /// terminal itself. + /// + /// # Arguments + /// + /// * `settings` - The high-level terminal settings to apply to this configuration. + pub fn update(&mut self, settings: &terminal::Settings) { + if let Some(echo_input) = &settings.echo_input { + if *echo_input { + self.termios.local_flags |= nix::sys::termios::LocalFlags::ECHO; + } else { + self.termios.local_flags -= nix::sys::termios::LocalFlags::ECHO; + } + } + + if let Some(line_input) = &settings.line_input { + if *line_input { + self.termios.local_flags |= nix::sys::termios::LocalFlags::ICANON; + } else { + self.termios.local_flags -= nix::sys::termios::LocalFlags::ICANON; + } + } + + if let Some(interrupt_signals) = &settings.interrupt_signals { + if *interrupt_signals { + self.termios.local_flags |= nix::sys::termios::LocalFlags::ISIG; + } else { + self.termios.local_flags -= nix::sys::termios::LocalFlags::ISIG; + } + } + + if let Some(output_nl_as_nlcr) = &settings.output_nl_as_nlcr { + if *output_nl_as_nlcr { + self.termios.output_flags |= + nix::sys::termios::OutputFlags::OPOST | nix::sys::termios::OutputFlags::ONLCR; + } else { + self.termios.output_flags -= nix::sys::termios::OutputFlags::ONLCR; + } + } + } +} + +/// Get the process ID of this process's parent. +pub fn get_parent_process_id() -> Option { + Some(nix::unistd::getppid().as_raw()) +} + +/// Get the process group ID for this process's process group. +pub fn get_process_group_id() -> Option { + Some(nix::unistd::getpgrp().as_raw()) +} + +/// Get the foreground process ID of the attached terminal. +pub fn get_foreground_pid() -> Option { + nix::unistd::tcgetpgrp(std::io::stdin()) + .ok() + .map(|pgid| pgid.as_raw()) +} + +/// Move the specified process to the foreground of the attached terminal. +pub fn move_to_foreground(pid: sys::process::ProcessId) -> Result<(), error::Error> { + nix::unistd::tcsetpgrp(std::io::stdin(), nix::unistd::Pid::from_raw(pid))?; + Ok(()) +} + +/// Moves the current process to the foreground of the attached terminal. +pub fn move_self_to_foreground() -> Result<(), error::Error> { + if std::io::stdin().is_terminal() { + let pgid = nix::unistd::getpgid(None)?; + + // TODO: jobs: This sometimes fails with ENOTTY even though we checked that stdin is a + // terminal. We should investigate why this is happening. + let _ = nix::unistd::tcsetpgrp(std::io::stdin(), pgid); + } + + Ok(()) +} diff --git a/crates/brush-core-vendored/src/sys/unix/users.rs b/crates/brush-core-vendored/src/sys/unix/users.rs new file mode 100644 index 000000000..24955c6cc --- /dev/null +++ b/crates/brush-core-vendored/src/sys/unix/users.rs @@ -0,0 +1,72 @@ +use crate::{error, trace_categories}; +use std::path::PathBuf; + +use uzers::os::unix::UserExt; + +pub(crate) fn is_root() -> bool { + uzers::get_current_uid() == 0 +} + +pub(crate) fn get_user_home_dir(username: &str) -> Option { + if let Some(user_info) = uzers::get_user_by_name(username) { + return Some(user_info.home_dir().to_path_buf()); + } + + None +} + +pub(crate) fn get_current_user_home_dir() -> Option { + if let Some(username) = uzers::get_current_username() { + if let Some(user_info) = uzers::get_user_by_name(&username) { + return Some(user_info.home_dir().to_path_buf()); + } + } + + None +} + +#[expect(clippy::unnecessary_wraps)] +pub(crate) fn get_current_uid() -> Result { + Ok(uzers::get_current_uid()) +} + +#[expect(clippy::unnecessary_wraps)] +pub(crate) fn get_current_gid() -> Result { + Ok(uzers::get_current_gid()) +} + +#[expect(clippy::unnecessary_wraps)] +pub(crate) fn get_effective_uid() -> Result { + Ok(uzers::get_effective_uid()) +} + +#[expect(clippy::unnecessary_wraps)] +pub(crate) fn get_effective_gid() -> Result { + Ok(uzers::get_effective_gid()) +} + +pub(crate) fn get_current_username() -> Result { + let username = uzers::get_current_username().ok_or_else(|| error::ErrorKind::NoCurrentUser)?; + Ok(username.to_string_lossy().to_string()) +} + +pub(crate) fn get_user_group_ids() -> Result, error::Error> { + let username = uzers::get_current_username().ok_or_else(|| error::ErrorKind::NoCurrentUser)?; + let gid = uzers::get_current_gid(); + let groups = uzers::get_user_groups(&username, gid).unwrap_or_default(); + Ok(groups.into_iter().map(|g| g.gid()).collect()) +} + +#[expect(clippy::unnecessary_wraps)] +pub(crate) fn get_all_users() -> Result, error::Error> { + // TODO(#475): uzers::all_users() is available but unsafe + tracing::debug!(target: trace_categories::UNIMPLEMENTED, "get_all_users"); + Ok(vec![]) +} + +#[expect(clippy::unnecessary_wraps)] +pub(crate) fn get_all_groups() -> Result, error::Error> { + // TODO(#475): uzers::all_groups() is available but unsafe + tracing::debug!(target: trace_categories::UNIMPLEMENTED, "get_all_groups"); + Ok(vec![]) +} diff --git a/crates/brush-core-vendored/src/sys/wasm.rs b/crates/brush-core-vendored/src/sys/wasm.rs new file mode 100644 index 000000000..2b4970e59 --- /dev/null +++ b/crates/brush-core-vendored/src/sys/wasm.rs @@ -0,0 +1,15 @@ +pub use crate::sys::stubs::commands; +pub use crate::sys::stubs::fd; +pub use crate::sys::stubs::fs; +pub use crate::sys::stubs::input; +pub(crate) use crate::sys::stubs::network; +pub(crate) use crate::sys::stubs::pipes; +pub use crate::sys::stubs::process; +pub use crate::sys::stubs::resource; +pub use crate::sys::stubs::signal; +pub use crate::sys::stubs::terminal; +pub(crate) use crate::sys::stubs::users; + +/// Platform-specific errors. +#[derive(Debug, thiserror::Error)] +pub enum PlatformError {} diff --git a/crates/brush-core-vendored/src/sys/windows.rs b/crates/brush-core-vendored/src/sys/windows.rs new file mode 100644 index 000000000..d19244079 --- /dev/null +++ b/crates/brush-core-vendored/src/sys/windows.rs @@ -0,0 +1,20 @@ +pub use crate::sys::stubs::commands; +pub use crate::sys::stubs::fd; +pub use crate::sys::stubs::fs; +pub use crate::sys::stubs::input; +pub(crate) mod network; +pub use crate::sys::stubs::resource; + +/// Signal processing utilities +pub mod signal { + pub(crate) use crate::sys::stubs::signal::*; + pub(crate) use tokio::signal::ctrl_c as await_ctrl_c; +} + +pub use crate::sys::stubs::terminal; +pub use crate::sys::tokio_process as process; +pub(crate) mod users; + +/// Platform-specific errors. +#[derive(Debug, thiserror::Error)] +pub enum PlatformError {} diff --git a/crates/brush-core-vendored/src/sys/windows/network.rs b/crates/brush-core-vendored/src/sys/windows/network.rs new file mode 100644 index 000000000..b2ae829ec --- /dev/null +++ b/crates/brush-core-vendored/src/sys/windows/network.rs @@ -0,0 +1,3 @@ +pub(crate) fn get_hostname() -> std::io::Result { + crate::sys::hostname::get() +} diff --git a/crates/brush-core-vendored/src/sys/windows/users.rs b/crates/brush-core-vendored/src/sys/windows/users.rs new file mode 100644 index 000000000..3da297c25 --- /dev/null +++ b/crates/brush-core-vendored/src/sys/windows/users.rs @@ -0,0 +1,60 @@ +#![allow(clippy::missing_const_for_fn)] + +use crate::error; +use std::path::PathBuf; + +// +// Non-Unix implementation +// + +pub(crate) fn get_user_home_dir(username: &str) -> Option { + homedir::home(username).unwrap_or_default() +} + +pub(crate) fn get_current_user_home_dir() -> Option { + homedir::my_home().unwrap_or_default() +} + +pub(crate) fn is_root() -> bool { + // TODO: implement some version of this for Windows + false +} + +pub(crate) fn get_current_uid() -> Result { + Err(error::ErrorKind::NotSupportedOnThisPlatform("getting current uid").into()) +} + +pub(crate) fn get_current_gid() -> Result { + Err(error::ErrorKind::NotSupportedOnThisPlatform("getting current gid").into()) +} + +pub(crate) fn get_effective_uid() -> Result { + Err(error::ErrorKind::NotSupportedOnThisPlatform("getting effective uid").into()) +} + +pub(crate) fn get_effective_gid() -> Result { + Err(error::ErrorKind::NotSupportedOnThisPlatform("getting effective gid").into()) +} + +pub(crate) fn get_current_username() -> Result { + let username = whoami::fallible::username()?; + Ok(username) +} + +#[allow(clippy::unnecessary_wraps)] +pub(crate) fn get_user_group_ids() -> Result, error::Error> { + // TODO: implement some version of this for Windows + Ok(vec![]) +} + +#[expect(clippy::unnecessary_wraps)] +pub(crate) fn get_all_users() -> Result, error::Error> { + // TODO: implement some version of this for Windows + Ok(vec![]) +} + +#[expect(clippy::unnecessary_wraps)] +pub(crate) fn get_all_groups() -> Result, error::Error> { + // TODO: implement some version of this for Windows + Ok(vec![]) +} diff --git a/crates/brush-core-vendored/src/terminal.rs b/crates/brush-core-vendored/src/terminal.rs new file mode 100644 index 000000000..438ca29f4 --- /dev/null +++ b/crates/brush-core-vendored/src/terminal.rs @@ -0,0 +1,92 @@ +//! Terminal control utilities. + +use crate::{error, openfiles, sys}; + +/// Encapsulates the state of a controlled terminal. +pub struct TerminalControl { + prev_fg_pid: Option, +} + +impl TerminalControl { + /// Acquire the terminal for the shell. + pub fn acquire() -> Result { + let prev_fg_pid = sys::terminal::get_foreground_pid(); + + // Break out into new process group. + // TODO: jobs: Investigate why this sometimes fails with EPERM. + let _ = sys::signal::lead_new_process_group(); + + // Take ownership. + sys::terminal::move_self_to_foreground()?; + + // Mask out SIGTTOU. + sys::signal::mask_sigttou()?; + + Ok(Self { prev_fg_pid }) + } + + fn try_release(&mut self) { + // Restore the previous foreground process group. + if let Some(pid) = self.prev_fg_pid { + if sys::terminal::move_to_foreground(pid).is_ok() { + self.prev_fg_pid = None; + } + } + } +} + +impl Drop for TerminalControl { + fn drop(&mut self) { + self.try_release(); + } +} + +/// Describes high-level terminal settings that can be requested. +#[derive(Default, bon::Builder)] +pub struct Settings { + /// Whether to enable input echoing. + pub echo_input: Option, + /// Whether to enable line input (sometimes known as canonical mode). + pub line_input: Option, + /// Whether to disable interrupt signals and instead yield the control characters. + pub interrupt_signals: Option, + /// Whether to output newline characters as CRLF pairs. + pub output_nl_as_nlcr: Option, +} + +/// Guard that automatically restores terminal settings on drop. +pub struct AutoModeGuard { + initial: sys::terminal::Config, + file: openfiles::OpenFile, +} + +impl AutoModeGuard { + /// Creates a new `AutoModeGuard` for the given file. + /// + /// # Arguments + /// + /// * `file` - The file representing the terminal to control. + pub fn new(file: openfiles::OpenFile) -> Result { + let initial = sys::terminal::Config::from_term(&file)?; + Ok(Self { initial, file }) + } + + /// Applies the given terminal settings. + /// + /// # Arguments + /// + /// * `settings` - The terminal settings to apply. + pub fn apply_settings(&self, settings: &Settings) -> Result<(), error::Error> { + let mut config = sys::terminal::Config::from_term(&self.file)?; + config.update(settings); + config.apply_to_term(&self.file)?; + + Ok(()) + } +} + +impl Drop for AutoModeGuard { + fn drop(&mut self) { + let _ = self.initial.apply_to_term(&self.file); + } +} diff --git a/crates/brush-core-vendored/src/tests.rs b/crates/brush-core-vendored/src/tests.rs new file mode 100644 index 000000000..f375987c6 --- /dev/null +++ b/crates/brush-core-vendored/src/tests.rs @@ -0,0 +1,37 @@ +//! Shell test conditional expressions + +use crate::{ExecutionParameters, Shell, error, extendedtests}; + +/// Evaluate the given test expression within the provided shell and +/// execution context. Returns true if the expression evaluates to true, +/// false otherwise. +/// +/// # Arguments +/// +/// * `expr` - The test expression to evaluate. +/// * `shell` - The shell context in which to evaluate the expression. +/// * `params` - The execution parameters to use during evaluation. +pub fn eval_expr( + expr: &brush_parser::ast::TestExpr, + shell: &mut Shell, + params: &ExecutionParameters, +) -> Result { + match expr { + brush_parser::ast::TestExpr::False => Ok(false), + brush_parser::ast::TestExpr::Literal(s) => Ok(!s.is_empty()), + brush_parser::ast::TestExpr::And(left, right) => { + Ok(eval_expr(left, shell, params)? && eval_expr(right, shell, params)?) + } + brush_parser::ast::TestExpr::Or(left, right) => { + Ok(eval_expr(left, shell, params)? || eval_expr(right, shell, params)?) + } + brush_parser::ast::TestExpr::Not(expr) => Ok(!eval_expr(expr, shell, params)?), + brush_parser::ast::TestExpr::Parenthesized(expr) => eval_expr(expr, shell, params), + brush_parser::ast::TestExpr::UnaryTest(op, operand) => { + extendedtests::apply_unary_predicate_to_str(op, operand, shell, params) + } + brush_parser::ast::TestExpr::BinaryTest(op, left, right) => { + extendedtests::apply_binary_predicate_to_strs(op, left.as_str(), right.as_str(), shell) + } + } +} diff --git a/crates/brush-core-vendored/src/timing.rs b/crates/brush-core-vendored/src/timing.rs new file mode 100644 index 000000000..bee450176 --- /dev/null +++ b/crates/brush-core-vendored/src/timing.rs @@ -0,0 +1,137 @@ +//! Command timing + +use crate::error; + +struct StopwatchTime { + now: std::time::SystemTime, + self_user: std::time::Duration, + self_system: std::time::Duration, + children_user: std::time::Duration, + children_system: std::time::Duration, +} + +impl StopwatchTime { + #[allow(clippy::unchecked_time_subtraction)] + fn minus(&self, other: &Self) -> Result { + let user = (self.self_user - other.self_user) + (self.children_user - other.children_user); + let system = + (self.self_system - other.self_system) + (self.children_system - other.children_system); + + Ok(StopwatchTiming { + wall: self.now.duration_since(other.now)?, + user, + system, + }) + } +} + +pub(crate) struct Stopwatch { + start: StopwatchTime, +} + +impl Stopwatch { + pub fn stop(&self) -> Result { + let end = get_current_stopwatch_time()?; + end.minus(&self.start) + } +} +pub(crate) struct StopwatchTiming { + pub wall: std::time::Duration, + pub user: std::time::Duration, + pub system: std::time::Duration, +} + +pub(crate) fn start_timing() -> Result { + Ok(Stopwatch { + start: get_current_stopwatch_time()?, + }) +} + +fn get_current_stopwatch_time() -> Result { + let now = std::time::SystemTime::now(); + let (self_user, self_system) = crate::sys::resource::get_self_user_and_system_time()?; + let (children_user, children_system) = + crate::sys::resource::get_children_user_and_system_time()?; + + Ok(StopwatchTime { + now, + self_user, + self_system, + children_user, + children_system, + }) +} + +/// Format the given duration in a non-POSIX-y way. +/// +/// # Arguments +/// +/// * `duration` - The duration to format. +pub fn format_duration_non_posixly(duration: &std::time::Duration) -> String { + let minutes = duration.as_secs() / 60; + let seconds = duration.as_secs() % 60; + let millis = duration.subsec_millis(); + format!("{minutes}m{seconds}.{millis:03}s") +} + +/// Format the given duration in a POSIX-y way. +/// +/// # Arguments +/// +/// * `duration` - The duration to format. +pub fn format_duration_posixly(duration: &std::time::Duration) -> String { + let seconds = duration.as_secs(); + let ten_millis = duration.subsec_millis() / 10; + format!("{seconds}.{ten_millis:02}") +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + #[test] + fn test_format_time() { + assert_eq!( + format_duration_non_posixly(&Duration::from_millis(0)), + "0m0.000s" + ); + assert_eq!( + format_duration_non_posixly(&Duration::from_millis(1)), + "0m0.001s" + ); + assert_eq!( + format_duration_non_posixly(&Duration::from_millis(123)), + "0m0.123s" + ); + assert_eq!( + format_duration_non_posixly(&Duration::from_millis(1234)), + "0m1.234s" + ); + assert_eq!( + format_duration_non_posixly(&Duration::from_millis(12345)), + "0m12.345s" + ); + assert_eq!( + format_duration_non_posixly(&Duration::from_millis(123_456)), + "2m3.456s" + ); + assert_eq!( + format_duration_non_posixly(&Duration::from_millis(1_234_567)), + "20m34.567s" + ); + + assert_eq!( + format_duration_non_posixly(&Duration::from_micros(1)), + "0m0.000s" + ); + assert_eq!( + format_duration_non_posixly(&Duration::from_micros(999)), + "0m0.000s" + ); + assert_eq!( + format_duration_non_posixly(&Duration::from_micros(1000)), + "0m0.001s" + ); + } +} diff --git a/crates/brush-core-vendored/src/trace_categories.rs b/crates/brush-core-vendored/src/trace_categories.rs new file mode 100644 index 000000000..a3106993c --- /dev/null +++ b/crates/brush-core-vendored/src/trace_categories.rs @@ -0,0 +1,20 @@ +//! Trace utilities + +/// Trace category for command execution. +pub const COMMANDS: &str = "commands"; +/// Trace category for completion. +pub const COMPLETION: &str = "completion"; +/// Trace category for word expansion. +pub const EXPANSION: &str = "expansion"; +/// Trace category for function calls. +pub const FUNCTIONS: &str = "functions"; +/// Trace category for user input. +pub const INPUT: &str = "input"; +/// Trace category for job control. +pub const JOBS: &str = "jobs"; +/// Trace category for parsing. +pub const PARSE: &str = "parse"; +/// Trace category for shell patterns. +pub const PATTERN: &str = "pattern"; +/// Trace category for unimplemented behavior. +pub const UNIMPLEMENTED: &str = "unimplemented"; diff --git a/crates/brush-core-vendored/src/traps.rs b/crates/brush-core-vendored/src/traps.rs new file mode 100644 index 000000000..1b52bdd87 --- /dev/null +++ b/crates/brush-core-vendored/src/traps.rs @@ -0,0 +1,188 @@ +//! Facilities for configuring trap handlers. + +use std::str::FromStr; +use std::{collections::HashMap, fmt::Display}; + +use itertools::Itertools as _; + +use crate::{error, sys}; + +/// Type of signal that can be trapped in the shell. +#[derive(Clone, Copy, Eq, Hash, PartialEq)] +pub enum TrapSignal { + /// A system signal. + Signal(sys::signal::Signal), + /// The `DEBUG` trap. + Debug, + /// The `ERR` trap. + Err, + /// The `EXIT` trap. + Exit, + /// The `RETURN` trp. + Return, +} + +impl Display for TrapSignal { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +impl TrapSignal { + /// Returns all possible values of [`TrapSignal`]. + pub fn iterator() -> impl Iterator { + const SIGNALS: &[TrapSignal] = &[TrapSignal::Debug, TrapSignal::Err, TrapSignal::Exit]; + + let iter = itertools::chain!( + SIGNALS.iter().copied(), + sys::signal::Signal::iterator().map(TrapSignal::Signal) + ); + + iter + } + + /// Converts [`TrapSignal`] into its corresponding signal name as a [`&'static str`](str) + pub const fn as_str(self) -> &'static str { + match self { + Self::Signal(s) => s.as_str(), + Self::Debug => "DEBUG", + Self::Err => "ERR", + Self::Exit => "EXIT", + Self::Return => "RETURN", + } + } +} + +/// Formats [`Iterator`](TrapSignal) to the provided writer. +/// +/// # Arguments +/// +/// * `f` - Any type that implements [`std::io::Write`]. +/// * `it` - An iterator over the signals that will be formatted into the `f`. +pub fn format_signals( + mut f: impl std::io::Write, + it: impl Iterator, +) -> Result<(), error::Error> { + let it = it + .filter_map(|s| i32::try_from(s).ok().map(|n| (s, n))) + .sorted_by(|a, b| Ord::cmp(&a.1, &b.1)) + .format_with("\n", |s, f| f(&format_args!("{}) {}", s.1, s.0))); + write!(f, "{it}")?; + Ok(()) +} + +// implement s.parse::() +impl FromStr for TrapSignal { + type Err = error::Error; + fn from_str(s: &str) -> Result::Err> { + if let Ok(n) = s.parse::() { + Self::try_from(n) + } else { + Self::try_from(s) + } + } +} + +// from a signal number +impl TryFrom for TrapSignal { + type Error = error::Error; + fn try_from(value: i32) -> Result { + // NOTE: DEBUG and ERR are real-time signals, defined based on NSIG or SIGRTMAX (is not + // available on bsd-like systems), + // and don't have persistent numbers across platforms, so we skip them here. + Ok(match value { + 0 => Self::Exit, + value => Self::Signal( + sys::signal::Signal::try_from(value) + .map_err(|_| error::ErrorKind::InvalidSignal(value.to_string()))?, + ), + }) + } +} + +// from a signal name +impl TryFrom<&str> for TrapSignal { + type Error = error::Error; + fn try_from(value: &str) -> Result { + #[allow(unused_mut, reason = "only mutated on some platforms")] + let mut s = value.to_ascii_uppercase(); + + Ok(match s.as_str() { + "DEBUG" => Self::Debug, + "ERR" => Self::Err, + "EXIT" => Self::Exit, + "RETURN" => Self::Return, + _ => { + // Bash compatibility: + // support for signal names without the `SIG` prefix, for example `HUP` -> `SIGHUP` + if !s.starts_with("SIG") { + s.insert_str(0, "SIG"); + } + sys::signal::Signal::from_str(s.as_str()) + .map(TrapSignal::Signal) + .map_err(|_| error::ErrorKind::InvalidSignal(value.into()))? + } + }) + } +} + +/// Error type used when failing to convert a `TrapSignal` to a number. +#[derive(Debug, Clone, Copy)] +pub struct TrapSignalNumberError; + +impl TryFrom for i32 { + type Error = TrapSignalNumberError; + fn try_from(value: TrapSignal) -> Result { + Ok(match value { + TrapSignal::Signal(s) => s as Self, + TrapSignal::Exit => 0, + _ => return Err(TrapSignalNumberError), + }) + } +} + +/// Configuration for trap handlers in the shell. +#[derive(Clone, Default)] +pub struct TrapHandlerConfig { + /// Registered handlers for traps; maps signal type to command. + pub(crate) handlers: HashMap, + /// Current depth of the handler stack. + pub(crate) handler_depth: i32, +} + +impl TrapHandlerConfig { + /// Iterates over the registered handlers for trap signals. + pub fn iter_handlers(&self) -> impl Iterator { + self.handlers + .iter() + .map(|(signal, cmd)| (*signal, cmd.as_str())) + } + + /// Tries to find the handler associated with the given signal. + /// + /// # Arguments + /// + /// * `signal_type` - The type of signal to get the handler for. + pub fn get_handler(&self, signal_type: TrapSignal) -> Option<&str> { + self.handlers.get(&signal_type).map(|s| s.as_str()) + } + + /// Registers a handler for a trap signal. + /// + /// # Arguments + /// + /// * `signal_type` - The type of signal to register a handler for. + /// * `command` - The command to execute when the signal is trapped. + pub fn register_handler(&mut self, signal_type: TrapSignal, command: String) { + let _ = self.handlers.insert(signal_type, command); + } + + /// Removes handlers for a trap signal. + /// + /// # Arguments + /// + /// * `signal_type` - The type of signal to remove handlers for. + pub fn remove_handlers(&mut self, signal_type: TrapSignal) { + self.handlers.remove(&signal_type); + } +} diff --git a/crates/brush-core-vendored/src/variables.rs b/crates/brush-core-vendored/src/variables.rs new file mode 100644 index 000000000..57031fdef --- /dev/null +++ b/crates/brush-core-vendored/src/variables.rs @@ -0,0 +1,1033 @@ +//! Implements variables for a shell environment. + +use std::borrow::Cow; +use std::collections::BTreeMap; +use std::fmt::{Display, Write}; + +use crate::shell::Shell; +use crate::{error, escape}; + +/// A shell variable. +#[derive(Clone, Debug)] +pub struct ShellVariable { + /// The value currently associated with the variable. + value: ShellValue, + /// Whether or not the variable is marked as exported to child processes. + exported: bool, + /// Whether or not the variable is marked as read-only. + readonly: bool, + /// Whether or not the variable should be enumerated in the shell's environment. + enumerable: bool, + /// The transformation to apply to the variable's value when it is updated. + transform_on_update: ShellVariableUpdateTransform, + /// Whether or not the variable is marked as being traced. + trace: bool, + /// Whether or not the variable should be treated as an integer. + treat_as_integer: bool, + /// Whether or not the variable should be treated as a name reference. + treat_as_nameref: bool, +} + +/// Kind of transformation to apply to a variable's value when it is updated. +#[derive(Clone, Copy, Debug)] +pub enum ShellVariableUpdateTransform { + /// No transformation. + None, + /// Convert the value to lowercase. + Lowercase, + /// Convert the value to uppercase. + Uppercase, + /// Convert the value to lowercase, with the first character capitalized. + Capitalize, +} + +impl Default for ShellVariable { + fn default() -> Self { + Self { + value: ShellValue::String(String::new()), + exported: false, + readonly: false, + enumerable: true, + transform_on_update: ShellVariableUpdateTransform::None, + trace: false, + treat_as_integer: false, + treat_as_nameref: false, + } + } +} + +impl ShellVariable { + /// Returns a new shell variable, initialized with the given value. + /// + /// # Arguments + /// + /// * `value` - The value to associate with the variable. + pub fn new>(value: I) -> Self { + Self { + value: value.into(), + ..Self::default() + } + } + + /// Returns the value associated with the variable. + pub const fn value(&self) -> &ShellValue { + &self.value + } + + /// Returns whether or not the variable is exported to child processes. + pub const fn is_exported(&self) -> bool { + self.exported + } + + /// Marks the variable as exported to child processes. + pub const fn export(&mut self) -> &mut Self { + self.exported = true; + self + } + + /// Marks the variable as not exported to child processes. + pub const fn unexport(&mut self) -> &mut Self { + self.exported = false; + self + } + + /// Returns whether or not the variable is read-only. + pub const fn is_readonly(&self) -> bool { + self.readonly + } + + /// Marks the variable as read-only. + pub const fn set_readonly(&mut self) -> &mut Self { + self.readonly = true; + self + } + + /// Marks the variable as not read-only. + pub fn unset_readonly(&mut self) -> Result<&mut Self, error::Error> { + if self.readonly { + return Err(error::ErrorKind::ReadonlyVariable.into()); + } + + self.readonly = false; + Ok(self) + } + + /// Returns whether or not the variable is traced. + pub const fn is_trace_enabled(&self) -> bool { + self.trace + } + + /// Marks the variable as traced. + pub const fn enable_trace(&mut self) -> &mut Self { + self.trace = true; + self + } + + /// Marks the variable as not traced. + pub const fn disable_trace(&mut self) -> &mut Self { + self.trace = false; + self + } + + /// Returns whether or not the variable should be enumerated in the shell's environment. + pub const fn is_enumerable(&self) -> bool { + self.enumerable + } + + /// Marks the variable as not enumerable in the shell's environment. + pub const fn hide_from_enumeration(&mut self) -> &mut Self { + self.enumerable = false; + self + } + + /// Return the update transform associated with the variable. + pub const fn get_update_transform(&self) -> ShellVariableUpdateTransform { + self.transform_on_update + } + + /// Set the update transform associated with the variable. + pub const fn set_update_transform(&mut self, transform: ShellVariableUpdateTransform) { + self.transform_on_update = transform; + } + + /// Returns whether or not the variable should be treated as an integer. + pub const fn is_treated_as_integer(&self) -> bool { + self.treat_as_integer + } + + /// Marks the variable as being treated as an integer. + pub const fn treat_as_integer(&mut self) -> &mut Self { + self.treat_as_integer = true; + self + } + + /// Marks the variable as not being treated as an integer. + pub const fn unset_treat_as_integer(&mut self) -> &mut Self { + self.treat_as_integer = false; + self + } + + /// Returns whether or not the variable should be treated as a name reference. + pub const fn is_treated_as_nameref(&self) -> bool { + self.treat_as_nameref + } + + /// Marks the variable as being treated as a name reference. + pub const fn treat_as_nameref(&mut self) -> &mut Self { + self.treat_as_nameref = true; + self + } + + /// Marks the variable as not being treated as a name reference. + pub const fn unset_treat_as_nameref(&mut self) -> &mut Self { + self.treat_as_nameref = false; + self + } + + /// Converts the variable to an indexed array. + pub fn convert_to_indexed_array(&mut self) -> Result<(), error::Error> { + match self.value() { + ShellValue::IndexedArray(_) => Ok(()), + ShellValue::AssociativeArray(_) => { + Err(error::ErrorKind::ConvertingAssociativeArrayToIndexedArray.into()) + } + _ => { + let mut new_values = BTreeMap::new(); + new_values.insert( + 0, + self.value.to_cow_str_without_dynamic_support().to_string(), + ); + self.value = ShellValue::IndexedArray(new_values); + Ok(()) + } + } + } + + /// Converts the variable to an associative array. + pub fn convert_to_associative_array(&mut self) -> Result<(), error::Error> { + match self.value() { + ShellValue::AssociativeArray(_) => Ok(()), + ShellValue::IndexedArray(_) => { + Err(error::ErrorKind::ConvertingIndexedArrayToAssociativeArray.into()) + } + _ => { + let mut new_values: BTreeMap = BTreeMap::new(); + new_values.insert( + String::from("0"), + self.value.to_cow_str_without_dynamic_support().to_string(), + ); + self.value = ShellValue::AssociativeArray(new_values); + Ok(()) + } + } + } + + /// Assign the given value to the variable, conditionally appending to the preexisting value. + /// + /// # Arguments + /// + /// * `value` - The value to assign to the variable. + /// * `append` - Whether or not to append the value to the preexisting value. + #[expect(clippy::too_many_lines)] + pub fn assign(&mut self, value: ShellValueLiteral, append: bool) -> Result<(), error::Error> { + if self.is_readonly() { + return Err(error::ErrorKind::ReadonlyVariable.into()); + } + + let value = self.convert_value_literal_for_assignment(value); + + if append { + match (&self.value, &value) { + // If we're appending an array to a declared-but-unset variable (or appending + // anything to a declared-but-unset array), then fill it out first. + (ShellValue::Unset(_), ShellValueLiteral::Array(_)) + | ( + ShellValue::Unset( + ShellValueUnsetType::IndexedArray | ShellValueUnsetType::AssociativeArray, + ), + _, + ) => { + self.assign(ShellValueLiteral::Array(ArrayLiteral(vec![])), false)?; + } + // If we're appending a scalar to a declared-but-unset variable, then + // start with the empty string. This will result in the right thing happening, + // even in treat-as-integer cases. + (ShellValue::Unset(_), ShellValueLiteral::Scalar(_)) => { + self.assign(ShellValueLiteral::Scalar(String::new()), false)?; + } + // If we're trying to append an array to a string, we first promote the string to be + // an array with the string being present at index 0. + (ShellValue::String(_), ShellValueLiteral::Array(_)) => { + self.convert_to_indexed_array()?; + } + _ => (), + } + + let treat_as_int = self.is_treated_as_integer(); + let update_transform = self.get_update_transform(); + + match &mut self.value { + ShellValue::String(base) => match value { + ShellValueLiteral::Scalar(suffix) => { + if treat_as_int { + let int_value = base.parse::().unwrap_or(0) + + suffix.parse::().unwrap_or(0); + base.clear(); + base.push_str(int_value.to_string().as_str()); + } else { + base.push_str(suffix.as_str()); + Self::apply_value_transforms(base, treat_as_int, update_transform); + } + Ok(()) + } + ShellValueLiteral::Array(_) => { + // This case was already handled (see above). + Ok(()) + } + }, + ShellValue::IndexedArray(existing_values) => match value { + ShellValueLiteral::Scalar(new_value) => { + self.assign_at_index(String::from("0"), new_value, append) + } + ShellValueLiteral::Array(new_values) => { + ShellValue::update_indexed_array_from_literals(existing_values, new_values); + Ok(()) + } + }, + ShellValue::AssociativeArray(existing_values) => match value { + ShellValueLiteral::Scalar(new_value) => { + self.assign_at_index(String::from("0"), new_value, append) + } + ShellValueLiteral::Array(new_values) => { + ShellValue::update_associative_array_from_literals( + existing_values, + new_values, + ) + } + }, + ShellValue::Unset(_) => unreachable!("covered in conversion above"), + // TODO(dynamic): implement appending to dynamic vars + ShellValue::Dynamic { .. } => Ok(()), + } + } else { + match (&self.value, value) { + // If we're updating an array value with a string, then treat it as an update to + // just the "0"-indexed element of the array. + ( + ShellValue::IndexedArray(_) + | ShellValue::AssociativeArray(_) + | ShellValue::Unset( + ShellValueUnsetType::AssociativeArray | ShellValueUnsetType::IndexedArray, + ), + ShellValueLiteral::Scalar(s), + ) => self.assign_at_index(String::from("0"), s, false), + + // If we're updating an indexed array value with an array, then preserve the array + // type. We also default to using an indexed array if we are + // assigning an array to a previously string-holding variable. + ( + ShellValue::IndexedArray(_) + | ShellValue::Unset( + ShellValueUnsetType::IndexedArray | ShellValueUnsetType::Untyped, + ) + | ShellValue::String(_) + | ShellValue::Dynamic { .. }, + ShellValueLiteral::Array(literal_values), + ) => { + self.value = ShellValue::indexed_array_from_literals(literal_values); + Ok(()) + } + + // If we're updating an associative array value with an array, then preserve the + // array type. + ( + ShellValue::AssociativeArray(_) + | ShellValue::Unset(ShellValueUnsetType::AssociativeArray), + ShellValueLiteral::Array(literal_values), + ) => { + self.value = ShellValue::associative_array_from_literals(literal_values)?; + Ok(()) + } + + // Handle updates to dynamic values; for now we just drop them. + // TODO(dynamic): Allow updates to dynamic values + (ShellValue::Dynamic { .. }, _) => Ok(()), + + // Assign a scalar value to a scalar or unset (and untyped) variable. + (ShellValue::String(_) | ShellValue::Unset(_), ShellValueLiteral::Scalar(s)) => { + self.value = ShellValue::String(s); + Ok(()) + } + } + } + } + + /// Assign the given value to the variable at the given index, conditionally appending to the + /// preexisting value present at that element within the value. + /// + /// # Arguments + /// + /// * `array_index` - The index at which to assign the value. + /// * `value` - The value to assign to the variable at the given index. + /// * `append` - Whether or not to append the value to the preexisting value stored at the given + /// index. + pub fn assign_at_index( + &mut self, + array_index: String, + value: String, + append: bool, + ) -> Result<(), error::Error> { + match &self.value { + ShellValue::Unset(_) => { + self.assign(ShellValueLiteral::Array(ArrayLiteral(vec![])), false)?; + } + ShellValue::String(_) => { + self.convert_to_indexed_array()?; + } + _ => (), + } + + let treat_as_int = self.is_treated_as_integer(); + let value = self.convert_value_str_for_assignment(value); + + match &mut self.value { + ShellValue::IndexedArray(arr) => { + let key: u64 = array_index.parse().unwrap_or(0); + + if append { + let existing_value = arr.get(&key).map_or_else(|| "", |v| v.as_str()); + + let mut new_value; + if treat_as_int { + new_value = (existing_value.parse::().unwrap_or(0) + + value.parse::().unwrap_or(0)) + .to_string(); + } else { + new_value = existing_value.to_owned(); + new_value.push_str(value.as_str()); + } + + arr.insert(key, new_value); + } else { + arr.insert(key, value); + } + + Ok(()) + } + ShellValue::AssociativeArray(arr) => { + if append { + let existing_value = arr + .get(array_index.as_str()) + .map_or_else(|| "", |v| v.as_str()); + + let mut new_value; + if treat_as_int { + new_value = (existing_value.parse::().unwrap_or(0) + + value.parse::().unwrap_or(0)) + .to_string(); + } else { + new_value = existing_value.to_owned(); + new_value.push_str(value.as_str()); + } + + arr.insert(array_index, new_value.clone()); + } else { + arr.insert(array_index, value); + } + Ok(()) + } + _ => { + tracing::error!("assigning to index {array_index} of {:?}", self.value); + error::unimp("assigning to index of non-array variable") + } + } + } + + fn convert_value_literal_for_assignment(&self, value: ShellValueLiteral) -> ShellValueLiteral { + match value { + ShellValueLiteral::Scalar(s) => { + ShellValueLiteral::Scalar(self.convert_value_str_for_assignment(s)) + } + ShellValueLiteral::Array(literals) => ShellValueLiteral::Array(ArrayLiteral( + literals + .0 + .into_iter() + .map(|(k, v)| (k, self.convert_value_str_for_assignment(v))) + .collect(), + )), + } + } + + fn convert_value_str_for_assignment(&self, mut s: String) -> String { + Self::apply_value_transforms( + &mut s, + self.is_treated_as_integer(), + self.get_update_transform(), + ); + + s + } + + fn apply_value_transforms( + s: &mut String, + treat_as_int: bool, + update_transform: ShellVariableUpdateTransform, + ) { + if treat_as_int { + *s = (*s).parse::().unwrap_or(0).to_string(); + } else { + match update_transform { + ShellVariableUpdateTransform::None => (), + ShellVariableUpdateTransform::Lowercase => *s = (*s).to_lowercase(), + ShellVariableUpdateTransform::Uppercase => *s = (*s).to_uppercase(), + ShellVariableUpdateTransform::Capitalize => { + // This isn't really title-case; only the first character is capitalized. + *s = s.to_lowercase(); + if let Some(c) = s.chars().next() { + s.replace_range(0..1, &c.to_uppercase().to_string()); + } + } + } + } + } + + /// Tries to unset the value stored at the given index in the variable. Returns + /// whether or not a value was unset. + /// + /// # Arguments + /// + /// * `index` - The index at which to unset the value. + pub fn unset_index(&mut self, index: &str) -> Result { + match &mut self.value { + ShellValue::Unset(ty) => match ty { + ShellValueUnsetType::Untyped => Err(error::ErrorKind::NotArray.into()), + ShellValueUnsetType::AssociativeArray | ShellValueUnsetType::IndexedArray => { + Ok(false) + } + }, + ShellValue::String(_) => Err(error::ErrorKind::NotArray.into()), + ShellValue::AssociativeArray(values) => Ok(values.remove(index).is_some()), + ShellValue::IndexedArray(values) => { + let key = index.parse::().unwrap_or(0); + Ok(values.remove(&key).is_some()) + } + ShellValue::Dynamic { .. } => Ok(false), + } + } + + /// Returns the variable's value; for dynamic values, this will resolve the value. + /// + /// # Arguments + /// + /// * `shell` - The shell in which the variable is being resolved. + pub fn resolve_value(&self, shell: &Shell) -> ShellValue { + // N.B. We do *not* specially handle a dynamic value that resolves to a dynamic value. + match &self.value { + ShellValue::Dynamic { getter, .. } => getter(shell), + _ => self.value.clone(), + } + } + + /// Returns the canonical attribute flag string for this variable. + pub fn attribute_flags(&self, shell: &Shell) -> String { + let value = self.resolve_value(shell); + + let mut result = String::new(); + + if matches!( + value, + ShellValue::IndexedArray(_) | ShellValue::Unset(ShellValueUnsetType::IndexedArray) + ) { + result.push('a'); + } + if matches!( + value, + ShellValue::AssociativeArray(_) + | ShellValue::Unset(ShellValueUnsetType::AssociativeArray) + ) { + result.push('A'); + } + if matches!( + self.get_update_transform(), + ShellVariableUpdateTransform::Capitalize + ) { + result.push('c'); + } + if self.is_treated_as_integer() { + result.push('i'); + } + if self.is_treated_as_nameref() { + result.push('n'); + } + if self.is_readonly() { + result.push('r'); + } + if matches!( + self.get_update_transform(), + ShellVariableUpdateTransform::Lowercase + ) { + result.push('l'); + } + if self.is_trace_enabled() { + result.push('t'); + } + if matches!( + self.get_update_transform(), + ShellVariableUpdateTransform::Uppercase + ) { + result.push('u'); + } + if self.is_exported() { + result.push('x'); + } + + result + } +} + +type DynamicValueGetter = fn(&Shell) -> ShellValue; +type DynamicValueSetter = fn(&Shell) -> (); + +/// A shell value. +#[derive(Clone, Debug)] +pub enum ShellValue { + /// A value that has been typed but not yet set. + Unset(ShellValueUnsetType), + /// A string. + String(String), + /// An associative array. + AssociativeArray(BTreeMap), + /// An indexed array. + IndexedArray(BTreeMap), + /// A value that is dynamically computed. + Dynamic { + /// Function that can query the value. + getter: DynamicValueGetter, + /// Function that receives value update requests. + setter: DynamicValueSetter, + }, +} + +/// The type of an unset shell value. +#[derive(Clone, Debug)] +pub enum ShellValueUnsetType { + /// The value is untyped. + Untyped, + /// The value is an associative array. + AssociativeArray, + /// The value is an indexed array. + IndexedArray, +} + +/// A shell value literal; used for assignment. +#[derive(Clone, Debug)] +pub enum ShellValueLiteral { + /// A scalar value. + Scalar(String), + /// An array value. + Array(ArrayLiteral), +} + +impl ShellValueLiteral { + pub(crate) fn fmt_for_tracing(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Scalar(s) => Self::fmt_scalar_for_tracing(s.as_str(), f), + Self::Array(elements) => { + write!(f, "(")?; + for (i, (key, value)) in elements.0.iter().enumerate() { + if i > 0 { + write!(f, " ")?; + } + if let Some(key) = key { + write!(f, "[")?; + Self::fmt_scalar_for_tracing(key.as_str(), f)?; + write!(f, "]=")?; + } + Self::fmt_scalar_for_tracing(value.as_str(), f)?; + } + write!(f, ")") + } + } + } + + fn fmt_scalar_for_tracing(s: &str, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let processed = escape::quote_if_needed(s, escape::QuoteMode::SingleQuote); + write!(f, "{processed}") + } +} + +impl Display for ShellValueLiteral { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.fmt_for_tracing(f) + } +} + +impl From<&str> for ShellValueLiteral { + fn from(value: &str) -> Self { + Self::Scalar(value.to_owned()) + } +} + +impl From for ShellValueLiteral { + fn from(value: String) -> Self { + Self::Scalar(value) + } +} + +impl From> for ShellValueLiteral { + fn from(value: Vec<&str>) -> Self { + Self::Array(ArrayLiteral( + value.into_iter().map(|s| (None, s.to_owned())).collect(), + )) + } +} + +/// An array literal. +#[derive(Clone, Debug)] +pub struct ArrayLiteral(pub Vec<(Option, String)>); + +/// Style for formatting a shell variable's value. +#[derive(Copy, Clone, Debug)] +pub enum FormatStyle { + /// Basic formatting. + Basic, + /// Formatting as appropriate in the `declare` built-in command. + DeclarePrint, +} + +impl ShellValue { + /// Returns whether or not the value is an array. + pub const fn is_array(&self) -> bool { + matches!( + self, + Self::IndexedArray(_) + | Self::AssociativeArray(_) + | Self::Unset( + ShellValueUnsetType::IndexedArray | ShellValueUnsetType::AssociativeArray + ) + ) + } + + /// Returns whether or not the value is set. + pub const fn is_set(&self) -> bool { + !matches!(self, Self::Unset(_)) + } + + /// Returns a new indexed array value constructed from the given slice of owned strings. + /// + /// # Arguments + /// + /// * `values` - The slice of strings to construct the indexed array from. + pub fn indexed_array_from_strings(values: S) -> Self + where + S: IntoIterator, + { + let mut owned_values = BTreeMap::new(); + for (i, value) in values.into_iter().enumerate() { + owned_values.insert(i as u64, value); + } + + Self::IndexedArray(owned_values) + } + + /// Returns a new indexed array value constructed from the given slice of unowned strings. + /// + /// # Arguments + /// + /// * `values` - The slice of strings to construct the indexed array from. + pub fn indexed_array_from_strs(values: &[&str]) -> Self { + let mut owned_values = BTreeMap::new(); + for (i, value) in values.iter().enumerate() { + owned_values.insert(i as u64, (*value).to_string()); + } + + Self::IndexedArray(owned_values) + } + + /// Returns a new indexed array value constructed from the given literals. + /// + /// # Arguments + /// + /// * `literals` - The literals to construct the indexed array from. + pub fn indexed_array_from_literals(literals: ArrayLiteral) -> Self { + let mut values = BTreeMap::new(); + Self::update_indexed_array_from_literals(&mut values, literals); + + Self::IndexedArray(values) + } + + fn update_indexed_array_from_literals( + existing_values: &mut BTreeMap, + literal_values: ArrayLiteral, + ) { + let mut new_key = if let Some((largest_index, _)) = existing_values.last_key_value() { + largest_index + 1 + } else { + 0 + }; + + for (key, value) in literal_values.0 { + if let Some(key) = key { + new_key = key.parse().unwrap_or(0); + } + + existing_values.insert(new_key, value); + new_key += 1; + } + } + + /// Returns a new associative array value constructed from the given literals. + /// + /// # Arguments + /// + /// * `literals` - The literals to construct the associative array from. + pub fn associative_array_from_literals(literals: ArrayLiteral) -> Result { + let mut values = BTreeMap::new(); + Self::update_associative_array_from_literals(&mut values, literals)?; + + Ok(Self::AssociativeArray(values)) + } + + fn update_associative_array_from_literals( + existing_values: &mut BTreeMap, + literal_values: ArrayLiteral, + ) -> Result<(), error::Error> { + let mut current_key = None; + for (key, value) in literal_values.0 { + if let Some(current_key) = current_key.take() { + if key.is_some() { + return error::unimp("misaligned keys/values in associative array literal"); + } else { + existing_values.insert(current_key, value); + } + } else if let Some(key) = key { + existing_values.insert(key, value); + } else { + current_key = Some(value); + } + } + + if let Some(current_key) = current_key { + existing_values.insert(current_key, String::new()); + } + + Ok(()) + } + + /// Formats the value using the given style. + /// + /// # Arguments + /// + /// * `style` - The style to use for formatting the value. + pub fn format(&self, style: FormatStyle, shell: &Shell) -> Result, error::Error> { + match self { + Self::Unset(_) => Ok("".into()), + Self::String(s) => match style { + FormatStyle::Basic => Ok(escape::quote_if_needed( + s.as_str(), + escape::QuoteMode::SingleQuote, + )), + FormatStyle::DeclarePrint => { + Ok(escape::force_quote(s.as_str(), escape::QuoteMode::DoubleQuote).into()) + } + }, + Self::AssociativeArray(values) => { + let mut result = String::new(); + result.push('('); + + for (key, value) in values { + let formatted_key = + escape::quote_if_needed(key.as_str(), escape::QuoteMode::DoubleQuote); + let formatted_value = + escape::force_quote(value.as_str(), escape::QuoteMode::DoubleQuote); + + // N.B. We include an unconditional trailing space character (even after the + // last entry in the associative array) to match standard + // output behavior. + write!(result, "[{formatted_key}]={formatted_value} ")?; + } + + result.push(')'); + Ok(result.into()) + } + Self::IndexedArray(values) => { + let mut result = String::new(); + result.push('('); + + for (i, (key, value)) in values.iter().enumerate() { + if i > 0 { + result.push(' '); + } + + let formatted_value = + escape::force_quote(value.as_str(), escape::QuoteMode::DoubleQuote); + write!(result, "[{key}]={formatted_value}")?; + } + + result.push(')'); + Ok(result.into()) + } + Self::Dynamic { getter, .. } => { + let dynamic_value = getter(shell); + let result = dynamic_value.format(style, shell)?.to_string(); + Ok(result.into()) + } + } + } + + /// Tries to retrieve the value stored at the given index in this variable. + /// + /// # Arguments + /// + /// * `index` - The index at which to retrieve the value. + pub fn get_at(&self, index: &str, shell: &Shell) -> Result>, error::Error> { + match self { + Self::Unset(_) => Ok(None), + Self::String(s) => { + if index.parse::().unwrap_or(0) == 0 { + Ok(Some(Cow::Borrowed(s))) + } else { + Ok(None) + } + } + Self::AssociativeArray(values) => { + Ok(values.get(index).map(|s| Cow::Borrowed(s.as_str()))) + } + Self::IndexedArray(values) => { + let mut index_value = index.parse::().unwrap_or(0); + + #[expect(clippy::cast_possible_wrap)] + if index_value < 0 { + index_value += values.len() as i64; + if index_value < 0 { + return Err(error::ErrorKind::ArrayIndexOutOfRange(index_value).into()); + } + } + + // Now that we've confirmed that the index is non-negative, we can safely convert it + // to a u64 without any fuss. + #[expect(clippy::cast_sign_loss)] + let index_value = index_value as u64; + + Ok(values.get(&index_value).map(|s| Cow::Borrowed(s.as_str()))) + } + Self::Dynamic { getter, .. } => { + let dynamic_value = getter(shell); + let result = dynamic_value.get_at(index, shell)?; + Ok(result.map(|s| s.to_string().into())) + } + } + } + + /// Returns the keys of the elements in this variable. + pub fn element_keys(&self, shell: &Shell) -> Vec { + match self { + Self::Unset(_) => vec![], + Self::String(_) => vec!["0".to_owned()], + Self::AssociativeArray(array) => array.keys().map(|k| k.to_owned()).collect(), + Self::IndexedArray(array) => array.keys().map(|k| k.to_string()).collect(), + Self::Dynamic { getter, .. } => getter(shell).element_keys(shell), + } + } + + /// Returns the values of the elements in this variable. + pub fn element_values(&self, shell: &Shell) -> Vec { + match self { + Self::Unset(_) => vec![], + Self::String(s) => vec![s.to_owned()], + Self::AssociativeArray(array) => array.values().map(|v| v.to_owned()).collect(), + Self::IndexedArray(array) => array.values().map(|v| v.to_owned()).collect(), + Self::Dynamic { getter, .. } => getter(shell).element_values(shell), + } + } + + /// Converts this value to a string. + pub fn to_cow_str(&self, shell: &Shell) -> Cow<'_, str> { + self.try_get_cow_str(shell).unwrap_or(Cow::Borrowed("")) + } + + fn to_cow_str_without_dynamic_support(&self) -> Cow<'_, str> { + self.try_get_cow_str_without_dynamic_support() + .unwrap_or(Cow::Borrowed("")) + } + + /// Tries to convert this value to a string; returns `None` if the value is unset + /// or otherwise doesn't exist. + pub fn try_get_cow_str(&self, shell: &Shell) -> Option> { + match self { + Self::Dynamic { getter, .. } => { + let dynamic_value = getter(shell); + dynamic_value + .try_get_cow_str(shell) + .map(|s| s.to_string().into()) + } + _ => self.try_get_cow_str_without_dynamic_support(), + } + } + + fn try_get_cow_str_without_dynamic_support(&self) -> Option> { + match self { + Self::Unset(_) => None, + Self::String(s) => Some(Cow::Borrowed(s.as_str())), + Self::AssociativeArray(values) => values.get("0").map(|s| Cow::Borrowed(s.as_str())), + Self::IndexedArray(values) => values.get(&0).map(|s| Cow::Borrowed(s.as_str())), + Self::Dynamic { .. } => None, + } + } + + /// Formats this value as a program string usable in an assignment. + /// + /// # Arguments + /// + /// * `index` - The index at which to retrieve the value, if indexing is to be performed. + pub fn to_assignable_str(&self, index: Option<&str>, shell: &Shell) -> String { + match self { + Self::Unset(_) => String::new(), + Self::String(s) => escape::force_quote(s.as_str(), escape::QuoteMode::SingleQuote), + Self::AssociativeArray(_) | Self::IndexedArray(_) => { + if let Some(index) = index { + if let Ok(Some(value)) = self.get_at(index, shell) { + escape::force_quote(value.as_ref(), escape::QuoteMode::SingleQuote) + } else { + String::new() + } + } else { + self.format(FormatStyle::DeclarePrint, shell) + .unwrap() + .into_owned() + } + } + Self::Dynamic { getter, .. } => getter(shell).to_assignable_str(index, shell), + } + } +} + +impl From<&str> for ShellValue { + fn from(value: &str) -> Self { + Self::String(value.to_owned()) + } +} + +impl From<&String> for ShellValue { + fn from(value: &String) -> Self { + Self::String(value.clone()) + } +} + +impl From for ShellValue { + fn from(value: String) -> Self { + Self::String(value) + } +} + +impl From> for ShellValue { + fn from(values: Vec) -> Self { + Self::indexed_array_from_strings(values) + } +} + +impl From> for ShellValue { + fn from(values: Vec<&str>) -> Self { + Self::indexed_array_from_strs(values.as_slice()) + } +} diff --git a/crates/brush-core-vendored/src/wellknownvars.rs b/crates/brush-core-vendored/src/wellknownvars.rs new file mode 100644 index 000000000..b88e99b22 --- /dev/null +++ b/crates/brush-core-vendored/src/wellknownvars.rs @@ -0,0 +1,514 @@ +use rand::Rng; + +use crate::{Shell, ShellValue, ShellVariable, error, sys, variables}; + +const BASH_MAJOR: u32 = 5; +const BASH_MINOR: u32 = 2; +const BASH_PATCH: u32 = 15; +const BASH_BUILD: u32 = 1; +const BASH_RELEASE: &str = "release"; +const BASH_MACHINE: &str = "unknown"; + +#[expect(clippy::too_many_lines)] +pub(crate) fn initialize_vars( + shell: &mut Shell, + do_not_inherit_env: bool, +) -> Result<(), error::Error> { + // Seed parameters from environment (unless requested not to do so). + if !do_not_inherit_env { + for (k, v) in std::env::vars() { + // See if it's a function exported by an ancestor process. + if let Some(func_name) = k.strip_prefix("BASH_FUNC_") { + if let Some(func_name) = func_name.strip_suffix("%%") { + // Intentionally best-effort; don't fail out of the shell if we can't + // parse an incoming function. + if shell.define_func_from_str(func_name, v.as_str()).is_ok() { + shell.func_mut(func_name).unwrap().export(); + } + + continue; + } + } + + let mut var = ShellVariable::new(ShellValue::String(v)); + var.export(); + shell.env.set_global(k, var)?; + } + } + + let shell_version = shell.version().clone(); + shell.env.set_global( + "BRUSH_VERSION", + ShellVariable::new(shell_version.unwrap_or_default()), + )?; + + // TODO(#479): implement $_ + + // BASH + if let Some(shell_name) = &shell.shell_name { + shell + .env + .set_global("BASH", ShellVariable::new(shell_name))?; + } + + // BASHOPTS + let mut bashopts_var = ShellVariable::new(ShellValue::Dynamic { + getter: |shell| shell.options.shopt_optstr().into(), + setter: |_| (), + }); + bashopts_var.set_readonly(); + shell.env.set_global("BASHOPTS", bashopts_var)?; + + // BASHPID + #[cfg(not(target_family = "wasm"))] + { + let mut bashpid_var = + ShellVariable::new(ShellValue::String(std::process::id().to_string())); + bashpid_var.treat_as_integer(); + shell.env.set_global("BASHPID", bashpid_var)?; + } + + // BASH_ALIASES + shell.env.set_global( + "BASH_ALIASES", + ShellVariable::new(ShellValue::Dynamic { + getter: |shell| { + let values = variables::ArrayLiteral( + shell + .aliases + .iter() + .map(|(k, v)| (Some(k.to_owned()), v.to_owned())) + .collect::>(), + ); + + ShellValue::associative_array_from_literals(values).unwrap() + }, + setter: |_| (), + }), + )?; + + // TODO(vars): when extdebug is enabled, BASH_ARGC and BASH_ARGV are set to valid values + // TODO(vars): implement BASH_ARGC + // TODO(vars): implement BASH_ARGV + + // BASH_ARGV0 + shell.env.set_global( + "BASH_ARGV0", + ShellVariable::new(ShellValue::Dynamic { + getter: |shell| { + let argv0 = shell.shell_name.as_deref().unwrap_or_default(); + argv0.to_string().into() + }, + // TODO(vars): implement updating BASH_ARGV0 + setter: |_| (), + }), + )?; + + // TODO(vars): implement mutation of BASH_CMDS + shell.env.set_global( + "BASH_CMDS", + ShellVariable::new(ShellValue::Dynamic { + getter: |shell| shell.program_location_cache.to_value().unwrap(), + setter: |_| (), + }), + )?; + + // TODO(vars): implement BASH_COMMAND + // TODO(vars): implement BASH_EXECUTIION_STRING + // TODO(vars): implement BASH_LINENO + + // BASH_SOURCE + shell.env.set_global( + "BASH_SOURCE", + ShellVariable::new(ShellValue::Dynamic { + getter: |shell| get_bash_source_value(shell), + setter: |_| (), + }), + )?; + + // BASH_SUBSHELL + shell.env.set_global( + "BASH_SUBSHELL", + ShellVariable::new(ShellValue::Dynamic { + getter: |shell| shell.depth().to_string().into(), + setter: |_| (), + }), + )?; + + // BASH_VERSINFO + let mut bash_versinfo_var = ShellVariable::new(ShellValue::indexed_array_from_strs( + [ + BASH_MAJOR.to_string().as_str(), + BASH_MINOR.to_string().as_str(), + BASH_PATCH.to_string().as_str(), + BASH_BUILD.to_string().as_str(), + BASH_RELEASE, + BASH_MACHINE, + ] + .as_slice(), + )); + bash_versinfo_var.set_readonly(); + shell.env.set_global("BASH_VERSINFO", bash_versinfo_var)?; + + // BASH_VERSION + // This is the Bash interface version. See BRUSH_VERSION for its implementation version. + shell.env.set_global( + "BASH_VERSION", + ShellVariable::new(std::format!( + "{BASH_MAJOR}.{BASH_MINOR}.{BASH_PATCH}({BASH_BUILD})-{BASH_RELEASE}" + )), + )?; + + // COMP_WORDBREAKS + shell + .env + .set_global("COMP_WORDBREAKS", ShellVariable::new(" \t\n\"\'@><=;|&(:"))?; + + // DIRSTACK + shell.env.set_global( + "DIRSTACK", + ShellVariable::new(ShellValue::Dynamic { + getter: |shell| { + shell + .directory_stack + .iter() + .map(|p| p.to_string_lossy().to_string()) + .collect::>() + .into() + }, + setter: |_| (), + }), + )?; + + // EPOCHREALTIME + shell.env.set_global( + "EPOCHREALTIME", + ShellVariable::new(ShellValue::Dynamic { + getter: |_shell| { + let now = std::time::SystemTime::now(); + let since_epoch = now + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(); + since_epoch.as_secs_f64().to_string().into() + }, + setter: |_| (), + }), + )?; + + // EPOCHSECONDS + shell.env.set_global( + "EPOCHSECONDS", + ShellVariable::new(ShellValue::Dynamic { + getter: |_shell| { + let now = std::time::SystemTime::now(); + let since_epoch = now + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(); + since_epoch.as_secs().to_string().into() + }, + setter: |_| (), + }), + )?; + + // EUID + if let Ok(euid) = sys::users::get_effective_uid() { + let mut euid_var = ShellVariable::new(ShellValue::String(format!("{euid}"))); + euid_var.treat_as_integer().set_readonly(); + shell.env.set_global("EUID", euid_var)?; + } + + // FUNCNAME + shell.env.set_global( + "FUNCNAME", + ShellVariable::new(ShellValue::Dynamic { + getter: |shell| get_funcname_value(shell), + setter: |_| (), + }), + )?; + + // GROUPS + // N.B. We could compute this up front, but we choose to make it dynamic so that we + // don't have to make costly system calls if the user never accesses it. + shell.env.set_global( + "GROUPS", + ShellVariable::new(ShellValue::Dynamic { + getter: |_shell| { + let groups = get_current_user_gids(); + ShellValue::indexed_array_from_strings( + groups.into_iter().map(|gid| gid.to_string()), + ) + }, + setter: |_| (), + }), + )?; + + // HISTCMD + let mut histcmd_var = ShellVariable::new(ShellValue::Dynamic { + getter: |shell| { + shell + .history() + .map_or_else(|| "0".into(), |h| h.count().to_string().into()) + }, + setter: |_| (), + }); + histcmd_var.treat_as_integer(); + shell.env.set_global("HISTCMD", histcmd_var)?; + + // HISTFILE (if not already set) + if !shell.env.is_set("HISTFILE") { + if let Some(home_dir) = shell.home_dir() { + let histfile = home_dir.join(".brush_history"); + shell.env.set_global( + "HISTFILE", + ShellVariable::new(ShellValue::String(histfile.to_string_lossy().to_string())), + )?; + } + } + + // HOSTNAME + shell.env.set_global( + "HOSTNAME", + ShellVariable::new( + sys::network::get_hostname() + .unwrap_or_default() + .to_string_lossy() + .to_string(), + ), + )?; + + // HOSTTYPE + shell.env.set_global( + "HOSTTYPE", + ShellVariable::new(std::env::consts::ARCH.to_string()), + )?; + + // IFS + shell.env.set_global("IFS", ShellVariable::new(" \t\n"))?; + + // LINENO + shell.env.set_global( + "LINENO", + ShellVariable::new(ShellValue::Dynamic { + getter: |shell| shell.current_line_number().to_string().into(), + setter: |_| (), + }), + )?; + + // MACHTYPE + shell + .env + .set_global("MACHTYPE", ShellVariable::new(BASH_MACHINE))?; + + // OLDPWD (initialization) + if !shell.env.is_set("OLDPWD") { + let mut oldpwd_var = + ShellVariable::new(ShellValue::Unset(variables::ShellValueUnsetType::Untyped)); + oldpwd_var.export(); + shell.env.set_global("OLDPWD", oldpwd_var)?; + } + + // OPTERR + shell.env.set_global("OPTERR", ShellVariable::new("1"))?; + + // OPTIND + let mut optind_var = ShellVariable::new("1"); + optind_var.treat_as_integer(); + shell.env.set_global("OPTIND", optind_var)?; + + // OSTYPE + let os_type = match std::env::consts::OS { + "linux" => "linux-gnu", + "windows" => "windows", + _ => "unknown", + }; + shell + .env + .set_global("OSTYPE", ShellVariable::new(os_type))?; + + // PATH (if not already set) + if !shell.env.is_set("PATH") { + let default_path_str = sys::fs::get_default_executable_search_paths().join(":"); + shell + .env + .set_global("PATH", ShellVariable::new(default_path_str))?; + } + + // PIPESTATUS + // TODO: Investigate what happens if this gets unset. + // TODO: Investigate if this needs to be saved/preserved across prompt display. + shell.env.set_global( + "PIPESTATUS", + ShellVariable::new(ShellValue::Dynamic { + getter: |shell| { + ShellValue::indexed_array_from_strings( + shell.last_pipeline_statuses.iter().map(|s| s.to_string()), + ) + }, + setter: |_| (), + }), + )?; + + // PPID + if let Some(ppid) = sys::terminal::get_parent_process_id() { + let mut ppid_var = ShellVariable::new(ppid.to_string()); + ppid_var.treat_as_integer().set_readonly(); + shell.env.set_global("PPID", ppid_var)?; + } + + // RANDOM + let mut random_var = ShellVariable::new(ShellValue::Dynamic { + getter: get_random_value, + setter: |_| (), + }); + random_var.treat_as_integer(); + shell.env.set_global("RANDOM", random_var)?; + + // SECONDS + shell.env.set_global( + "SECONDS", + ShellVariable::new(ShellValue::Dynamic { + getter: |shell| { + let now = std::time::SystemTime::now(); + let since_last = now + .duration_since(shell.last_stopwatch_time()) + .unwrap_or_default(); + let total_seconds = since_last.as_secs() + u64::from(shell.last_stopwatch_offset()); + total_seconds.to_string().into() + }, + // TODO(vars): implement updating SECONDS + setter: |_| (), + }), + )?; + + // SHELL + if let Ok(exe_path) = std::env::current_exe() { + shell.env.set_global( + "SHELL", + ShellVariable::new(exe_path.to_string_lossy().to_string()), + )?; + } + + // SHELLOPTS + let mut shellopts_var = ShellVariable::new(ShellValue::Dynamic { + getter: |shell| shell.options.seto_optstr().into(), + setter: |_| (), + }); + shellopts_var.set_readonly(); + shell.env.set_global("SHELLOPTS", shellopts_var)?; + + // SHLVL + let input_shlvl = shell.env_str("SHLVL").unwrap_or_else(|| "0".into()); + let updated_shlvl = input_shlvl.as_ref().parse::().unwrap_or(0) + 1; + let mut shlvl_var = ShellVariable::new(updated_shlvl.to_string()); + shlvl_var.export(); + shell.env.set_global("SHLVL", shlvl_var)?; + + // SRANDOM + let mut random_var = ShellVariable::new(ShellValue::Dynamic { + getter: get_srandom_value, + setter: |_| (), + }); + random_var.treat_as_integer(); + shell.env.set_global("SRANDOM", random_var)?; + + // PS1 / PS2 + if shell.options.interactive { + if !shell.env.is_set("PS1") { + shell + .env + .set_global("PS1", ShellVariable::new(r"\s-\v\$ "))?; + } + + if !shell.env.is_set("PS2") { + shell.env.set_global("PS2", ShellVariable::new("> "))?; + } + } + + // PS4 + if !shell.env.is_set("PS4") { + shell.env.set_global("PS4", ShellVariable::new("+ "))?; + } + + // + // PWD + // + // Reflect our actual working directory. There's a chance + // we inherited an out-of-sync version of the variable. Future updates + // will be handled by set_working_dir(). + // + let pwd = shell.working_dir().to_string_lossy().to_string(); + let mut pwd_var = ShellVariable::new(pwd); + pwd_var.export(); + shell.env.set_global("PWD", pwd_var)?; + + // UID + if let Ok(uid) = sys::users::get_current_uid() { + let mut uid_var = ShellVariable::new(ShellValue::String(format!("{uid}"))); + uid_var.treat_as_integer().set_readonly(); + shell.env.set_global("UID", uid_var)?; + } + + Ok(()) +} + +/// Returns a list of the current user's group IDs, with the effective GID at the front. +fn get_current_user_gids() -> Vec { + let mut groups = sys::users::get_user_group_ids().unwrap_or_default(); + + // If the effective GID is present but not in the first position in the list, then move + // it there. + if let Ok(gid) = sys::users::get_effective_gid() { + if let Some(index) = groups.iter().position(|&g| g == gid) { + if index > 0 { + // Move it to the front. + groups.remove(index); + groups.insert(0, gid); + } + } + } + + groups +} + +fn get_random_value(_shell: &Shell) -> ShellValue { + let mut rng = rand::rng(); + let num = rng.random_range(0..32768); + let str = num.to_string(); + str.into() +} + +fn get_srandom_value(_shell: &Shell) -> ShellValue { + let mut rng = rand::rng(); + let num: u32 = rng.random(); + let str = num.to_string(); + str.into() +} + +fn get_funcname_value(shell: &Shell) -> variables::ShellValue { + if shell.function_call_stack().is_empty() { + ShellValue::Unset(variables::ShellValueUnsetType::IndexedArray) + } else { + shell + .function_call_stack() + .iter() + .map(|s| s.function_name.as_str()) + .collect::>() + .into() + } +} + +fn get_bash_source_value(shell: &Shell) -> variables::ShellValue { + if shell.function_call_stack().is_empty() { + let top_frame = shell.script_call_stack().iter().next(); + top_frame + .map_or_else(Vec::new, |frame| vec![frame.source.as_ref()]) + .into() + } else { + shell + .function_call_stack() + .iter() + .map(|s| s.function_definition.source.as_ref()) + .collect::>() + .into() + } +} diff --git a/crates/pi-natives/Cargo.toml b/crates/pi-natives/Cargo.toml index cfb69fd03..d0b770b67 100644 --- a/crates/pi-natives/Cargo.toml +++ b/crates/pi-natives/Cargo.toml @@ -16,7 +16,7 @@ workspace = true tokio = { version = "1", features = ["full"] } napi = { version = "3", features = ["napi10", "tokio_rt", "tokio_time"] } napi-derive = "3" -brush-core = "0.4.0" +brush-core = { version = "0.4.0", path = "../brush-core-vendored" } brush-builtins = "0.1.0" parking_lot = "0.12.5" clap = { version = "4", features = ["derive"] } diff --git a/packages/coding-agent/test/bash-executor.test.ts b/packages/coding-agent/test/bash-executor.test.ts index 288d80890..c98035cf7 100644 --- a/packages/coding-agent/test/bash-executor.test.ts +++ b/packages/coding-agent/test/bash-executor.test.ts @@ -1,9 +1,11 @@ -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { executeBash } from "@oh-my-pi/pi-coding-agent/exec/bash-executor"; +import { DEFAULT_MAX_BYTES } from "@oh-my-pi/pi-coding-agent/tools/truncate"; +import * as shellSnapshot from "@oh-my-pi/pi-coding-agent/utils/shell-snapshot"; function makeTempDir(): string { return fs.mkdtempSync(path.join(os.tmpdir(), "omp-bash-exec-")); @@ -20,6 +22,7 @@ describe("executeBash", () => { afterEach(() => { _resetSettingsForTest(); + vi.restoreAllMocks(); if (fs.existsSync(tempDir)) { fs.rmSync(tempDir, { recursive: true }); } @@ -45,6 +48,22 @@ describe("executeBash", () => { expect(result.output.trim()).toBe("hello"); }); + it("invokes onChunk with command output", async () => { + let seenChunk: string | null = null; + const result = await executeBash("echo hello", { + cwd: tempDir, + timeout: 5000, + onChunk: chunk => { + if (seenChunk === null) { + seenChunk = chunk; + } + }, + }); + expect(result.output.trim()).toBe("hello"); + expect(seenChunk).not.toBeNull(); + expect(seenChunk ?? "").toContain("hello"); + }); + it("times out commands", async () => { if (process.platform === "win32") { return; @@ -54,6 +73,16 @@ describe("executeBash", () => { expect(result.output).toContain("timed out"); }); + it("times out before follow-up output", async () => { + if (process.platform === "win32") { + return; + } + const result = await executeBash("sleep 10; echo done", { cwd: tempDir, timeout: 50 }); + expect(result.cancelled).toBe(true); + expect(result.output).toContain("timed out"); + expect(result.output).not.toContain("done"); + }); + it("aborts commands", async () => { if (process.platform === "win32") { return; @@ -71,12 +100,33 @@ describe("executeBash", () => { expect(result.output).toContain("Command cancelled"); }); + it("aborts before follow-up output", async () => { + if (process.platform === "win32") { + return; + } + const controller = new AbortController(); + const promise = executeBash("sleep 10; echo done", { + cwd: tempDir, + timeout: 5000, + signal: controller.signal, + }); + await Bun.sleep(100); + controller.abort(); + const result = await promise; + expect(result.cancelled).toBe(true); + expect(result.output).toContain("Command cancelled"); + expect(result.output).not.toContain("done"); + }); + it("streams output chunks", async () => { const chunks: string[] = []; const result = await executeBash("i=1; while [ $i -le 20 ]; do echo line$i; i=$((i+1)); done", { cwd: tempDir, timeout: 5000, - onChunk: chunk => chunks.push(chunk), + onChunk: chunk => { + expect(chunk.length).toBeGreaterThan(0); + chunks.push(chunk); + }, }); const combined = chunks.join(""); expect(chunks.length).toBeGreaterThan(0); @@ -86,6 +136,53 @@ describe("executeBash", () => { expect(result.output).toContain("line20"); }); + it("streams large output without exhausting memory", async () => { + if (process.platform === "win32") { + return; + } + let totalBytes = 0; + let sawChunk = false; + const result = await executeBash("awk 'BEGIN { for (i = 0; i < 100000; i++) printf \"a\" }'", { + cwd: tempDir, + timeout: 5000, + onChunk: chunk => { + sawChunk = true; + totalBytes += Buffer.byteLength(chunk, "utf-8"); + }, + }); + expect(sawChunk).toBe(true); + expect(totalBytes).toBe(100000); + expect(result.totalBytes).toBe(100000); + expect(result.outputBytes).toBeLessThanOrEqual(DEFAULT_MAX_BYTES); + expect(result.output).toContain("a"); + }); + + it("sources snapshot env vars across session commands", async () => { + if (process.platform === "win32") { + return; + } + const bashPath = process.env.SHELL?.includes("bash") ? process.env.SHELL : "/bin/bash"; + if (!fs.existsSync(bashPath)) { + return; + } + const snapshotPath = path.join(tempDir, "snapshot.sh"); + fs.writeFileSync(snapshotPath, "export OMP_SNAPSHOT_TEST=from_snapshot\n"); + vi.spyOn(Settings.prototype, "getShellConfig").mockReturnValue({ + shell: bashPath, + args: ["-l", "-c"], + env: { + PATH: process.env.PATH ?? "", + HOME: process.env.HOME ?? tempDir, + }, + prefix: undefined, + }); + vi.spyOn(shellSnapshot, "getOrCreateSnapshot").mockResolvedValue(snapshotPath); + const sessionKey = "snapshot-test"; + await executeBash("true", { cwd: tempDir, timeout: 5000, sessionKey }); + const result = await executeBash("echo $OMP_SNAPSHOT_TEST", { cwd: tempDir, timeout: 5000, sessionKey }); + expect(result.output.trim()).toBe("from_snapshot"); + }); + it("does not allow exec to replace the host", async () => { const result = await executeBash("exec echo hi", { cwd: tempDir, timeout: 5000 }); expect(result.cancelled).toBe(false);