diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f76a7b8f..a5f82a2f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -35,6 +35,7 @@ jobs: os: "linux" arch: "x86_64" binary_name: "brush" + extra_build_args: "" # Build for aarch64/macos target on native host. - host: "macos-latest" target: "" @@ -42,6 +43,7 @@ jobs: arch: "aarch64" required_tools: "" binary_name: "brush" + extra_build_args: "" # Build for aarch64/linux target on x86_64/linux host. - host: "ubuntu-latest" target: "aarch64-unknown-linux-gnu" @@ -49,13 +51,15 @@ jobs: arch: "aarch64" required_tools: "gcc-aarch64-linux-gnu" binary_name: "brush" - # Build for WASI-0.1 target on x86_64/linux host. + extra_build_args: "" + # Build for WASI-0.2 target on x86_64/linux host. - host: "ubuntu-latest" - target: "wasm32-wasip1" - os: "wasi-0.1" + target: "wasm32-wasip2" + os: "wasi-0.2" arch: "wasm32" required_tools: "" binary_name: "brush.wasm" + extra_build_args: "--no-default-features --features minimal" # Build for x86_64/windows target on x86_64/linux host. - host: "ubuntu-latest" target: "x86_64-pc-windows-gnu" @@ -63,6 +67,7 @@ jobs: arch: "x86_64" required_tools: "" binary_name: "brush.exe" + extra_build_args: "" name: "Build (${{ matrix.arch }}/${{ matrix.os }})" runs-on: ${{ matrix.host }} @@ -93,11 +98,11 @@ jobs: - name: "Build (native)" if: ${{ matrix.target == '' }} - run: cargo build --release --all-targets + run: cargo build --release --all-targets ${{ matrix.extra_build_args }} - name: "Build (cross)" if: ${{ matrix.target != '' }} - run: cross build --release --target=${{ matrix.target }} + run: cross build --release --target=${{ matrix.target }} ${{ matrix.extra_build_args }} - name: "Upload binaries" uses: actions/upload-artifact@v4 diff --git a/Cargo.lock b/Cargo.lock index 1123747a..46c22941 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -50,9 +50,9 @@ dependencies = [ [[package]] name = "allocator-api2" -version = "0.2.20" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45862d1c77f2228b9e10bc609d5bc203d86ebc9b87ad8d5d5167a6c9abf739d9" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "android-tzdata" @@ -190,7 +190,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.89", + "syn 2.0.90", ] [[package]] @@ -201,7 +201,7 @@ checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.89", + "syn 2.0.90", ] [[package]] @@ -307,6 +307,7 @@ dependencies = [ "async-trait", "brush-core", "brush-parser", + "crossterm", "indexmap", "nu-ansi-term 0.50.1", "reedline", @@ -400,9 +401,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.8.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ac0150caa2ae65ca5bd83f25c7de183dea78d4d366469f148435e2acfbad0da" +checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b" [[package]] name = "cached" @@ -428,7 +429,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.89", + "syn 2.0.90", ] [[package]] @@ -445,9 +446,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd9de9f2205d5ef3fd67e685b0df337994ddd4495e2a28d185500d0e1edfea47" +checksum = "f34d93e62b03caf570cccc334cbc6c2fceca82f39211051345108adcba3eebdc" dependencies = [ "jobserver", "libc", @@ -508,9 +509,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.21" +version = "4.5.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb3b4b9e5a7c7514dfa52869339ee98b3156b0bfb4e8a77c4ff4babb64b1604f" +checksum = "3135e7ec2ef7b10c6ed8950f0f792ed96ee093fa088608f1c76e569722700c84" dependencies = [ "clap_builder", "clap_derive", @@ -527,9 +528,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.21" +version = "4.5.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b17a95aa67cc7b5ebd32aa5370189aa0d79069ef1c64ce893bd30fb24bff20ec" +checksum = "30582fc632330df2bd26877bde0c1f4470d57c582bbc070376afcd04d8cb4838" dependencies = [ "anstream", "anstyle", @@ -547,14 +548,14 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.89", + "syn 2.0.90", ] [[package]] name = "clap_lex" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afb84c814227b90d6895e01398aee0d8033c00e7466aca416fb6a8e0eb19d8a7" +checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" [[package]] name = "clap_mangen" @@ -761,7 +762,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.89", + "syn 2.0.90", ] [[package]] @@ -772,7 +773,7 @@ checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" dependencies = [ "darling_core", "quote", - "syn 2.0.89", + "syn 2.0.90", ] [[package]] @@ -812,7 +813,7 @@ checksum = "30542c1ad912e0e3d22a1935c290e12e8a29d704a420177a31faad4a601a0800" dependencies = [ "proc-macro2", "quote", - "syn 2.0.89", + "syn 2.0.90", ] [[package]] @@ -889,7 +890,7 @@ checksum = "3bf679796c0322556351f287a51b49e48f7c4986e727b5dd78c972d30e2e16cc" dependencies = [ "proc-macro2", "quote", - "syn 2.0.89", + "syn 2.0.90", ] [[package]] @@ -900,12 +901,12 @@ checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" [[package]] name = "errno" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" +checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1031,7 +1032,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.89", + "syn 2.0.90", ] [[package]] @@ -1098,7 +1099,7 @@ checksum = "53010ccb100b96a67bc32c0175f0ed1426b31b655d562898e57325f81c023ac0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.89", + "syn 2.0.90", ] [[package]] @@ -1163,12 +1164,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hermit-abi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" - [[package]] name = "hermit-abi" version = "0.4.0" @@ -1317,7 +1312,7 @@ version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "261f68e344040fbd0edea105bef17c66edf46f984ddb1115b775ce31be948f4b" dependencies = [ - "hermit-abi 0.4.0", + "hermit-abi", "libc", "windows-sys 0.52.0", ] @@ -1372,10 +1367,11 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.72" +version = "0.3.74" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a88f1bda2bd75b0452a14784937d796722fdebfe50df998aeb3f0b7603019a9" +checksum = "a865e038f7f6ed956f788f0d7d60c541fff74c7bd74272c5d4cf15c63743e705" dependencies = [ + "once_cell", "wasm-bindgen", ] @@ -1399,9 +1395,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.166" +version = "0.2.167" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2ccc108bbc0b1331bd061864e7cd823c0cab660bbe6970e66e2c0614decde36" +checksum = "09d6582e104315a817dff97f75133544b2e094ee22447d2acf4a74e189ba06fc" [[package]] name = "libfuzzer-sys" @@ -1470,11 +1466,10 @@ dependencies = [ [[package]] name = "mio" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" +checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" dependencies = [ - "hermit-abi 0.3.9", "libc", "log", "wasi", @@ -1584,9 +1579,9 @@ checksum = "b410bbe7e14ab526a0e86877eb47c6996a2bd7746f027ba551028c925390e4e9" [[package]] name = "os_info" -version = "3.8.2" +version = "3.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae99c7fa6dd38c7cafe1ec085e804f8f555a2f8659b0dbe03f1f9963a9b51092" +checksum = "e5ca711d8b83edbb00b44d504503cd247c9c0bd8b0fa2694f2a1a3d8165379ce" dependencies = [ "log", "serde", @@ -2036,7 +2031,7 @@ checksum = "ad1e866f866923f252f05c889987993144fb74e722403468a4ebd70c3cd756c0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.89", + "syn 2.0.90", ] [[package]] @@ -2176,14 +2171,14 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.89", + "syn 2.0.90", ] [[package]] name = "symbolic-common" -version = "12.12.1" +version = "12.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d4d73159efebfb389d819fd479afb2dbd57dcb3e3f4b7fcfa0e675f5a46c1cb" +checksum = "e5ba5365997a4e375660bed52f5b42766475d5bc8ceb1bb13fea09c469ea0f49" dependencies = [ "debugid", "memmap2", @@ -2193,9 +2188,9 @@ dependencies = [ [[package]] name = "symbolic-demangle" -version = "12.12.1" +version = "12.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a767859f6549c665011970874c3f541838b4835d5aaaa493d3ee383918be9f10" +checksum = "beff338b2788519120f38c59ff4bb15174f52a183e547bac3d6072c2c0aa48aa" dependencies = [ "cpp_demangle", "rustc-demangle", @@ -2215,9 +2210,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.89" +version = "2.0.90" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d46482f1c1c87acd84dea20c1bf5ebff4c757009ed6bf19cfd36fb10e92c4e" +checksum = "919d3b74a5dd0ccd15aeb8f93e7006bd9e14c295087c9896a110f490752bcf31" dependencies = [ "proc-macro2", "quote", @@ -2248,9 +2243,9 @@ dependencies = [ [[package]] name = "terminal_size" -version = "0.4.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f599bd7ca042cfdf8f4512b277c02ba102247820f9d9d4a9f521f496751a6ef" +checksum = "5352447f921fda68cf61b4101566c0bdb5104eff6804d0678e5227580ab6a4e9" dependencies = [ "rustix", "windows-sys 0.59.0", @@ -2288,7 +2283,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.89", + "syn 2.0.90", ] [[package]] @@ -2299,7 +2294,7 @@ checksum = "d65750cab40f4ff1929fb1ba509e9914eb756131cef4210da8d5d700d26f6312" dependencies = [ "proc-macro2", "quote", - "syn 2.0.89", + "syn 2.0.90", ] [[package]] @@ -2314,9 +2309,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.36" +version = "0.3.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" +checksum = "35e7868883861bd0e56d9ac6efcaaca0d6d5d82a2a7ec8209ff492c07cf37b21" dependencies = [ "deranged", "itoa", @@ -2335,9 +2330,9 @@ checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" [[package]] name = "time-macros" -version = "0.2.18" +version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" +checksum = "2834e6017e3e5e4b9834939793b282bc03b37a3336245fa820e35e233e2a85de" dependencies = [ "num-conv", "time-core", @@ -2377,7 +2372,7 @@ checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" dependencies = [ "proc-macro2", "quote", - "syn 2.0.89", + "syn 2.0.90", ] [[package]] @@ -2432,7 +2427,7 @@ checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.89", + "syn 2.0.90", ] [[package]] @@ -2605,9 +2600,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.95" +version = "0.2.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "128d1e363af62632b8eb57219c8fd7877144af57558fb2ef0368d0087bddeb2e" +checksum = "d15e63b4482863c109d70a7b8706c1e364eb6ea449b201a76c5b89cedcec2d5c" dependencies = [ "cfg-if", "once_cell", @@ -2616,24 +2611,24 @@ dependencies = [ [[package]] name = "wasm-bindgen-backend" -version = "0.2.95" +version = "0.2.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb6dd4d3ca0ddffd1dd1c9c04f94b868c37ff5fac97c30b97cff2d74fce3a358" +checksum = "8d36ef12e3aaca16ddd3f67922bc63e48e953f126de60bd33ccc0101ef9998cd" dependencies = [ "bumpalo", "log", "once_cell", "proc-macro2", "quote", - "syn 2.0.89", + "syn 2.0.90", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.95" +version = "0.2.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e79384be7f8f5a9dd5d7167216f022090cf1f9ec128e6e6a482a2cb5c5422c56" +checksum = "705440e08b42d3e4b36de7d66c944be628d579796b8090bfa3471478a2260051" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2641,28 +2636,28 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.95" +version = "0.2.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68" +checksum = "98c9ae5a76e46f4deecd0f0255cc223cfa18dc9b261213b8aa0c7b36f61b3f1d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.89", + "syn 2.0.90", "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.95" +version = "0.2.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d" +checksum = "6ee99da9c5ba11bd675621338ef6fa52296b76b83305e9b6e5c77d4c286d6d49" [[package]] name = "web-sys" -version = "0.3.72" +version = "0.3.74" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6488b90108c040df0fe62fa815cbdee25124641df01814dd7282749234c6112" +checksum = "a98bc3c33f0fe7e59ad7cd041b89034fa82a7c2d4365ca538dda6cdaf513863c" dependencies = [ "js-sys", "wasm-bindgen", @@ -2784,7 +2779,7 @@ checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.89", + "syn 2.0.90", ] [[package]] @@ -2795,7 +2790,7 @@ checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.89", + "syn 2.0.90", ] [[package]] @@ -3047,5 +3042,5 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.89", + "syn 2.0.90", ] diff --git a/README.md b/README.md index 31d6791a..40205d6a 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ If you feel so inclined, we'd love contributions toward any of the above, with b ## 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 [300+ test cases](brush-shell/tests/cases) run on both this shell and an oracle, comparing standard output and exit codes. +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 [450+ 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). diff --git a/brush-core/src/builtins.rs b/brush-core/src/builtins.rs index d04ef04e..05914851 100644 --- a/brush-core/src/builtins.rs +++ b/brush-core/src/builtins.rs @@ -10,6 +10,7 @@ use crate::ExecutionResult; mod alias; mod bg; +mod bind; mod break_; mod brushinfo; mod builtin_; @@ -38,6 +39,7 @@ mod jobs; #[cfg(unix)] mod kill; mod let_; +mod mapfile; mod popd; mod printf; mod pushd; diff --git a/brush-core/src/builtins/bind.rs b/brush-core/src/builtins/bind.rs new file mode 100644 index 00000000..3dffcd49 --- /dev/null +++ b/brush-core/src/builtins/bind.rs @@ -0,0 +1,118 @@ +use clap::Parser; +use std::io::Write; + +use crate::{builtins, commands, error}; + +/// Inspect and modify key bindings and other input configuration. +#[derive(Parser)] +pub(crate) struct BindCommand { + /// Name of key map to use. + #[arg(short = 'm')] + keymap: Option, + /// List functions. + #[arg(short = 'l')] + list_funcs: bool, + /// List functions and bindings. + #[arg(short = 'P')] + list_funcs_and_bindings: bool, + /// List functions and bindings in a format suitable for use as input. + #[arg(short = 'p')] + list_funcs_and_bindings_reusable: bool, + /// List key sequences that invoke macros. + #[arg(short = 'S')] + list_key_seqs_that_invoke_macros: bool, + /// List key sequences that invoke macros in a format suitable for use as input. + #[arg(short = 's')] + list_key_seqs_that_invoke_macros_reusable: bool, + /// List variables. + #[arg(short = 'V')] + list_vars: bool, + /// List variables in a format suitable for use as input. + #[arg(short = 'v')] + list_vars_reusable: bool, + /// Find the keys bound to the given named function. + #[arg(short = 'q')] + query_func_bindings: Option, + /// Remove all bindings for the given named function. + #[arg(short = 'u')] + remove_func_bindings: Option, + /// Remove the binding for the given key sequence. + #[arg(short = 'r')] + remove_key_seq_binding: Option, + /// Import bindings from the given file. + #[arg(short = 'f')] + bindings_file: Option, + /// Bind key sequence to command. + #[arg(short = 'x')] + key_seq_bindings: Vec, + /// List key sequence bindings. + #[arg(short = 'X')] + list_key_seq_bindings: bool, +} + +impl builtins::Command for BindCommand { + async fn execute( + &self, + context: commands::ExecutionContext<'_>, + ) -> Result { + if self.keymap.is_some() { + return error::unimp("bind -m is not yet implemented"); + } + + if self.list_funcs { + return error::unimp("bind -l is not yet implemented"); + } + + if self.list_funcs_and_bindings { + return error::unimp("bind -P is not yet implemented"); + } + + if self.list_funcs_and_bindings_reusable { + return error::unimp("bind -p is not yet implemented"); + } + + if self.list_key_seqs_that_invoke_macros { + return error::unimp("bind -S is not yet implemented"); + } + + if self.list_key_seqs_that_invoke_macros_reusable { + return error::unimp("bind -s is not yet implemented"); + } + + if self.list_vars { + return error::unimp("bind -V is not yet implemented"); + } + + if self.list_vars_reusable { + // For now we'll just display a few items and show defaults. + writeln!(context.stdout(), "set mark-directories on")?; + writeln!(context.stdout(), "set mark-symlinked-directories off")?; + } + + if self.query_func_bindings.is_some() { + return error::unimp("bind -q is not yet implemented"); + } + + if self.remove_func_bindings.is_some() { + return error::unimp("bind -u is not yet implemented"); + } + + if self.remove_key_seq_binding.is_some() { + return error::unimp("bind -r is not yet implemented"); + } + + if self.bindings_file.is_some() { + return error::unimp("bind -f is not yet implemented"); + } + + if !self.key_seq_bindings.is_empty() { + return error::unimp("bind -x is not yet implemented"); + } + + if self.list_key_seq_bindings { + return error::unimp("bind -X is not yet implemented"); + } + + Ok(builtins::ExitCode::Success) + } +} diff --git a/brush-core/src/builtins/complete.rs b/brush-core/src/builtins/complete.rs index 931d0912..a38169bc 100644 --- a/brush-core/src/builtins/complete.rs +++ b/brush-core/src/builtins/complete.rs @@ -7,6 +7,7 @@ use crate::builtins; use crate::commands; use crate::completion::{self, CompleteAction, CompleteOption, Spec}; use crate::error; +use crate::escape; #[derive(Parser)] pub(crate) struct CommonCompleteCommandArgs { @@ -302,6 +303,7 @@ impl CompleteCommand { } } + #[allow(clippy::too_many_lines)] fn display_spec( context: &commands::ExecutionContext<'_>, special_name: Option<&str>, @@ -316,35 +318,36 @@ impl CompleteCommand { } for action in &spec.actions { + s.push(' '); + let action_str = match action { - CompleteAction::Alias => "alias", - CompleteAction::ArrayVar => "arrayvar", - CompleteAction::Binding => "binding", - CompleteAction::Builtin => "builtin", - CompleteAction::Command => "command", - CompleteAction::Directory => "directory", - CompleteAction::Disabled => "disabled", - CompleteAction::Enabled => "enabled", - CompleteAction::Export => "export", - CompleteAction::File => "file", - CompleteAction::Function => "function", - CompleteAction::Group => "group", - CompleteAction::HelpTopic => "helptopic", - CompleteAction::HostName => "hostname", - CompleteAction::Job => "job", - CompleteAction::Keyword => "keyword", - CompleteAction::Running => "running", - CompleteAction::Service => "service", - CompleteAction::SetOpt => "setopt", - CompleteAction::ShOpt => "shopt", - CompleteAction::Signal => "signal", - CompleteAction::Stopped => "stopped", - CompleteAction::User => "user", - CompleteAction::Variable => "variable", + CompleteAction::Alias => "-a", + CompleteAction::ArrayVar => "-A arrayvar", + CompleteAction::Binding => "-A binding", + CompleteAction::Builtin => "-b", + CompleteAction::Command => "-c", + CompleteAction::Directory => "-d", + CompleteAction::Disabled => "-A disabled", + CompleteAction::Enabled => "-A enabled", + CompleteAction::Export => "-e", + CompleteAction::File => "-f", + CompleteAction::Function => "-A function", + CompleteAction::Group => "-g", + CompleteAction::HelpTopic => "-A helptopic", + CompleteAction::HostName => "-A hostname", + CompleteAction::Job => "-j", + CompleteAction::Keyword => "-k", + CompleteAction::Running => "-A running", + CompleteAction::Service => "-s", + CompleteAction::SetOpt => "-A setopt", + CompleteAction::ShOpt => "-A shopt", + CompleteAction::Signal => "-A signal", + CompleteAction::Stopped => "-A stopped", + CompleteAction::User => "-u", + CompleteAction::Variable => "-v", }; - let piece = std::format!(" -A {action_str}"); - s.push_str(&piece); + s.push_str(action_str); } if spec.options.bash_default { @@ -373,25 +376,49 @@ impl CompleteCommand { } if let Some(glob_pattern) = &spec.glob_pattern { - write!(s, " -G {glob_pattern}")?; + write!( + s, + " -G {}", + escape::force_quote(glob_pattern, escape::QuoteMode::Quote) + )?; } if let Some(word_list) = &spec.word_list { - write!(s, " -W {word_list}")?; + write!( + s, + " -W {}", + escape::force_quote(word_list, escape::QuoteMode::Quote) + )?; } if let Some(function_name) = &spec.function_name { write!(s, " -F {function_name}")?; } if let Some(command) = &spec.command { - write!(s, " -C {command}")?; + write!( + s, + " -C {}", + escape::force_quote(command, escape::QuoteMode::Quote) + )?; } if let Some(filter_pattern) = &spec.filter_pattern { - write!(s, " -X {filter_pattern}")?; + write!( + s, + " -X {}", + escape::force_quote(filter_pattern, escape::QuoteMode::Quote) + )?; } if let Some(prefix) = &spec.prefix { - write!(s, " -P {prefix}")?; + write!( + s, + " -P {}", + escape::force_quote(prefix, escape::QuoteMode::Quote) + )?; } if let Some(suffix) = &spec.suffix { - write!(s, " -S {suffix}")?; + write!( + s, + " -S {}", + escape::force_quote(suffix, escape::QuoteMode::Quote) + )?; } if let Some(command_name) = command_name { @@ -430,7 +457,7 @@ pub(crate) struct CompGenCommand { #[clap(flatten)] common_args: CommonCompleteCommandArgs, - #[clap(allow_hyphen_values = true)] + // N.B. The word can only start with a hyphen if it's after a --. word: Option, } @@ -439,7 +466,9 @@ impl builtins::Command for CompGenCommand { &self, context: commands::ExecutionContext<'_>, ) -> Result { - let spec = self.common_args.create_spec(); + let mut spec = self.common_args.create_spec(); + spec.options.no_sort = true; + let token_to_complete = self.word.as_deref().unwrap_or_default(); // We unquote the token-to-be-completed before passing it to the completion system. diff --git a/brush-core/src/builtins/factory.rs b/brush-core/src/builtins/factory.rs index 3454a483..42615521 100644 --- a/brush-core/src/builtins/factory.rs +++ b/brush-core/src/builtins/factory.rs @@ -260,6 +260,7 @@ pub(crate) fn get_default_builtins( m.insert("echo".into(), builtin::()); m.insert("enable".into(), builtin::()); m.insert("let".into(), builtin::()); + m.insert("mapfile".into(), builtin::()); m.insert("printf".into(), builtin::()); m.insert("shopt".into(), builtin::()); m.insert("source".into(), special_builtin::()); @@ -277,13 +278,14 @@ pub(crate) fn get_default_builtins( m.insert("popd".into(), builtin::()); m.insert("pushd".into(), builtin::()); + // Input configuration builtins + m.insert("bind".into(), builtin::()); + // TODO: Unimplemented builtins - m.insert("bind".into(), builtin::()); m.insert("caller".into(), builtin::()); m.insert("disown".into(), builtin::()); m.insert("history".into(), builtin::()); m.insert("logout".into(), builtin::()); - m.insert("mapfile".into(), builtin::()); m.insert("readarray".into(), builtin::()); m.insert("suspend".into(), builtin::()); } diff --git a/brush-core/src/builtins/mapfile.rs b/brush-core/src/builtins/mapfile.rs new file mode 100644 index 00000000..cb26ba26 --- /dev/null +++ b/brush-core/src/builtins/mapfile.rs @@ -0,0 +1,158 @@ +use std::io::Read; + +use clap::Parser; + +use crate::{builtins, commands, env, error, openfiles, sys, variables}; + +/// Inspect and modify key bindings and other input configuration. +#[derive(Parser)] +pub(crate) struct MapFileCommand { + /// Delimiter to use (defaults to newline). + #[arg(short = 'd', default_value = "\n")] + delimiter: String, + + /// Maximum number of entries to read (0 means no limit). + #[arg(short = 'n', default_value = "0")] + max_count: i64, + + /// Index into array at which to start assignment. + #[arg(short = 'O')] + origin: Option, + + /// Number of initial entries to skip. + #[arg(short = 's', default_value = "0")] + skip_count: i64, + + /// Whether or not to remove the delimiter from each read line. + #[arg(short = 't')] + remove_delimiter: bool, + + /// File descriptor to read from (defaults to stdin). + #[arg(short = 'u', default_value = "0")] + fd: u32, + + /// Name of function to call for each group of lines. + #[arg(short = 'C')] + callback: Option, + + /// Number of lines to pass the callback for each group. + #[arg(short = 'c', default_value = "5000")] + callback_group_size: i64, + + /// Name of array to read into. + array_var_name: String, +} + +impl builtins::Command for MapFileCommand { + async fn execute( + &self, + context: commands::ExecutionContext<'_>, + ) -> Result { + if self.delimiter != "\n" { + // This will require reading a single char at a time and stoping as soon as + // the delimiter is hit. + return error::unimp("mapfile with non-newline delimiter not yet implemented"); + } + + if self.max_count != 0 { + return error::unimp("mapfile -n is not yet implemented"); + } + + if self.origin.is_some() { + // This will require merging into a potentially already-existing array. + return error::unimp("mapfile -O is not yet implemented"); + } + + if self.skip_count != 0 { + return error::unimp("mapfile -s is not yet implemented"); + } + + if self.callback.is_some() { + return error::unimp("mapfile -C is not yet implemented"); + } + + let input_file = context + .fd(self.fd) + .ok_or_else(|| error::Error::BadFileDescriptor(self.fd))?; + + // Read! + let results = self.read_entries(input_file)?; + + // Assign! + context.shell.env.update_or_add( + &self.array_var_name, + variables::ShellValueLiteral::Array(results), + |_| Ok(()), + env::EnvironmentLookup::Anywhere, + env::EnvironmentScope::Global, + )?; + + Ok(builtins::ExitCode::Success) + } +} + +impl MapFileCommand { + fn read_entries( + &self, + mut input_file: openfiles::OpenFile, + ) -> Result { + let mut entries = vec![]; + + let orig_term_attr = setup_terminal_settings(&input_file)?; + + let mut current_entry = String::new(); + let mut buffer: [u8; 1] = [0; 1]; // 1-byte buffer + + loop { + // TODO: Figure out how to restore terminal settings on error? + let n = input_file.read(&mut buffer)?; + if n == 0 { + // EOF reached. + break; + } + + let ch = buffer[0] as char; + + // Check for Ctrl+C. + if ch == '\x03' { + break; + // Ctrl+D is EOF *if* there's no entry in progress. + } else if ch == '\x04' && current_entry.is_empty() { + break; + } + + // Check for a delimiting newline char. + // TODO: Support other delimiters. + if ch == '\n' { + if !self.remove_delimiter { + current_entry.push(ch); + } + + entries.push((None, std::mem::take(&mut current_entry))); + } else { + current_entry.push(ch); + } + } + + if let Some(orig_term_attr) = &orig_term_attr { + input_file.set_term_attr(orig_term_attr)?; + } + + Ok(variables::ArrayLiteral(entries)) + } +} + +fn setup_terminal_settings( + file: &openfiles::OpenFile, +) -> Result, crate::Error> { + let orig_term_attr = file.get_term_attr()?; + if let Some(orig_term_attr) = &orig_term_attr { + let mut updated_term_attr = orig_term_attr.to_owned(); + + updated_term_attr.set_canonical(false); + updated_term_attr.set_int_signal(false); + + file.set_term_attr(&updated_term_attr)?; + } + Ok(orig_term_attr) +} diff --git a/brush-core/src/builtins/read.rs b/brush-core/src/builtins/read.rs index 87c6dc8d..9c0df044 100644 --- a/brush-core/src/builtins/read.rs +++ b/brush-core/src/builtins/read.rs @@ -195,6 +195,7 @@ impl ReadCommand { let mut buffer = [0; 1]; // 1-byte buffer let reason = loop { + // TODO: Figure out how to restore terminal settings on error? let n = input_file.read(&mut buffer)?; if n == 0 { break ReadTermination::EndOfInput; // EOF reached. diff --git a/brush-core/src/builtins/set.rs b/brush-core/src/builtins/set.rs index 9fa64485..237f23a4 100644 --- a/brush-core/src/builtins/set.rs +++ b/brush-core/src/builtins/set.rs @@ -1,8 +1,10 @@ use std::collections::HashMap; +use std::io::Write; use clap::Parser; +use itertools::Itertools; -use crate::{builtins, commands, error, namedoptions}; +use crate::{builtins, commands, error, namedoptions, variables}; builtins::minus_or_plus_flag_arg!( ExportVariablesOnModification, @@ -70,10 +72,10 @@ builtins::minus_or_plus_flag_arg!( #[derive(clap::Parser)] pub(crate) struct SetOption { - #[arg(short = 'o', name = "setopt_enable")] - enable: Vec, - #[arg(long = concat!("+o"), name = "setopt_disable", hide = true)] - disable: Vec, + #[arg(short = 'o', name = "setopt_enable", num_args=0..=1)] + enable: Option>, + #[arg(long = concat!("+o"), name = "setopt_disable", hide = true, num_args=0..=1)] + disable: Option>, } /// Manage set-based shell options. @@ -142,28 +144,36 @@ impl builtins::Command for SetCommand { ) -> Result { let mut result = builtins::ExitCode::Success; + let mut saw_option = false; + if let Some(value) = self.print_commands_and_arguments.to_bool() { context.shell.options.print_commands_and_arguments = value; + saw_option = true; } if let Some(value) = self.export_variables_on_modification.to_bool() { context.shell.options.export_variables_on_modification = value; + saw_option = true; } if let Some(value) = self.notify_job_termination_immediately.to_bool() { context.shell.options.notify_job_termination_immediately = value; + saw_option = true; } if let Some(value) = self.exit_on_nonzero_command_exit.to_bool() { context.shell.options.exit_on_nonzero_command_exit = value; + saw_option = true; } if let Some(value) = self.disable_filename_globbing.to_bool() { context.shell.options.disable_filename_globbing = value; + saw_option = true; } if let Some(value) = self.remember_command_locations.to_bool() { context.shell.options.remember_command_locations = value; + saw_option = true; } if let Some(value) = self.place_all_assignment_args_in_command_env.to_bool() { @@ -171,38 +181,47 @@ impl builtins::Command for SetCommand { .shell .options .place_all_assignment_args_in_command_env = value; + saw_option = true; } if let Some(value) = self.enable_job_control.to_bool() { context.shell.options.enable_job_control = value; + saw_option = true; } if let Some(value) = self.do_not_execute_commands.to_bool() { context.shell.options.do_not_execute_commands = value; + saw_option = true; } if let Some(value) = self.real_effective_uid_mismatch.to_bool() { context.shell.options.real_effective_uid_mismatch = value; + saw_option = true; } if let Some(value) = self.exit_after_one_command.to_bool() { context.shell.options.exit_after_one_command = value; + saw_option = true; } if let Some(value) = self.treat_unset_variables_as_error.to_bool() { context.shell.options.treat_unset_variables_as_error = value; + saw_option = true; } if let Some(value) = self.print_shell_input_lines.to_bool() { context.shell.options.print_shell_input_lines = value; + saw_option = true; } if let Some(value) = self.print_commands_and_arguments.to_bool() { context.shell.options.print_commands_and_arguments = value; + saw_option = true; } if let Some(value) = self.perform_brace_expansion.to_bool() { context.shell.options.perform_brace_expansion = value; + saw_option = true; } if let Some(value) = self @@ -213,14 +232,17 @@ impl builtins::Command for SetCommand { .shell .options .disallow_overwriting_regular_files_via_output_redirection = value; + saw_option = true; } if let Some(value) = self.shell_functions_inherit_err_trap.to_bool() { context.shell.options.shell_functions_inherit_err_trap = value; + saw_option = true; } if let Some(value) = self.enable_bang_style_history_substitution.to_bool() { context.shell.options.enable_bang_style_history_substitution = value; + saw_option = true; } if let Some(value) = self.do_not_resolve_symlinks_when_changing_dir.to_bool() { @@ -228,6 +250,7 @@ impl builtins::Command for SetCommand { .shell .options .do_not_resolve_symlinks_when_changing_dir = value; + saw_option = true; } if let Some(value) = self @@ -238,14 +261,43 @@ impl builtins::Command for SetCommand { .shell .options .shell_functions_inherit_debug_and_return_traps = value; + saw_option = true; } let mut named_options: HashMap = HashMap::new(); - for option_name in &self.set_option.disable { - named_options.insert(option_name.to_owned(), false); + if let Some(option_names) = &self.set_option.disable { + saw_option = true; + if option_names.is_empty() { + for (option_name, option_definition) in crate::namedoptions::SET_O_OPTIONS + .iter() + .sorted_by_key(|(k, _)| *k) + { + let option_value = (option_definition.getter)(&context.shell.options); + let option_value_str = if option_value { "-o" } else { "+o" }; + writeln!(context.stdout(), "set {option_value_str} {option_name}")?; + } + } else { + for option_name in option_names { + named_options.insert(option_name.to_owned(), false); + } + } } - for option_name in &self.set_option.enable { - named_options.insert(option_name.to_owned(), true); + if let Some(option_names) = &self.set_option.enable { + saw_option = true; + if option_names.is_empty() { + for (option_name, option_definition) in crate::namedoptions::SET_O_OPTIONS + .iter() + .sorted_by_key(|(k, _)| *k) + { + let option_value = (option_definition.getter)(&context.shell.options); + let option_value_str = if option_value { "on" } else { "off" }; + writeln!(context.stdout(), "{option_name:15}\t{option_value_str}")?; + } + } else { + for option_name in option_names { + named_options.insert(option_name.to_owned(), true); + } + } } for (option_name, value) in named_options { @@ -268,6 +320,34 @@ impl builtins::Command for SetCommand { } } + saw_option = saw_option || !self.positional_args.is_empty(); + + // If we *still* haven't seen any options, then we need to display all variables and + // functions. + if !saw_option { + display_all(&context)?; + } + Ok(result) } } + +fn display_all(context: &commands::ExecutionContext<'_>) -> Result<(), error::Error> { + // Display variables. + for (name, var) in context.shell.env.iter().sorted_by_key(|v| v.0) { + writeln!( + context.stdout(), + "{name}={}", + var.value().format(variables::FormatStyle::Basic)?, + )?; + } + + // Display functions... unless we're in posix compliance mode. + if !context.shell.options.posix_mode { + for (_name, registration) in context.shell.funcs.iter().sorted_by_key(|v| v.0) { + writeln!(context.stdout(), "{}", registration.definition)?; + } + } + + Ok(()) +} diff --git a/brush-core/src/commands.rs b/brush-core/src/commands.rs index fbdb1c6b..e1aef8f1 100644 --- a/brush-core/src/commands.rs +++ b/brush-core/src/commands.rs @@ -1,3 +1,4 @@ +use std::io::Write; #[cfg(unix)] use std::os::unix::process::CommandExt; use std::{borrow::Cow, ffi::OsStr, fmt::Display, process::Stdio, sync::Arc}; @@ -333,7 +334,11 @@ pub(crate) async fn execute( &args[1..], ) } else { - tracing::error!("{}: command not found", cmd_context.command_name); + writeln!( + cmd_context.stderr(), + "{}: command not found", + cmd_context.command_name + )?; Ok(CommandSpawnResult::ImmediateExit(127)) } } else { @@ -350,6 +355,7 @@ pub(crate) async fn execute( } #[allow(clippy::too_many_lines)] +#[allow(unused_variables)] pub(crate) fn execute_external_command( context: ExecutionContext<'_>, executable_path: &str, @@ -375,6 +381,9 @@ pub(crate) fn execute_external_command( // Figure out if we should be setting up a new process group. let new_pg = context.should_cmd_lead_own_process_group(); + // Save copy of stderr for errors. + let mut stderr = context.stderr(); + // Compose the std::process::Command that encapsulates what we want to launch. #[allow(unused_mut)] let mut cmd = compose_std_command( @@ -439,14 +448,15 @@ pub(crate) fn execute_external_command( } if context.shell.options.sh_mode { - tracing::error!( + writeln!( + stderr, "{}: {}: {}: not found", context.shell.shell_name.as_ref().unwrap_or(&String::new()), context.shell.get_current_input_line_number(), context.command_name - ); + )?; } else { - tracing::error!("{}: not found", context.command_name); + writeln!(stderr, "{}: not found", context.command_name)?; } Ok(CommandSpawnResult::ImmediateExit(127)) } @@ -539,3 +549,37 @@ pub(crate) async fn invoke_shell_function( Ok(CommandSpawnResult::ImmediateExit(result?.exit_code)) } + +pub(crate) async fn invoke_command_in_subshell_and_get_output( + shell: &mut Shell, + s: String, +) -> Result { + // Instantiate a subshell to run the command in. + let mut subshell = shell.clone(); + + // Set up pipe so we can read the output. + let (reader, writer) = sys::pipes::pipe()?; + subshell + .open_files + .files + .insert(1, openfiles::OpenFile::PipeWriter(writer)); + + let mut params = subshell.default_exec_params(); + params.process_group_policy = ProcessGroupPolicy::SameProcessGroup; + + // Run the command. + let result = subshell.run_string(s, ¶ms).await?; + + // Make sure the subshell and params are closed; among other things, this + // ensures they're not holding onto the write end of the pipe. + drop(subshell); + drop(params); + + // Store the status. + shell.last_exit_status = result.exit_code; + + // Extract output. + let output_str = std::io::read_to_string(reader)?; + + Ok(output_str) +} diff --git a/brush-core/src/completion.rs b/brush-core/src/completion.rs index 5cd97ad3..a1184b57 100644 --- a/brush-core/src/completion.rs +++ b/brush-core/src/completion.rs @@ -3,15 +3,16 @@ use clap::ValueEnum; use indexmap::IndexSet; use std::{ + borrow::Cow, collections::HashMap, path::{Path, PathBuf}, }; use crate::{ - env, error, jobs, namedoptions, patterns, + commands, env, error, escape, jobs, namedoptions, patterns, sys::{self, users}, - trace_categories, traps, variables, - variables::ShellValueLiteral, + trace_categories, traps, + variables::{self, ShellValueLiteral}, Shell, }; @@ -278,13 +279,29 @@ impl Spec { } } if let Some(command) = &self.command { - tracing::debug!(target: trace_categories::COMPLETION, "UNIMPLEMENTED: complete -C({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. + // Apply filter pattern, if present. Anything the filter selects gets removed. if let Some(filter_pattern) = &self.filter_pattern { if !filter_pattern.is_empty() { - tracing::debug!(target: trace_categories::COMPLETION, "UNIMPLEMENTED: complete -X (filter pattern): '{filter_pattern}'"); + let mut updated = IndexSet::new(); + + for candidate in candidates { + if !completion_filter_pattern_matches( + filter_pattern.as_str(), + candidate.as_str(), + context.token_to_complete, + shell, + )? { + updated.insert(candidate); + } + } + + candidates = updated; } } @@ -524,6 +541,67 @@ impl Spec { Ok(candidates) } + async fn call_completion_command( + &self, + shell: &mut 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::Quote); + command_line.push_str(escaped_arg.as_ref()); + } + + // Run the command. + let output = + commands::invoke_command_in_subshell_and_get_output(&mut shell, 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, @@ -534,8 +612,8 @@ impl Spec { let vars_and_values: Vec<(&str, ShellValueLiteral)> = vec![ ("COMP_LINE", context.input_line.into()), ("COMP_POINT", context.cursor_index.to_string().into()), - // TODO: ("COMP_KEY", String::from("???")), - // TODO: ("COMP_TYPE", String::from("???")), + // TODO: add COMP_KEY + // TODO: add COMP_TYPE ( "COMP_WORDS", context @@ -548,6 +626,11 @@ impl Spec { ("COMP_CWORD", context.token_index.to_string().into()), ]; + if tracing::enabled!(target: trace_categories::COMPLETION, tracing::Level::DEBUG) { + 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( @@ -573,17 +656,17 @@ impl Spec { // handler depth count to suppress any debug traps. shell.traps.handler_depth += 1; - let result = shell.invoke_function(function_name, &args).await?; + let invoke_result = shell.invoke_function(function_name, &args).await; shell.traps.handler_depth -= 1; - tracing::debug!(target: trace_categories::COMPLETION, "[called completion func '{function_name}' => {result}]"); - - // Unset any of the temporary variables. + // Make a best-effort attempt to unset the temporary variables. for var_name in vars_to_remove { - shell.env.unset(var_name)?; + let _ = shell.env.unset(var_name); } + let result = invoke_result?; + // When the function returns the special value 124, then it's a request // for us to restart the completion process. if result == 124 { @@ -878,10 +961,12 @@ impl Config { } } - fn tokenize_input_for_completion(_shell: &mut Shell, input: &str) -> Vec { + fn tokenize_input_for_completion(shell: &mut Shell, input: &str) -> Vec { // Best-effort tokenization. - // TODO: Use shell options for tokenization. - if let Ok(tokens) = brush_parser::tokenize_str(input) { + if let Ok(tokens) = brush_parser::tokenize_str_with_options( + input, + &(shell.parser_options().tokenizer_options()), + ) { return tokens; } @@ -1078,3 +1163,56 @@ fn simple_tokenize_by_delimiters(input: &str, delimiters: &[char]) -> Vec Result { + let mut pattern = pattern; + + let invert = if let Some(remaining_pattern) = pattern.strip_prefix('!') { + pattern = remaining_pattern; + true + } else { + false + }; + + 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(if invert { !matches } else { 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/brush-core/src/error.rs b/brush-core/src/error.rs index 946e0821..07c6ed22 100644 --- a/brush-core/src/error.rs +++ b/brush-core/src/error.rs @@ -184,6 +184,10 @@ pub enum Error { /// Interrupted #[error("interrupted")] Interrupted, + + /// Maximum function call depth was exceeded. + #[error("maximum function call depth exceeded")] + MaxFunctionCallDepthExceeded, } /// Convenience function for returning an error for unimplemented functionality. diff --git a/brush-core/src/escape.rs b/brush-core/src/escape.rs index 6ebea540..7aaf9c5e 100644 --- a/brush-core/src/escape.rs +++ b/brush-core/src/escape.rs @@ -173,15 +173,22 @@ pub(crate) enum QuoteMode { Quote, } +pub(crate) fn force_quote(s: &str, mode: QuoteMode) -> String { + match mode { + QuoteMode::BackslashEscape => escape_with_backslash(s, true).to_string(), + QuoteMode::Quote => escape_with_quoting(s, true).to_string(), + } +} + pub(crate) fn quote_if_needed(s: &str, mode: QuoteMode) -> Cow<'_, str> { match mode { - QuoteMode::BackslashEscape => escape_with_backslash(s), - QuoteMode::Quote => escape_with_quoting(s), + QuoteMode::BackslashEscape => escape_with_backslash(s, false), + QuoteMode::Quote => escape_with_quoting(s, false), } } -fn escape_with_backslash(s: &str) -> Cow<'_, str> { - if !s.chars().any(needs_escaping) { +fn escape_with_backslash(s: &str, force: bool) -> Cow<'_, str> { + if !force && !s.chars().any(needs_escaping) { return s.into(); } @@ -201,9 +208,9 @@ fn escape_with_backslash(s: &str) -> Cow<'_, str> { output.into() } -fn escape_with_quoting(s: &str) -> Cow<'_, str> { +fn escape_with_quoting(s: &str, force: bool) -> Cow<'_, str> { // TODO: Handle single-quote! - if s.is_empty() || s.chars().any(needs_escaping) { + if force || s.is_empty() || s.chars().any(needs_escaping) { std::format!("'{s}'").into() } else { s.into() diff --git a/brush-core/src/expansion.rs b/brush-core/src/expansion.rs index de04f739..36ec8f9f 100644 --- a/brush-core/src/expansion.rs +++ b/brush-core/src/expansion.rs @@ -6,11 +6,10 @@ use brush_parser::word::SubstringMatchKind; use itertools::Itertools; use crate::arithmetic::ExpandAndEvaluate; +use crate::commands; use crate::env; use crate::error; use crate::escape; -use crate::interp::ProcessGroupPolicy; -use crate::openfiles; use crate::patterns; use crate::prompt; use crate::shell::Shell; @@ -376,7 +375,12 @@ impl<'a> WordExpander<'a> { word: &Option, ) -> Result, error::Error> { if let Some(word) = word { - Ok(Some(self.basic_expand_pattern(word).await?)) + let pattern = self + .basic_expand_pattern(word) + .await? + .set_extended_globbing(self.parser_options.enable_extended_globbing); + + Ok(Some(pattern)) } else { Ok(None) } @@ -645,37 +649,13 @@ impl<'a> WordExpander<'a> { } brush_parser::word::WordPiece::BackquotedCommandSubstitution(s) | brush_parser::word::WordPiece::CommandSubstitution(s) => { - // Insantiate a subshell to run the command in. - let mut subshell = self.shell.clone(); - - // Set up pipe so we can read the output. - let (reader, writer) = sys::pipes::pipe()?; - subshell - .open_files - .files - .insert(1, openfiles::OpenFile::PipeWriter(writer)); - - let mut params = subshell.default_exec_params(); - params.process_group_policy = ProcessGroupPolicy::SameProcessGroup; - - // Run the command. - let result = subshell.run_string(s, ¶ms).await?; - - // Make sure the subshell and params are closed; among other things, this - // ensures they're not holding onto the write end of the pipe. - drop(subshell); - drop(params); - - // Store the status. - self.shell.last_exit_status = result.exit_code; - - // Extract output. - let output_str = std::io::read_to_string(reader)?; + let output_str = + commands::invoke_command_in_subshell_and_get_output(self.shell, s).await?; // We trim trailing newlines, per spec. - let output_str = output_str.trim_end_matches('\n'); + let trimmed = output_str.trim_end_matches('\n'); - Expansion::from(ExpansionPiece::Splittable(output_str.to_owned())) + Expansion::from(ExpansionPiece::Splittable(trimmed.to_owned())) } brush_parser::word::WordPiece::EscapeSequence(s) => { let expanded = s.strip_prefix('\\').unwrap(); @@ -835,6 +815,7 @@ impl<'a> WordExpander<'a> { } => { let expanded_parameter = self.expand_parameter(¶meter, indirect).await?; let expanded_pattern = self.basic_expand_opt_pattern(&pattern).await?; + transform_expansion(expanded_parameter, |s| { patterns::remove_smallest_matching_prefix(s.as_str(), &expanded_pattern) .map(|s| s.to_owned()) @@ -847,6 +828,7 @@ impl<'a> WordExpander<'a> { } => { let expanded_parameter = self.expand_parameter(¶meter, indirect).await?; let expanded_pattern = self.basic_expand_opt_pattern(&pattern).await?; + transform_expansion(expanded_parameter, |s| { patterns::remove_largest_matching_prefix(s.as_str(), &expanded_pattern) .map(|s| s.to_owned()) @@ -1026,17 +1008,17 @@ impl<'a> WordExpander<'a> { match_kind, } => { let expanded_parameter = self.expand_parameter(¶meter, indirect).await?; - let expanded_pattern = self.basic_expand_to_str(&pattern).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 pattern = patterns::Pattern::from(expanded_pattern.as_str()) - .set_extended_globbing(self.parser_options.enable_extended_globbing) - .set_case_insensitive(self.shell.options.case_insensitive_conditionals); - - let regex = pattern.to_regex( + let regex = expanded_pattern.to_regex( matches!(match_kind, brush_parser::word::SubstringMatchKind::Prefix), matches!(match_kind, brush_parser::word::SubstringMatchKind::Suffix), )?; diff --git a/brush-core/src/extendedtests.rs b/brush-core/src/extendedtests.rs index fc8a3dcf..bf875373 100644 --- a/brush-core/src/extendedtests.rs +++ b/brush-core/src/extendedtests.rs @@ -2,6 +2,7 @@ use brush_parser::ast; use std::path::Path; use crate::{ + arithmetic::ExpandAndEvaluate, env, error, escape, expansion, namedoptions, patterns, sys::{ fs::{MetadataExt, PathExt}, @@ -197,10 +198,16 @@ async fn apply_binary_predicate( let s = expansion::basic_expand_word(shell, left).await?; let regex = expansion::basic_expand_regex(shell, right).await?; - let (matches, captures) = if let Some(captures) = regex.matches(s.as_str())? { - (true, captures) - } else { - (false, vec![]) + 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( @@ -262,88 +269,100 @@ async fn apply_binary_predicate( Ok(left > right) } ast::BinaryPredicate::ArithmeticEqualTo => { - let left = expansion::basic_expand_word(shell, left).await?; - let right = expansion::basic_expand_word(shell, right).await?; + let unexpanded_left = ast::UnexpandedArithmeticExpr { + value: left.value.clone(), + }; + let unexpanded_right = ast::UnexpandedArithmeticExpr { + value: right.value.clone(), + }; + let left = unexpanded_left.eval(shell, false).await?; + let right = unexpanded_right.eval(shell, false).await?; if shell.options.print_commands_and_arguments { shell.trace_command(std::format!("[[ {left} {op} {right} ]]"))?; } - Ok(apply_binary_arithmetic_predicate( - left.as_str(), - right.as_str(), - |left, right| left == right, - )) + Ok(left == right) } ast::BinaryPredicate::ArithmeticNotEqualTo => { - let left = expansion::basic_expand_word(shell, left).await?; - let right = expansion::basic_expand_word(shell, right).await?; + let unexpanded_left = ast::UnexpandedArithmeticExpr { + value: left.value.clone(), + }; + let unexpanded_right = ast::UnexpandedArithmeticExpr { + value: right.value.clone(), + }; + let left = unexpanded_left.eval(shell, false).await?; + let right = unexpanded_right.eval(shell, false).await?; if shell.options.print_commands_and_arguments { shell.trace_command(std::format!("[[ {left} {op} {right} ]]"))?; } - Ok(apply_binary_arithmetic_predicate( - left.as_str(), - right.as_str(), - |left, right| left != right, - )) + Ok(left != right) } ast::BinaryPredicate::ArithmeticLessThan => { - let left = expansion::basic_expand_word(shell, left).await?; - let right = expansion::basic_expand_word(shell, right).await?; + let unexpanded_left = ast::UnexpandedArithmeticExpr { + value: left.value.clone(), + }; + let unexpanded_right = ast::UnexpandedArithmeticExpr { + value: right.value.clone(), + }; + let left = unexpanded_left.eval(shell, false).await?; + let right = unexpanded_right.eval(shell, false).await?; if shell.options.print_commands_and_arguments { shell.trace_command(std::format!("[[ {left} {op} {right} ]]"))?; } - Ok(apply_binary_arithmetic_predicate( - left.as_str(), - right.as_str(), - |left, right| left < right, - )) + Ok(left < right) } ast::BinaryPredicate::ArithmeticLessThanOrEqualTo => { - let left = expansion::basic_expand_word(shell, left).await?; - let right = expansion::basic_expand_word(shell, right).await?; + let unexpanded_left = ast::UnexpandedArithmeticExpr { + value: left.value.clone(), + }; + let unexpanded_right = ast::UnexpandedArithmeticExpr { + value: right.value.clone(), + }; + let left = unexpanded_left.eval(shell, false).await?; + let right = unexpanded_right.eval(shell, false).await?; if shell.options.print_commands_and_arguments { shell.trace_command(std::format!("[[ {left} {op} {right} ]]"))?; } - Ok(apply_binary_arithmetic_predicate( - left.as_str(), - right.as_str(), - |left, right| left <= right, - )) + Ok(left <= right) } ast::BinaryPredicate::ArithmeticGreaterThan => { - let left = expansion::basic_expand_word(shell, left).await?; - let right = expansion::basic_expand_word(shell, right).await?; + let unexpanded_left = ast::UnexpandedArithmeticExpr { + value: left.value.clone(), + }; + let unexpanded_right = ast::UnexpandedArithmeticExpr { + value: right.value.clone(), + }; + let left = unexpanded_left.eval(shell, false).await?; + let right = unexpanded_right.eval(shell, false).await?; if shell.options.print_commands_and_arguments { shell.trace_command(std::format!("[[ {left} {op} {right} ]]"))?; } - Ok(apply_binary_arithmetic_predicate( - left.as_str(), - right.as_str(), - |left, right| left > right, - )) + Ok(left > right) } ast::BinaryPredicate::ArithmeticGreaterThanOrEqualTo => { - let left = expansion::basic_expand_word(shell, left).await?; - let right = expansion::basic_expand_word(shell, right).await?; + let unexpanded_left = ast::UnexpandedArithmeticExpr { + value: left.value.clone(), + }; + let unexpanded_right = ast::UnexpandedArithmeticExpr { + value: right.value.clone(), + }; + let left = unexpanded_left.eval(shell, false).await?; + let right = unexpanded_right.eval(shell, false).await?; if shell.options.print_commands_and_arguments { shell.trace_command(std::format!("[[ {left} {op} {right} ]]"))?; } - Ok(apply_binary_arithmetic_predicate( - left.as_str(), - right.as_str(), - |left, right| left >= right, - )) + 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 @@ -358,7 +377,11 @@ async fn apply_binary_predicate( if shell.options.print_commands_and_arguments { let expanded_right = expansion::basic_expand_word(shell, right).await?; - shell.trace_command(std::format!("[[ {s} {op} {expanded_right} ]]"))?; + let escaped_right = escape::quote_if_needed( + expanded_right.as_str(), + escape::QuoteMode::BackslashEscape, + ); + shell.trace_command(std::format!("[[ {s} {op} {escaped_right} ]]"))?; } pattern.exactly_matches(s.as_str()) @@ -372,7 +395,11 @@ async fn apply_binary_predicate( if shell.options.print_commands_and_arguments { let expanded_right = expansion::basic_expand_word(shell, right).await?; - shell.trace_command(std::format!("[[ {s} {op} {expanded_right} ]]"))?; + let escaped_right = escape::quote_if_needed( + expanded_right.as_str(), + escape::QuoteMode::BackslashEscape, + ); + shell.trace_command(std::format!("[[ {s} {op} {escaped_right} ]]"))?; } let eq = pattern.exactly_matches(s.as_str())?; @@ -405,33 +432,31 @@ pub(crate) fn apply_binary_predicate_to_strs( // TODO: According to docs, should be lexicographical order of the current locale. Ok(left > right) } - ast::BinaryPredicate::ArithmeticEqualTo => Ok(apply_binary_arithmetic_predicate( + ast::BinaryPredicate::ArithmeticEqualTo => Ok(apply_test_binary_arithmetic_predicate( left, right, |left, right| left == right, )), - ast::BinaryPredicate::ArithmeticNotEqualTo => Ok(apply_binary_arithmetic_predicate( + ast::BinaryPredicate::ArithmeticNotEqualTo => Ok(apply_test_binary_arithmetic_predicate( left, right, |left, right| left != right, )), - ast::BinaryPredicate::ArithmeticLessThan => Ok(apply_binary_arithmetic_predicate( + ast::BinaryPredicate::ArithmeticLessThan => Ok(apply_test_binary_arithmetic_predicate( left, right, |left, right| left < right, )), - ast::BinaryPredicate::ArithmeticLessThanOrEqualTo => Ok(apply_binary_arithmetic_predicate( - left, - right, - |left, right| left <= right, - )), - ast::BinaryPredicate::ArithmeticGreaterThan => Ok(apply_binary_arithmetic_predicate( + 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_binary_arithmetic_predicate(left, right, |left, right| left >= right), + apply_test_binary_arithmetic_predicate(left, right, |left, right| left >= right), ), ast::BinaryPredicate::StringExactlyMatchesPattern => { let pattern = patterns::Pattern::from(right) @@ -452,7 +477,11 @@ pub(crate) fn apply_binary_predicate_to_strs( } } -fn apply_binary_arithmetic_predicate(left: &str, right: &str, op: fn(i64, i64) -> bool) -> bool { +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(); diff --git a/brush-core/src/interp.rs b/brush-core/src/interp.rs index 4ce77c6e..a89acd9b 100644 --- a/brush-core/src/interp.rs +++ b/brush-core/src/interp.rs @@ -317,10 +317,20 @@ async fn spawn_pipeline_processes( let mut process_group_id: Option = None; for (current_pipeline_index, command) in pipeline.seq.iter().enumerate() { - // If there's only one command in the pipeline, then we run directly in the current - // shell. Otherwise, we spawn a separate subshell for each command in the - // pipeline. - if pipeline_len > 1 { + // + // 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, diff --git a/brush-core/src/options.rs b/brush-core/src/options.rs index 66da327a..6e9c32da 100644 --- a/brush-core/src/options.rs +++ b/brush-core/src/options.rs @@ -147,11 +147,11 @@ pub struct RuntimeOptions { /// 'mailwarn' pub mail_warn: bool, /// `no_empty_cmd_completion` - pub case_insensitive_pathname_expansion: bool, + pub no_empty_cmd_completion: bool, /// 'nocaseglob' - pub case_insensitive_conditionals: bool, + pub case_insensitive_pathname_expansion: bool, /// 'nocasematch' - pub no_empty_cmd_completion: bool, + pub case_insensitive_conditionals: bool, /// 'nullglob' pub expand_non_matching_patterns_to_null: bool, /// 'progcomp' @@ -177,6 +177,8 @@ pub struct RuntimeOptions { 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 { @@ -210,6 +212,7 @@ impl RuntimeOptions { 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() }; diff --git a/brush-core/src/patterns.rs b/brush-core/src/patterns.rs index 7ad501bc..5c1a9de8 100644 --- a/brush-core/src/patterns.rs +++ b/brush-core/src/patterns.rs @@ -382,7 +382,6 @@ fn pattern_to_regex_str( /// /// * `s` - The string to remove the prefix from. /// * `pattern` - The pattern to match. -/// * `enable_extended_globbing` - Whether or not to enable extended globbing (extglob). #[allow(clippy::ref_option)] pub(crate) fn remove_largest_matching_prefix<'a>( s: &'a str, @@ -405,7 +404,6 @@ pub(crate) fn remove_largest_matching_prefix<'a>( /// /// * `s` - The string to remove the prefix from. /// * `pattern` - The pattern to match. -/// * `enable_extended_globbing` - Whether or not to enable extended globbing (extglob). #[allow(clippy::ref_option)] pub(crate) fn remove_smallest_matching_prefix<'a>( s: &'a str, @@ -428,7 +426,6 @@ pub(crate) fn remove_smallest_matching_prefix<'a>( /// /// * `s` - The string to remove the suffix from. /// * `pattern` - The pattern to match. -/// * `enable_extended_globbing` - Whether or not to enable extended globbing (extglob). #[allow(clippy::ref_option)] pub(crate) fn remove_largest_matching_suffix<'a>( s: &'a str, @@ -451,7 +448,6 @@ pub(crate) fn remove_largest_matching_suffix<'a>( /// /// * `s` - The string to remove the suffix from. /// * `pattern` - The pattern to match. -/// * `enable_extended_globbing` - Whether or not to enable extended globbing (extglob). #[allow(clippy::ref_option)] pub(crate) fn remove_smallest_matching_suffix<'a>( s: &'a str, diff --git a/brush-core/src/regex.rs b/brush-core/src/regex.rs index f5675687..8c44ebc7 100644 --- a/brush-core/src/regex.rs +++ b/brush-core/src/regex.rs @@ -1,3 +1,5 @@ +#![allow(clippy::needless_pass_by_value)] + use std::borrow::Cow; use crate::error; @@ -73,21 +75,64 @@ impl Regex { } } -#[allow(clippy::needless_pass_by_value)] #[cached::proc_macro::cached(size = 64, result = true)] pub(crate) fn compile_regex( regex_str: String, case_insensitive: bool, ) -> Result { - let mut builder = fancy_regex::RegexBuilder::new(regex_str.as_str()); + // Handle identified cases where a shell-supported regex isn't supported directly by + // `fancy_regex` -- specifically, adding missing escape characters. + let regex_str = add_missing_escape_chars_to_regex(regex_str.as_str()); + + 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::Error::InvalidRegexError(e, regex_str)), + Err(e) => Err(error::Error::InvalidRegexError(e, regex_str.to_string())), } } +fn add_missing_escape_chars_to_regex(s: &str) -> Cow { + // 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 { let mut result = String::new(); @@ -110,3 +155,20 @@ fn regex_char_is_special(c: char) -> bool { '\\' | '^' | '$' | '.' | '|' | '?' | '*' | '+' | '(' | ')' | '[' | ']' | '{' | '}' ) } + +#[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/brush-core/src/shell.rs b/brush-core/src/shell.rs index 7e5db481..19ea0201 100644 --- a/brush-core/src/shell.rs +++ b/brush-core/src/shell.rs @@ -151,6 +151,8 @@ pub struct CreateOptions { pub sh_mode: bool, /// Whether to print verbose output. pub verbose: bool, + /// Maximum function call depth. + pub max_function_call_depth: Option, } /// Represents an active shell function call. @@ -235,7 +237,7 @@ impl Shell { env.set_global("IFS", ShellVariable::new(" \t\n".into()))?; env.set_global( "COMP_WORDBREAKS", - ShellVariable::new(" \t\n\"\'><=;|&(:".into()), + ShellVariable::new(" \t\n\"\'@><=;|&(:".into()), )?; // getopts vars @@ -273,6 +275,15 @@ impl Shell { )?; } + // Update PWD to 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 = std::env::current_dir()?.to_string_lossy().to_string(); + let mut pwd_var = ShellVariable::new(pwd.into()); + pwd_var.export(); + env.set_global("PWD", pwd_var)?; + + // Set version info. if !options.sh_mode { const BASH_MAJOR: u32 = 5; const BASH_MINOR: u32 = 2; @@ -818,6 +829,18 @@ impl Shell { 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.len() >= max_call_depth { + return Err(error::Error::MaxFunctionCallDepthExceeded); + } + } + + if tracing::enabled!(target: trace_categories::FUNCTIONS, tracing::Level::DEBUG) { + let depth = self.function_call_stack.len(); + let prefix = repeated_char_str(' ', depth); + tracing::debug!(target: trace_categories::FUNCTIONS, "Entering func [depth={depth}]: {prefix}{name}"); + } + self.function_call_stack.push_front(FunctionCall { function_name: name.to_owned(), function_definition: function_def.clone(), @@ -831,7 +854,15 @@ impl 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)?; - self.function_call_stack.pop_front(); + + if let Some(exited_call) = self.function_call_stack.pop_front() { + if tracing::enabled!(target: trace_categories::FUNCTIONS, tracing::Level::DEBUG) { + let depth = self.function_call_stack.len(); + let prefix = repeated_char_str(' ', depth); + tracing::debug!(target: trace_categories::FUNCTIONS, "Exiting func [depth={depth}]: {prefix}{}", exited_call.function_name); + } + } + self.update_funcname_var()?; Ok(()) } @@ -1209,3 +1240,7 @@ fn parse_string_impl( tracing::debug!(target: trace_categories::PARSE, "Parsing string as program..."); parser.parse() } + +fn repeated_char_str(c: char, count: usize) -> String { + (0..count).map(|_| c).collect() +} diff --git a/brush-core/src/sys/stubs/signal.rs b/brush-core/src/sys/stubs/signal.rs index 6b816cfe..91b10c0b 100644 --- a/brush-core/src/sys/stubs/signal.rs +++ b/brush-core/src/sys/stubs/signal.rs @@ -1,4 +1,4 @@ -use crate::{error, sys, traps}; +use crate::{error, sys}; pub(crate) fn continue_process(_pid: sys::process::ProcessId) -> Result<(), error::Error> { error::unimp("continue process") diff --git a/brush-core/src/sys/stubs/terminal.rs b/brush-core/src/sys/stubs/terminal.rs index 71f1aeb8..9bb748d7 100644 --- a/brush-core/src/sys/stubs/terminal.rs +++ b/brush-core/src/sys/stubs/terminal.rs @@ -28,10 +28,6 @@ pub(crate) fn set_term_attr_now( Ok(()) } -pub(crate) fn is_stdin_a_terminal() -> Result { - Ok(false) -} - pub(crate) fn get_parent_process_id() -> Option { None } diff --git a/brush-core/src/sys/unix/terminal.rs b/brush-core/src/sys/unix/terminal.rs index 643566f8..d710661b 100644 --- a/brush-core/src/sys/unix/terminal.rs +++ b/brush-core/src/sys/unix/terminal.rs @@ -1,5 +1,5 @@ use crate::{error, sys}; -use std::os::fd::{AsFd, AsRawFd}; +use std::{io::IsTerminal, os::fd::AsFd}; #[derive(Clone)] pub(crate) struct TerminalSettings { @@ -42,11 +42,6 @@ pub(crate) fn set_term_attr_now( Ok(()) } -pub(crate) fn is_stdin_a_terminal() -> Result { - let result = nix::unistd::isatty(std::io::stdin().as_raw_fd())?; - Ok(result) -} - #[allow(clippy::unnecessary_wraps)] pub(crate) fn get_parent_process_id() -> Option { Some(nix::unistd::getppid().as_raw()) @@ -69,7 +64,7 @@ pub(crate) fn move_to_foreground(pid: sys::process::ProcessId) -> Result<(), err } pub(crate) fn move_self_to_foreground() -> Result<(), error::Error> { - if is_stdin_a_terminal()? { + 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 diff --git a/brush-core/src/sys/unix/users.rs b/brush-core/src/sys/unix/users.rs index e683611f..4bf71442 100644 --- a/brush-core/src/sys/unix/users.rs +++ b/brush-core/src/sys/unix/users.rs @@ -42,14 +42,14 @@ pub(crate) fn get_current_username() -> Result { #[allow(clippy::unnecessary_wraps)] pub(crate) fn get_all_users() -> Result, error::Error> { - // TODO: implement this + // TODO: uzers::all_users() is available but unsafe tracing::debug!("UNIMPLEMENTED: get_all_users"); Ok(vec![]) } #[allow(clippy::unnecessary_wraps)] pub(crate) fn get_all_groups() -> Result, error::Error> { - // TODO: implement this + // TODO: uzers::all_groups() is available but unsafe tracing::debug!("UNIMPLEMENTED: get_all_groups"); Ok(vec![]) } diff --git a/brush-core/src/trace_categories.rs b/brush-core/src/trace_categories.rs index 9b579e28..8f379994 100644 --- a/brush-core/src/trace_categories.rs +++ b/brush-core/src/trace_categories.rs @@ -4,3 +4,4 @@ pub(crate) const EXPANSION: &str = "expansion"; pub(crate) const JOBS: &str = "jobs"; pub(crate) const PARSE: &str = "parse"; pub(crate) const PATTERN: &str = "pattern"; +pub(crate) const FUNCTIONS: &str = "functions"; diff --git a/brush-interactive/Cargo.toml b/brush-interactive/Cargo.toml index f8ca392e..c9a79902 100644 --- a/brush-interactive/Cargo.toml +++ b/brush-interactive/Cargo.toml @@ -15,8 +15,9 @@ rust-version.workspace = true bench = false [features] -default = ["basic"] -basic = [] +default = [] +basic = ["dep:crossterm"] +minimal = [] reedline = ["dep:reedline", "dep:nu-ansi-term"] [lints] @@ -26,6 +27,7 @@ workspace = true async-trait = "0.1.83" brush-parser = { version = "^0.2.11", path = "../brush-parser" } brush-core = { version = "^0.2.13", path = "../brush-core" } +crossterm = { version = "0.28.1", features = ["serde"], optional = true } indexmap = "2.7.0" nu-ansi-term = { version = "0.50.1", optional = true } reedline = { version = "0.37.0", optional = true } diff --git a/brush-interactive/src/basic/basic_shell.rs b/brush-interactive/src/basic/basic_shell.rs index f86dff11..b18eb99b 100644 --- a/brush-interactive/src/basic/basic_shell.rs +++ b/brush-interactive/src/basic/basic_shell.rs @@ -1,12 +1,15 @@ use std::io::{IsTerminal, Write}; use crate::{ + completion, interactive_shell::{InteractivePrompt, InteractiveShell, ReadResult}, ShellError, }; -/// Represents a minimal shell capable of taking commands from standard input -/// and reporting results to standard output and standard error streams. +use super::term_line_reader; + +/// Represents a basic shell capable of interactive usage, with primitive support +/// for completion and test-focused automation via pexpect and similar technologies. pub struct BasicShell { shell: brush_core::Shell, } @@ -35,32 +38,29 @@ impl InteractiveShell for BasicShell { } fn read_line(&mut self, prompt: InteractivePrompt) -> Result { - if self.should_display_prompt() { - print!("{}", prompt.prompt); - let _ = std::io::stdout().flush(); - } + self.display_prompt(&prompt)?; - let stdin = std::io::stdin(); let mut result = String::new(); - while result.is_empty() || !self.is_valid_input(result.as_str()) { - let mut read_buffer = String::new(); - let bytes_read = stdin - .read_line(&mut read_buffer) - .map_err(|_err| ShellError::InputError)?; - - if bytes_read == 0 { - break; + loop { + match self.read_input_line(&prompt)? { + ReadResult::Input(s) => { + result.push_str(s.as_str()); + if self.is_valid_input(result.as_str()) { + break; + } + } + ReadResult::Eof => { + if result.is_empty() { + return Ok(ReadResult::Eof); + } + break; + } + ReadResult::Interrupted => return Ok(ReadResult::Interrupted), } - - result.push_str(read_buffer.as_str()); } - if result.is_empty() { - Ok(ReadResult::Eof) - } else { - Ok(ReadResult::Input(result)) - } + Ok(ReadResult::Input(result)) } fn update_history(&mut self) -> Result<(), ShellError> { @@ -74,6 +74,34 @@ impl BasicShell { std::io::stdin().is_terminal() } + fn display_prompt(&self, prompt: &InteractivePrompt) -> Result<(), ShellError> { + if self.should_display_prompt() { + eprint!("{}", prompt.prompt); + std::io::stderr().flush()?; + } + + Ok(()) + } + + fn read_input_line(&mut self, prompt: &InteractivePrompt) -> Result { + if std::io::stdin().is_terminal() { + term_line_reader::read_line(prompt.prompt.as_str(), |line, cursor| { + self.generate_completions(line, cursor) + }) + } else { + let mut input = String::new(); + let bytes_read = std::io::stdin() + .read_line(&mut input) + .map_err(|_err| ShellError::InputError)?; + + if bytes_read == 0 { + Ok(ReadResult::Eof) + } else { + Ok(ReadResult::Input(input)) + } + } + } + fn is_valid_input(&self, input: &str) -> bool { match self.shell.parse_string(input.to_owned()) { Err(brush_parser::ParseError::Tokenizing { inner, position: _ }) @@ -85,4 +113,23 @@ impl BasicShell { _ => true, } } + + fn generate_completions( + &mut self, + line: &str, + cursor: usize, + ) -> Result { + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current() + .block_on(self.generate_completions_async(line, cursor)) + }) + } + + async fn generate_completions_async( + &mut self, + line: &str, + cursor: usize, + ) -> Result { + Ok(completion::complete_async(&mut self.shell, line, cursor).await) + } } diff --git a/brush-interactive/src/basic/mod.rs b/brush-interactive/src/basic/mod.rs index 2631530a..4404fa61 100644 --- a/brush-interactive/src/basic/mod.rs +++ b/brush-interactive/src/basic/mod.rs @@ -1,4 +1,6 @@ mod basic_shell; +mod raw_mode; +mod term_line_reader; #[allow(clippy::module_name_repetitions)] pub use basic_shell::BasicShell; diff --git a/brush-interactive/src/basic/raw_mode.rs b/brush-interactive/src/basic/raw_mode.rs new file mode 100644 index 00000000..a3e74363 --- /dev/null +++ b/brush-interactive/src/basic/raw_mode.rs @@ -0,0 +1,34 @@ +use crate::ShellError; + +pub(crate) struct RawModeToggle { + initial: bool, +} + +impl RawModeToggle { + pub fn new() -> Result { + let initial = crossterm::terminal::is_raw_mode_enabled()?; + Ok(Self { initial }) + } + + #[allow(clippy::unused_self)] + pub fn enable(&self) -> Result<(), ShellError> { + crossterm::terminal::enable_raw_mode()?; + Ok(()) + } + + #[allow(clippy::unused_self)] + pub fn disable(&self) -> Result<(), ShellError> { + crossterm::terminal::disable_raw_mode()?; + Ok(()) + } +} + +impl Drop for RawModeToggle { + fn drop(&mut self) { + let _ = if self.initial { + crossterm::terminal::enable_raw_mode() + } else { + crossterm::terminal::disable_raw_mode() + }; + } +} diff --git a/brush-interactive/src/basic/term_line_reader.rs b/brush-interactive/src/basic/term_line_reader.rs new file mode 100644 index 00000000..14cce83b --- /dev/null +++ b/brush-interactive/src/basic/term_line_reader.rs @@ -0,0 +1,272 @@ +// +// This module is intentionally limited, and does not have all the bells and whistles. We wan +// enough here that we can use it in the basic shell for (p)expect/pty-style testing of +// completion, and without using VT100-style escape sequences for cursor movement and display. +// + +use crossterm::ExecutableCommand; +use std::io::Write; + +use super::raw_mode; +use crate::{ReadResult, ShellError}; + +const BACKSPACE: char = 8u8 as char; + +pub(crate) fn read_line( + prompt: &str, + mut completion_handler: impl FnMut( + &str, + usize, + ) -> Result, +) -> Result { + let mut state = ReadLineState::new(prompt)?; + + loop { + state.raw_mode.enable()?; + if let crossterm::event::Event::Key(event) = crossterm::event::read()? { + if let Some(result) = state.on_key(event, &mut completion_handler)? { + return Ok(result); + } + } + } +} + +struct ReadLineState<'a> { + line: String, + cursor: usize, + prompt: &'a str, + raw_mode: raw_mode::RawModeToggle, +} + +impl<'a> ReadLineState<'a> { + fn new(prompt: &'a str) -> Result { + Ok(Self { + line: String::new(), + cursor: 0, + prompt, + raw_mode: raw_mode::RawModeToggle::new()?, + }) + } + + fn display_prompt(&self) -> Result<(), ShellError> { + self.raw_mode.disable()?; + eprint!("{}", self.prompt); + self.raw_mode.enable()?; + std::io::stderr().flush()?; + Ok(()) + } + + fn on_key( + &mut self, + event: crossterm::event::KeyEvent, + mut completion_handler: impl FnMut( + &str, + usize, + ) + -> Result, + ) -> Result, ShellError> { + match (event.modifiers, event.code) { + (_, crossterm::event::KeyCode::Enter) + | (crossterm::event::KeyModifiers::CONTROL, crossterm::event::KeyCode::Char('j')) => { + self.display_newline()?; + let line = std::mem::take(&mut self.line); + return Ok(Some(ReadResult::Input(line))); + } + ( + crossterm::event::KeyModifiers::SHIFT | crossterm::event::KeyModifiers::NONE, + crossterm::event::KeyCode::Char(c), + ) => { + self.on_char(c)?; + } + (crossterm::event::KeyModifiers::CONTROL, crossterm::event::KeyCode::Char('c')) => { + self.raw_mode.disable()?; + eprintln!("^C"); + return Ok(Some(ReadResult::Interrupted)); + } + (crossterm::event::KeyModifiers::CONTROL, crossterm::event::KeyCode::Char('d')) => { + if self.line.is_empty() { + self.raw_mode.disable()?; + eprintln!(); + return Ok(Some(ReadResult::Eof)); + } + } + (crossterm::event::KeyModifiers::CONTROL, crossterm::event::KeyCode::Char('l')) => { + self.clear_screen()?; + } + (_, crossterm::event::KeyCode::Backspace) => { + self.backspace()?; + } + (_, crossterm::event::KeyCode::Left) => { + self.move_cursor_left()?; + } + (_, crossterm::event::KeyCode::Tab) => { + let completions = completion_handler(self.line.as_str(), self.cursor)?; + self.handle_completions(&completions)?; + } + _ => (), + } + + Ok(None) + } + + fn on_char(&mut self, c: char) -> Result<(), ShellError> { + self.line.insert(self.cursor, c); + self.cursor += 1; + eprint!("{c}"); + std::io::stderr().flush()?; + + Ok(()) + } + + fn display_newline(&mut self) -> Result<(), ShellError> { + self.raw_mode.disable()?; + eprintln!(); + self.raw_mode.enable()?; + std::io::stderr().flush()?; + + Ok(()) + } + + fn clear_screen(&mut self) -> Result<(), ShellError> { + std::io::stderr() + .execute(crossterm::terminal::Clear( + crossterm::terminal::ClearType::All, + ))? + .execute(crossterm::cursor::MoveTo(0, 0))?; + + self.display_prompt()?; + eprint!("{}", self.line.as_str()); + std::io::stderr().flush()?; + Ok(()) + } + + fn backspace(&mut self) -> Result<(), ShellError> { + if self.cursor == 0 { + return Ok(()); + } + + self.cursor -= 1; + self.line.remove(self.cursor); + self.raw_mode.disable()?; + eprint!("{BACKSPACE}"); + eprint!("{} ", &self.line[self.cursor..]); + eprint!( + "{}", + repeated_char_str(BACKSPACE, self.line.len() + 1 - self.cursor) + ); + self.raw_mode.enable()?; + std::io::stderr().flush()?; + Ok(()) + } + + fn move_cursor_left(&mut self) -> Result<(), ShellError> { + self.raw_mode.disable()?; + eprint!("{BACKSPACE}"); + self.raw_mode.enable()?; + std::io::stderr().flush()?; + + if self.cursor == 0 { + return Ok(()); + } + + self.cursor -= 1; + + Ok(()) + } + + fn handle_completions( + &mut self, + completions: &brush_core::completion::Completions, + ) -> Result<(), ShellError> { + if completions.candidates.is_empty() { + // Do nothing + Ok(()) + } else if completions.candidates.len() == 1 { + self.handle_single_completion(completions) + } else { + self.handle_multiple_completions(completions) + } + } + + #[allow(clippy::unwrap_in_result)] + fn handle_single_completion( + &mut self, + completions: &brush_core::completion::Completions, + ) -> Result<(), ShellError> { + // Apply replacement directly. + let candidate = completions.candidates.iter().next().unwrap(); + if completions.insertion_index + completions.delete_count != self.cursor { + return Ok(()); + } + + let mut delete_count = completions.delete_count; + let mut redisplay_offset = completions.insertion_index; + + // Don't bother erasing and re-writing the portion of the + // completion's prefix that + // is identical to what we already had in the token-being-completed. + if delete_count > 0 + && candidate.starts_with(&self.line[redisplay_offset..redisplay_offset + delete_count]) + { + redisplay_offset += delete_count; + delete_count = 0; + } + + let mut updated_line = self.line.clone(); + updated_line.truncate(completions.insertion_index); + updated_line.push_str(candidate); + updated_line.push_str(&self.line[self.cursor..]); + self.line = updated_line; + + self.cursor = completions.insertion_index + candidate.len(); + + let move_left = repeated_char_str(BACKSPACE, delete_count); + self.raw_mode.disable()?; + eprint!("{move_left}{}", &self.line[redisplay_offset..]); + + // TODO: Remove trailing chars if completion is shorter? + eprint!( + "{}", + repeated_char_str(BACKSPACE, self.line.len() - self.cursor) + ); + + self.raw_mode.enable()?; + std::io::stderr().flush()?; + + Ok(()) + } + + fn handle_multiple_completions( + &mut self, + completions: &brush_core::completion::Completions, + ) -> Result<(), ShellError> { + // Display replacements. + self.raw_mode.disable()?; + eprintln!(); + for candidate in &completions.candidates { + eprintln!("{candidate}"); + } + self.raw_mode.enable()?; + std::io::stderr().flush()?; + + // Re-display prompt. + self.display_prompt()?; + + // Re-display line so far. + self.raw_mode.disable()?; + eprint!( + "{}{}", + self.line, + repeated_char_str(BACKSPACE, self.line.len() - self.cursor) + ); + + self.raw_mode.enable()?; + std::io::stderr().flush()?; + + Ok(()) + } +} + +fn repeated_char_str(c: char, count: usize) -> String { + (0..count).map(|_| c).collect() +} diff --git a/brush-interactive/src/lib.rs b/brush-interactive/src/lib.rs index f36b666d..3c405162 100644 --- a/brush-interactive/src/lib.rs +++ b/brush-interactive/src/lib.rs @@ -28,4 +28,10 @@ mod basic; #[cfg(feature = "basic")] pub use basic::BasicShell; +// Minimal shell +#[cfg(feature = "minimal")] +mod minimal; +#[cfg(feature = "minimal")] +pub use minimal::MinimalShell; + mod trace_categories; diff --git a/brush-interactive/src/minimal/minimal_shell.rs b/brush-interactive/src/minimal/minimal_shell.rs new file mode 100644 index 00000000..afddbf1f --- /dev/null +++ b/brush-interactive/src/minimal/minimal_shell.rs @@ -0,0 +1,106 @@ +use std::io::{IsTerminal, Write}; + +use crate::{ + interactive_shell::{InteractivePrompt, InteractiveShell, ReadResult}, + ShellError, +}; + +/// Represents a minimal shell capable of taking commands from standard input +/// and reporting results to standard output and standard error streams. +pub struct MinimalShell { + shell: brush_core::Shell, +} + +impl MinimalShell { + /// Returns a new interactive shell instance, created with the provided options. + /// + /// # Arguments + /// + /// * `options` - Options for creating the interactive shell. + pub async fn new(options: &crate::Options) -> Result { + let shell = brush_core::Shell::new(&options.shell).await?; + Ok(Self { shell }) + } +} + +impl InteractiveShell for MinimalShell { + /// Returns an immutable reference to the inner shell object. + fn shell(&self) -> impl AsRef { + self.shell.as_ref() + } + + /// Returns a mutable reference to the inner shell object. + fn shell_mut(&mut self) -> impl AsMut { + self.shell.as_mut() + } + + fn read_line(&mut self, prompt: InteractivePrompt) -> Result { + self.display_prompt(&prompt)?; + + let mut result = String::new(); + + loop { + match Self::read_input_line()? { + ReadResult::Input(s) => { + result.push_str(s.as_str()); + if self.is_valid_input(result.as_str()) { + break; + } + } + ReadResult::Eof => break, + ReadResult::Interrupted => return Ok(ReadResult::Interrupted), + } + } + + if result.is_empty() { + Ok(ReadResult::Eof) + } else { + Ok(ReadResult::Input(result)) + } + } + + fn update_history(&mut self) -> Result<(), ShellError> { + Ok(()) + } +} + +impl MinimalShell { + #[allow(clippy::unused_self)] + fn should_display_prompt(&self) -> bool { + std::io::stdin().is_terminal() + } + + fn display_prompt(&self, prompt: &InteractivePrompt) -> Result<(), ShellError> { + if self.should_display_prompt() { + eprint!("{}", prompt.prompt); + std::io::stderr().flush()?; + } + + Ok(()) + } + + fn read_input_line() -> Result { + let mut input = String::new(); + let bytes_read = std::io::stdin() + .read_line(&mut input) + .map_err(|_err| ShellError::InputError)?; + + if bytes_read == 0 { + Ok(ReadResult::Eof) + } else { + Ok(ReadResult::Input(input)) + } + } + + fn is_valid_input(&self, input: &str) -> bool { + match self.shell.parse_string(input.to_owned()) { + Err(brush_parser::ParseError::Tokenizing { inner, position: _ }) + if inner.is_incomplete() => + { + false + } + Err(brush_parser::ParseError::ParsingAtEndOfInput) => false, + _ => true, + } + } +} diff --git a/brush-interactive/src/minimal/mod.rs b/brush-interactive/src/minimal/mod.rs new file mode 100644 index 00000000..e157ed9c --- /dev/null +++ b/brush-interactive/src/minimal/mod.rs @@ -0,0 +1,4 @@ +mod minimal_shell; + +#[allow(clippy::module_name_repetitions)] +pub use minimal_shell::MinimalShell; diff --git a/brush-interactive/src/reedline/highlighter.rs b/brush-interactive/src/reedline/highlighter.rs index 0361d6ce..7f381873 100644 --- a/brush-interactive/src/reedline/highlighter.rs +++ b/brush-interactive/src/reedline/highlighter.rs @@ -121,7 +121,10 @@ impl<'a> StyledInputLine<'a> { fn style_and_append_program(&mut self, line: &str, global_offset: usize) { #[allow(clippy::cast_sign_loss)] - if let Ok(tokens) = brush_parser::tokenize_str(line) { + if let Ok(tokens) = brush_parser::tokenize_str_with_options( + line, + &(self.shell.parser_options().tokenizer_options()), + ) { let mut saw_command_token = false; for token in tokens { match token { diff --git a/brush-interactive/src/trace_categories.rs b/brush-interactive/src/trace_categories.rs index a666ef7a..b4fab328 100644 --- a/brush-interactive/src/trace_categories.rs +++ b/brush-interactive/src/trace_categories.rs @@ -1 +1,3 @@ +#![allow(dead_code)] + pub(crate) const COMPLETION: &str = "completion"; diff --git a/brush-parser/src/lib.rs b/brush-parser/src/lib.rs index 55bb9dde..3f902291 100644 --- a/brush-parser/src/lib.rs +++ b/brush-parser/src/lib.rs @@ -15,4 +15,6 @@ mod tokenizer; pub use error::{ParseError, TestCommandParseError, WordParseError}; pub use parser::{parse_tokens, Parser, ParserOptions, SourceInfo}; -pub use tokenizer::{tokenize_str, unquote_str, SourcePosition, Token, TokenLocation}; +pub use tokenizer::{ + tokenize_str, tokenize_str_with_options, unquote_str, SourcePosition, Token, TokenLocation, +}; diff --git a/brush-parser/src/parser.rs b/brush-parser/src/parser.rs index 1d1c9af5..f2be1e2a 100644 --- a/brush-parser/src/parser.rs +++ b/brush-parser/src/parser.rs @@ -26,6 +26,17 @@ impl Default for ParserOptions { } } +impl ParserOptions { + /// Returns the tokenizer options implied by these parser options. + pub fn tokenizer_options(&self) -> TokenizerOptions { + TokenizerOptions { + enable_extended_globbing: self.enable_extended_globbing, + posix_mode: self.posix_mode, + sh_mode: self.sh_mode, + } + } +} + /// Implements parsing for shell programs. pub struct Parser { reader: R, @@ -60,13 +71,7 @@ impl Parser { // // First we tokenize the input, according to the policy implied by provided options. - let mut tokenizer = Tokenizer::new( - &mut self.reader, - &TokenizerOptions { - enable_extended_globbing: self.options.enable_extended_globbing, - posix_mode: self.options.posix_mode, - }, - ); + let mut tokenizer = Tokenizer::new(&mut self.reader, &self.options.tokenizer_options()); tracing::debug!(target: "tokenize", "Tokenizing..."); @@ -356,13 +361,15 @@ peg::parser! { -- specific_operator("(") e:extended_test_expression() specific_operator(")") { ast::ExtendedTestExpr::Parenthesized(Box::from(e)) } -- - left:word() specific_word("-ef") right:word() { ast::ExtendedTestExpr::BinaryTest(ast::BinaryPredicate::FilesReferToSameDeviceAndInodeNumbers, ast::Word::from(left), ast::Word::from(right)) } + // Arithmetic operators left:word() specific_word("-eq") right:word() { ast::ExtendedTestExpr::BinaryTest(ast::BinaryPredicate::ArithmeticEqualTo, ast::Word::from(left), ast::Word::from(right)) } - left:word() specific_word("-ge") right:word() { ast::ExtendedTestExpr::BinaryTest(ast::BinaryPredicate::ArithmeticGreaterThanOrEqualTo, ast::Word::from(left), ast::Word::from(right)) } - left:word() specific_word("-gt") right:word() { ast::ExtendedTestExpr::BinaryTest(ast::BinaryPredicate::ArithmeticGreaterThan, ast::Word::from(left), ast::Word::from(right)) } - left:word() specific_word("-le") right:word() { ast::ExtendedTestExpr::BinaryTest(ast::BinaryPredicate::ArithmeticLessThanOrEqualTo, ast::Word::from(left), ast::Word::from(right)) } - left:word() specific_word("-lt") right:word() { ast::ExtendedTestExpr::BinaryTest(ast::BinaryPredicate::ArithmeticLessThan, ast::Word::from(left), ast::Word::from(right)) } left:word() specific_word("-ne") right:word() { ast::ExtendedTestExpr::BinaryTest(ast::BinaryPredicate::ArithmeticNotEqualTo, ast::Word::from(left), ast::Word::from(right)) } + left:word() specific_word("-lt") right:word() { ast::ExtendedTestExpr::BinaryTest(ast::BinaryPredicate::ArithmeticLessThan, ast::Word::from(left), ast::Word::from(right)) } + left:word() specific_word("-le") right:word() { ast::ExtendedTestExpr::BinaryTest(ast::BinaryPredicate::ArithmeticLessThanOrEqualTo, ast::Word::from(left), ast::Word::from(right)) } + left:word() specific_word("-gt") right:word() { ast::ExtendedTestExpr::BinaryTest(ast::BinaryPredicate::ArithmeticGreaterThan, ast::Word::from(left), ast::Word::from(right)) } + left:word() specific_word("-ge") right:word() { ast::ExtendedTestExpr::BinaryTest(ast::BinaryPredicate::ArithmeticGreaterThanOrEqualTo, ast::Word::from(left), ast::Word::from(right)) } + // Non-arithmetic binary operators + left:word() specific_word("-ef") right:word() { ast::ExtendedTestExpr::BinaryTest(ast::BinaryPredicate::FilesReferToSameDeviceAndInodeNumbers, ast::Word::from(left), ast::Word::from(right)) } left:word() specific_word("-nt") right:word() { ast::ExtendedTestExpr::BinaryTest(ast::BinaryPredicate::LeftFileIsNewerOrExistsWhenRightDoesNot, ast::Word::from(left), ast::Word::from(right)) } left:word() specific_word("-ot") right:word() { ast::ExtendedTestExpr::BinaryTest(ast::BinaryPredicate::LeftFileIsOlderOrDoesNotExistWhenRightDoes, ast::Word::from(left), ast::Word::from(right)) } left:word() (specific_word("==") / specific_word("=")) right:word() { ast::ExtendedTestExpr::BinaryTest(ast::BinaryPredicate::StringExactlyMatchesPattern, ast::Word::from(left), ast::Word::from(right)) } diff --git a/brush-parser/src/pattern.rs b/brush-parser/src/pattern.rs index b8803b62..cfce4934 100644 --- a/brush-parser/src/pattern.rs +++ b/brush-parser/src/pattern.rs @@ -94,8 +94,9 @@ peg::parser! { s.push('('); + // fancy_regex uses ?! to indicate a negative lookahead. if matches!(kind, ExtendedGlobKind::Exclamation) { - s.push_str("?!"); + s.push_str("(?!"); } s.push_str(&branches.join("|")); @@ -109,7 +110,7 @@ peg::parser! { } if matches!(kind, ExtendedGlobKind::Exclamation) { - s.push_str(".*?"); + s.push_str(".)*?"); } s diff --git a/brush-parser/src/tokenizer.rs b/brush-parser/src/tokenizer.rs index 02b3136f..a62c1e75 100644 --- a/brush-parser/src/tokenizer.rs +++ b/brush-parser/src/tokenizer.rs @@ -220,12 +220,15 @@ struct CrossTokenParseState { } /// Options controlling how the tokenizer operates. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Hash, Eq, PartialEq)] pub struct TokenizerOptions { /// Whether or not to enable extended globbing patterns (extglob). pub enable_extended_globbing: bool, /// Whether or not to operate in POSIX compliance mode. + #[allow(unused)] pub posix_mode: bool, + /// Whether or not we're running in SH emulation mode. + pub sh_mode: bool, } impl Default for TokenizerOptions { @@ -233,6 +236,7 @@ impl Default for TokenizerOptions { Self { enable_extended_globbing: true, posix_mode: false, + sh_mode: false, } } } @@ -466,13 +470,29 @@ impl TokenParseState { /// /// * `input` - The shell script to tokenize. pub fn tokenize_str(input: &str) -> Result, TokenizerError> { - cacheable_tokenize_str(input.to_owned()) + tokenize_str_with_options(input, &TokenizerOptions::default()) +} + +/// Break the given input shell script string into tokens, returning the tokens. +/// +/// # Arguments +/// +/// * `input` - The shell script to tokenize. +/// * `options` - Options controlling how the tokenizer operates. +pub fn tokenize_str_with_options( + input: &str, + options: &TokenizerOptions, +) -> Result, TokenizerError> { + cacheable_tokenize_str(input.to_owned(), options.to_owned()) } #[cached::proc_macro::cached(size = 64, result = true)] -pub fn cacheable_tokenize_str(input: String) -> Result, TokenizerError> { +fn cacheable_tokenize_str( + input: String, + options: TokenizerOptions, +) -> Result, TokenizerError> { let mut reader = std::io::BufReader::new(input.as_bytes()); - let mut tokenizer = crate::tokenizer::Tokenizer::new(&mut reader, &TokenizerOptions::default()); + let mut tokenizer = crate::tokenizer::Tokenizer::new(&mut reader, &options); let mut tokens = vec![]; loop { @@ -1112,7 +1132,7 @@ impl<'a, R: ?Sized + std::io::BufRead> Tokenizer<'a, R> { fn is_operator(&self, s: &str) -> bool { // Handle non-POSIX operators. - if !self.options.posix_mode && matches!(s, "<<<" | "&>" | "&>>" | ";;&" | ";&" | "|&") { + if !self.options.sh_mode && matches!(s, "<<<" | "&>" | "&>>" | ";;&" | ";&" | "|&") { return true; } diff --git a/brush-shell/Cargo.toml b/brush-shell/Cargo.toml index 6208a698..bb634888 100644 --- a/brush-shell/Cargo.toml +++ b/brush-shell/Cargo.toml @@ -31,8 +31,9 @@ path = "tests/completion_tests.rs" [features] default = ["basic", "reedline"] -basic = [] -reedline = [] +basic = ["brush-interactive/basic"] +minimal = ["brush-interactive/minimal"] +reedline = ["brush-interactive/reedline"] [lints] workspace = true @@ -52,12 +53,13 @@ human-panic = "2.0.2" [target.'cfg(not(any(windows, unix)))'.dependencies] brush-interactive = { version = "^0.2.13", path = "../brush-interactive", features = [ - "basic", + "minimal", ] } tokio = { version = "1.42.0", features = ["rt", "sync"] } [target.'cfg(any(windows, unix))'.dependencies] brush-interactive = { version = "^0.2.13", path = "../brush-interactive", features = [ + "basic", "reedline", ] } tokio = { version = "1.41.1", features = ["rt", "rt-multi-thread", "sync"] } diff --git a/brush-shell/src/args.rs b/brush-shell/src/args.rs index 0284ec70..9f215a0a 100644 --- a/brush-shell/src/args.rs +++ b/brush-shell/src/args.rs @@ -22,6 +22,7 @@ const VERSION: &str = const_format::concatcp!( pub enum InputBackend { Reedline, Basic, + Minimal, } /// Parsed command-line arguments for the brush shell. diff --git a/brush-shell/src/events.rs b/brush-shell/src/events.rs index e4e6ae2a..84bd5267 100644 --- a/brush-shell/src/events.rs +++ b/brush-shell/src/events.rs @@ -20,6 +20,9 @@ pub enum TraceEvent { /// Traces word expansion. #[clap(name = "expand")] Expand, + /// Traces functions. + #[clap(name = "functions")] + Functions, /// Traces job management. #[clap(name = "jobs")] Jobs, @@ -41,6 +44,7 @@ impl Display for TraceEvent { TraceEvent::Commands => write!(f, "commands"), TraceEvent::Complete => write!(f, "complete"), TraceEvent::Expand => write!(f, "expand"), + TraceEvent::Functions => write!(f, "functions"), TraceEvent::Jobs => write!(f, "jobs"), TraceEvent::Parse => write!(f, "parse"), TraceEvent::Pattern => write!(f, "pattern"), @@ -100,6 +104,7 @@ impl TraceEventConfig { TraceEvent::Commands => vec!["commands"], TraceEvent::Complete => vec!["completion"], TraceEvent::Expand => vec!["expansion"], + TraceEvent::Functions => vec!["functions"], TraceEvent::Jobs => vec!["jobs"], TraceEvent::Parse => vec!["parse"], TraceEvent::Pattern => vec!["pattern"], diff --git a/brush-shell/src/main.rs b/brush-shell/src/main.rs index e3faee88..ed533fcd 100644 --- a/brush-shell/src/main.rs +++ b/brush-shell/src/main.rs @@ -116,6 +116,7 @@ async fn run( run_impl(cli_args, args, shell_factory::ReedlineShellFactory).await } InputBackend::Basic => run_impl(cli_args, args, shell_factory::BasicShellFactory).await, + InputBackend::Minimal => run_impl(cli_args, args, shell_factory::MinimalShellFactory).await, } } @@ -213,6 +214,7 @@ async fn instantiate_shell( shell_product_display_str: Some(productinfo::get_product_display_str()), sh_mode: args.sh_mode, verbose: args.verbose, + max_function_call_depth: None, }, disable_bracketed_paste: args.disable_bracketed_paste, disable_color: args.disable_color, diff --git a/brush-shell/src/shell_factory.rs b/brush-shell/src/shell_factory.rs index 1a770ac8..ed333ed0 100644 --- a/brush-shell/src/shell_factory.rs +++ b/brush-shell/src/shell_factory.rs @@ -97,3 +97,27 @@ impl ShellFactory for BasicShellFactory { } } } + +pub(crate) struct MinimalShellFactory; + +impl ShellFactory for MinimalShellFactory { + #[cfg(feature = "minimal")] + type ShellType = brush_interactive::MinimalShell; + #[cfg(not(feature = "minimal"))] + type ShellType = StubShell; + + #[allow(unused)] + async fn create( + &self, + options: &brush_interactive::Options, + ) -> Result { + #[cfg(feature = "minimal")] + { + brush_interactive::MinimalShell::new(options).await + } + #[cfg(not(feature = "minimal"))] + { + Err(brush_interactive::ShellError::InputBackendNotSupported) + } + } +} diff --git a/brush-shell/tests/cases/builtins/compgen.yaml b/brush-shell/tests/cases/builtins/compgen.yaml index 91d53050..f799fcfa 100644 --- a/brush-shell/tests/cases/builtins/compgen.yaml +++ b/brush-shell/tests/cases/builtins/compgen.yaml @@ -57,6 +57,10 @@ cases: echo "2. compgen -W with expansion" myvar=value compgen -W '${myvar}' + - name: "compgen -W with unsorted values" + stdin: | + compgen -W 'c b d a' + - name: "compgen -W with options" stdin: | compgen -W '--abc --def' -- '--ab' @@ -112,3 +116,23 @@ cases: for p in $(compgen -f '\$HOME/'); do echo ${p//$HOME/HOME} done + + - name: "compgen with interesting hyphens" + stdin: | + compgen -P-before -S-after -W "one two three" -- t | sort + + - name: "compgen -X" + stdin: | + echo "[Take 1]" + compgen -W 'foo bar' -X 'foo' '' + + echo "[Take 2]" + compgen -W 'foo bar' -X 'f*' '' + + echo "[Take 3]" + compgen -W '&1 foo' -X '\&*' '' + + - name: "compgen -X with replacement" + stdin: | + echo "[Take 1]" + compgen -W 'somebody something' -X '&b*' some diff --git a/brush-shell/tests/cases/builtins/complete.yaml b/brush-shell/tests/cases/builtins/complete.yaml new file mode 100644 index 00000000..c55231cd --- /dev/null +++ b/brush-shell/tests/cases/builtins/complete.yaml @@ -0,0 +1,61 @@ +name: "Builtins: complete" +cases: + - name: "Roundtrip: complete -W" + stdin: | + complete -W foo mycmd + complete -p mycmd + + complete -W 'foo bar' mycmd + complete -p mycmd + + - name: "Roundtrip: complete -P" + stdin: | + complete -P myprefix mycmd + complete -p mycmd + + complete -P 'my prefix' mycmd + complete -p mycmd + + - name: "Roundtrip: complete -S" + stdin: | + complete -S mysuffix mycmd + complete -p mycmd + + complete -S 'my suffix' mycmd + complete -p mycmd + + - name: "Roundtrip: complete -F" + stdin: | + complete -Fmyfunc mycmd + complete -p mycmd + + - name: "Roundtrip: complete -F" + stdin: | + complete -G pattern mycmd + complete -p mycmd + + complete -G 'pat tern' mycmd + complete -p mycmd + + - name: "Roundtrip: complete -X" + stdin: | + complete -X pattern mycmd + complete -p mycmd + + complete -X 'pat tern' mycmd + complete -p mycmd + + - name: "Roundtrip: complete -C" + stdin: | + complete -C cmd mycmd + complete -p mycmd + + complete -C 'c md' mycmd + complete -p mycmd + + - name: "Roundtrip: complete -A" + stdin: | + for action in alias arrayvar binding builtin command directory disabled enabled export file 'function' group helptopic hostname job keyword running service setopt shopt signal stopped user variable; do + complete -A ${action} mycmd + complete -p mycmd + done diff --git a/brush-shell/tests/cases/builtins/mapfile.yaml b/brush-shell/tests/cases/builtins/mapfile.yaml new file mode 100644 index 00000000..f85e7c2b --- /dev/null +++ b/brush-shell/tests/cases/builtins/mapfile.yaml @@ -0,0 +1,6 @@ +name: "Builtins: mapfile" +cases: + - name: "mapfile -t" + stdin: | + mapfile -t myarray < /dev/null + (echo "hello"; echo "there") | (mapfile -t myarray && declare -p myarray) diff --git a/brush-shell/tests/cases/builtins/shopt.yaml b/brush-shell/tests/cases/builtins/shopt.yaml index 4c6b8e0e..f1999c96 100644 --- a/brush-shell/tests/cases/builtins/shopt.yaml +++ b/brush-shell/tests/cases/builtins/shopt.yaml @@ -65,3 +65,13 @@ cases: echo "Displaying emacs" shopt -o emacs shopt -o -p emacs + + - name: "shopt -s lastpipe" + stdin: | + echo ignored | var=value + echo "1. var='${var}'" + + shopt -s lastpipe + set +o monitor + echo ignored | var=value + echo "2. var='${var}'" diff --git a/brush-shell/tests/cases/extended_tests.yaml b/brush-shell/tests/cases/extended_tests.yaml index 8c00a3e6..38d007fe 100644 --- a/brush-shell/tests/cases/extended_tests.yaml +++ b/brush-shell/tests/cases/extended_tests.yaml @@ -228,6 +228,10 @@ cases: [[ "<" =~ (<) ]] && echo "1. Matched" [[ ">" =~ (<) ]] && echo "2. Matched" + - name: "Regex with unescaped open bracket in character class" + stdin: | + [[ "[" =~ ^([x[]) ]] && echo "Matched" + - name: "Empty and space checks" stdin: | check() { @@ -247,3 +251,20 @@ cases: && "b" == "b" ]] && echo "Succeeded" + + - name: "Variable set checks" + stdin: | + declare set_but_no_value + [[ -v set_but_no_value ]] && echo "1. Set but no value" + + declare set_with_value=xyz + [[ -v set_with_value ]] && echo "2. Set with value" + + [[ -v not_set ]] || echo "3. Not set" + + - name: "Variables in extended tests" + stdin: | + var=10 + + [[ $var -eq 10 ]] && echo "1. Pass" + [[ var -eq 10 ]] && echo "2. Pass" diff --git a/brush-shell/tests/cases/patterns.yaml b/brush-shell/tests/cases/patterns.yaml index 81d342df..b960680c 100644 --- a/brush-shell/tests/cases/patterns.yaml +++ b/brush-shell/tests/cases/patterns.yaml @@ -293,3 +293,8 @@ cases: shopt -s nocasematch [[ "abc" == "ABC" ]] && echo "3. Matched" [[ "abc" == "[A-Z]BC" ]] && echo "4. Matched" + + - name: "Pattern matching: stars in negative extglobs" + stdin: | + shopt -s extglob + [[ 'd' == !(*d)d ]] && echo "1. Matched" diff --git a/brush-shell/tests/cases/simple_commands.yaml b/brush-shell/tests/cases/simple_commands.yaml index ca57bfdd..04a3e287 100644 --- a/brush-shell/tests/cases/simple_commands.yaml +++ b/brush-shell/tests/cases/simple_commands.yaml @@ -20,3 +20,8 @@ cases: stdin: | ./non-existent-command echo "Result: $?" + + - name: "Redirection of errors" + stdin: | + ./non-existent-command 2>/dev/null + echo "Result: $?" diff --git a/brush-shell/tests/cases/word_expansion.yaml b/brush-shell/tests/cases/word_expansion.yaml index 7e08285a..3a6903f5 100644 --- a/brush-shell/tests/cases/word_expansion.yaml +++ b/brush-shell/tests/cases/word_expansion.yaml @@ -640,6 +640,13 @@ cases: var="Hello, world!" echo "\${var/world//world}: ${var/world//world}" + - name: "Substring replacement with patterns" + stdin: | + var="a[bc]d" + pattern="[bc]" + echo "Replacement 1: ${var/${pattern}/}" + echo "Replacement 2: ${var/"${pattern}"/}" + - name: "Prefix substring replacement" stdin: | var="Hello, world!" diff --git a/brush-shell/tests/compat_tests.rs b/brush-shell/tests/compat_tests.rs index d5ae0fc6..0d75fb5e 100644 --- a/brush-shell/tests/compat_tests.rs +++ b/brush-shell/tests/compat_tests.rs @@ -8,10 +8,8 @@ use anyhow::{Context, Result}; use assert_fs::fixture::{FileWriteStr, PathChild}; use clap::Parser; use colored::Colorize; -#[cfg(unix)] use descape::UnescapeExt; use serde::{Deserialize, Serialize}; -#[cfg(unix)] use std::os::unix::{fs::PermissionsExt, process::ExitStatusExt}; use std::{ collections::{HashMap, HashSet}, @@ -845,7 +843,6 @@ impl TestCase { test_file_path.write_str(test_file.contents.as_str())?; } - #[cfg(unix)] if test_file.executable { // chmod u+x let mut perms = test_file_path.metadata()?.permissions(); @@ -947,15 +944,7 @@ impl TestCase { let test_cmd = self.create_command_for_shell(shell_config, working_dir); let result = if self.pty { - #[cfg(unix)] - { - self.run_command_with_pty(test_cmd).await? - } - - #[cfg(not(unix))] - { - panic!("PTY tests are only supported on Unix-like systems"); - } + self.run_command_with_pty(test_cmd).await? } else { self.run_command_with_stdin(test_cmd).await? }; @@ -1019,7 +1008,6 @@ impl TestCase { } #[allow(clippy::unused_async)] - #[cfg(unix)] async fn run_command_with_pty(&self, cmd: std::process::Command) -> Result { use expectrl::Expect; diff --git a/brush-shell/tests/completion_tests.rs b/brush-shell/tests/completion_tests.rs index 777651f7..c00d9c7c 100644 --- a/brush-shell/tests/completion_tests.rs +++ b/brush-shell/tests/completion_tests.rs @@ -58,6 +58,10 @@ impl TestShellWithBashCompletion { } } + pub async fn complete_end_of_line(&mut self, line: &str) -> Result> { + self.complete(line, line.len()).await + } + pub async fn complete(&mut self, line: &str, pos: usize) -> Result> { let completions = self.shell.get_completions(line, pos).await?; Ok(completions.candidates.into_iter().collect()) @@ -80,8 +84,7 @@ async fn complete_relative_file_path() -> Result<()> { test_shell.temp_dir.child("item2").create_dir_all()?; // Complete; expect to see the two files. - let input = "ls item"; - let results = test_shell.complete(input, input.len()).await?; + let results = test_shell.complete_end_of_line("ls item").await?; assert_eq!(results, ["item1", "item2"]); @@ -98,8 +101,7 @@ async fn complete_relative_file_path_ignoring_case() -> Result<()> { test_shell.temp_dir.child("item2").create_dir_all()?; // Complete; expect to see the two files. - let input = "ls item"; - let results = test_shell.complete(input, input.len()).await?; + let results = test_shell.complete_end_of_line("ls item").await?; assert_eq!(results, ["ITEM1", "item2"]); @@ -115,8 +117,7 @@ async fn complete_relative_dir_path() -> Result<()> { test_shell.temp_dir.child("item2").create_dir_all()?; // Complete; expect to see just the dir. - let input = "cd item"; - let results = test_shell.complete(input, input.len()).await?; + let results = test_shell.complete_end_of_line("cd item").await?; assert_eq!(results, ["item2"]); @@ -131,8 +132,7 @@ async fn complete_under_empty_dir() -> Result<()> { test_shell.temp_dir.child("empty").create_dir_all()?; // Complete; expect to see nothing. - let input = "ls empty/"; - let results = test_shell.complete(input, input.len()).await?; + let results = test_shell.complete_end_of_line("ls empty/").await?; assert_eq!(results, Vec::::new()); @@ -144,8 +144,7 @@ async fn complete_nonexistent_relative_path() -> Result<()> { let mut test_shell = TestShellWithBashCompletion::new().await?; // Complete; expect to see nothing. - let input = "ls item"; - let results = test_shell.complete(input, input.len()).await?; + let results = test_shell.complete_end_of_line("ls item").await?; assert_eq!(results, Vec::::new()); @@ -162,7 +161,7 @@ async fn complete_absolute_paths() -> Result<()> { // Complete; expect to see just the dir. let input = std::format!("ls {}", test_shell.temp_dir.path().join("item").display()); - let results = test_shell.complete(input.as_str(), input.len()).await?; + let results = test_shell.complete_end_of_line(input.as_str()).await?; assert_eq!( results, @@ -194,8 +193,7 @@ async fn complete_path_with_var() -> Result<()> { test_shell.temp_dir.child("item2").create_dir_all()?; // Complete; expect to see the two files. - let input = "ls $PWD/item"; - let results = test_shell.complete(input, input.len()).await?; + let results = test_shell.complete_end_of_line("ls $PWD/item").await?; assert_eq!( results, @@ -238,8 +236,7 @@ async fn complete_path_with_tilde() -> Result<()> { test_shell.temp_dir.child("item2").create_dir_all()?; // Complete; expect to see the two files. - let input = "ls ~/item"; - let results = test_shell.complete(input, input.len()).await?; + let results = test_shell.complete_end_of_line("ls ~/item").await?; assert_eq!( results, @@ -271,8 +268,7 @@ async fn complete_variable_names() -> Result<()> { test_shell.set_var("TESTVAR2", "")?; // Complete. - let input = "echo $TESTVAR"; - let results = test_shell.complete(input, input.len()).await?; + let results = test_shell.complete_end_of_line("echo $TESTVAR").await?; assert_eq!(results, ["$TESTVAR1", "$TESTVAR2"]); Ok(()) @@ -287,8 +283,7 @@ async fn complete_variable_names_with_braces() -> Result<()> { test_shell.set_var("TESTVAR2", "")?; // Complete. - let input = "echo ${TESTVAR"; - let results = test_shell.complete(input, input.len()).await?; + let results = test_shell.complete_end_of_line("echo ${TESTVAR").await?; assert_eq!(results, ["${TESTVAR1}", "${TESTVAR2}"]); Ok(()) @@ -299,8 +294,7 @@ async fn complete_help_topic() -> Result<()> { let mut test_shell = TestShellWithBashCompletion::new().await?; // Complete. - let input = "help expor"; - let results = test_shell.complete(input, input.len()).await?; + let results = test_shell.complete_end_of_line("help expor").await?; assert_eq!(results, ["export"]); Ok(()) @@ -311,8 +305,7 @@ async fn complete_command_option() -> Result<()> { let mut test_shell = TestShellWithBashCompletion::new().await?; // Complete. - let input = "ls --hel"; - let results = test_shell.complete(input, input.len()).await?; + let results = test_shell.complete_end_of_line("ls --hel").await?; assert_eq!(results, ["--help"]); Ok(()) @@ -329,10 +322,22 @@ async fn complete_path_args_to_well_known_programs() -> Result<()> { test_shell.temp_dir.child("item2").create_dir_all()?; // Complete. - let input = "tar tvf ./item"; - let results = test_shell.complete(input, input.len()).await?; + let results = test_shell.complete_end_of_line("tar tvf ./item").await?; assert_eq!(results, ["./item1", "./item2"]); Ok(()) } + +/// Tests some 'find' completion. +#[tokio::test] +async fn complete_find_command() -> Result<()> { + let mut test_shell = TestShellWithBashCompletion::new().await?; + + // Complete. + let results = test_shell.complete_end_of_line("find . -na").await?; + + assert_eq!(results, ["-name"]); + + Ok(()) +} diff --git a/brush-shell/tests/interactive_tests.rs b/brush-shell/tests/interactive_tests.rs index 6a4973f5..9c5dc5d0 100644 --- a/brush-shell/tests/interactive_tests.rs +++ b/brush-shell/tests/interactive_tests.rs @@ -82,7 +82,7 @@ fn run_in_bg_then_fg() -> anyhow::Result<()> { // Make sure the jobs are gone. let jobs_output = session.exec_output("jobs")?; - assert!(jobs_output.trim().is_empty()); + assert_eq!(jobs_output.trim(), ""); // Exit the shell. session.exit()?; @@ -171,7 +171,8 @@ fn start_shell_session() -> anyhow::Result { // above). let session = expectrl::session::log(session, std::io::stdout())?; - let session = expectrl::repl::ReplSession::new(session, DEFAULT_PROMPT); + let mut session = expectrl::repl::ReplSession::new(session, DEFAULT_PROMPT); + session.set_echo(true); Ok(session) }