Repository: reubeno/brush
Pull request #288 (2024-12-06 20:38:58)

feat(completion): progress toward passing bash-completion test suite
These changes are an assortment focused on being able to run the `bash-completion` `pytest`/`pexpect`-based test suite and increase our pass rate of its tests. Along the way a number of targeted fixes and additions were made to address compatibility issues identified as side effects of these tests.

Upon initial execution of the tests we saw:

> 447 failed, 1043 passed, 381 skipped, 18 xfailed, 174 errors in 2361.74s (0:39:21)

(`xfailed` means "expected failed".) I haven't done a full re-run of the tests, but expected the pass rate to be somewhat higher with these changes.

Notable changes:

* Overhaul of the "basic" input backend to support tab completion, and further, to support programmatic testing via `pexpect` and other standard `pty`-based testing frameworks. This required a fair bit of fiddling to avoid use of VT100-style escape sequences as well as get the output to match what other shells emit.
* Implementation of the `lastpipe` option.
* Initial partial implementation of the `mapfile` built-in, required by some completion helper functions. The most notable constraint is that we only support `\n` as a delimiter. There are other limitations as well, identified by calls to `error::unimp()` in the code.
* Stub implementation of `bind` built-in, emitting *just enough* to let some completion helper functions pass. Clearly more work is required here, as well as some thought as to which functions we can/should expose.
* Workarounds or fixes for various compat issues found with pattern matching, regex processing, and extended tests expressions.

github-actions[bot]: ## Test Results
0 tests   0 ✅  0s ⏱️
0 suites  0 💤
0 files    0 ❌

Results for commit be9f7c84.

[test-results]:data:application/gzip;base64,H4sIAPlhU2cC/1WMywqDMBBFf0Vm3UXMQ01/puiYgaFqSh4r6b9XrLTJ7p5z4exAvLgI90bcGoiZ0w/mHMbEfrvwOFIs9yNmxFo8+VUJGnmphAvBh8uEvMVilrkv/2snF7GTyxb6deV0AEzOUo+D7juSQhOSNcNklRFOSY2qVaabhWwJ3h9aGz9O+wAAAA==

github-actions[bot]: # Performance Benchmark Report

Benchmarks removed:
  - parse_bash_completion
  - parse_sample_script
  - run_echo_builtin_command
  - expand_one_string
  - run_one_external_command_directly
  - run_one_builtin_command
  - instantiate_shell_with_init_scripts
  - run_one_external_command
  - instantiate_shell

<!-- Sticky Pull Request Comment -->

Patch:
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index f76a7b8..a5f82a2 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 1123747..46c2294 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 31d6791..40205d6 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 d04ef04..0591485 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 0000000..3dffcd4
--- /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<String>,
+    /// 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<String>,
+    /// Remove all bindings for the given named function.
+    #[arg(short = 'u')]
+    remove_func_bindings: Option<String>,
+    /// Remove the binding for the given key sequence.
+    #[arg(short = 'r')]
+    remove_key_seq_binding: Option<String>,
+    /// Import bindings from the given file.
+    #[arg(short = 'f')]
+    bindings_file: Option<String>,
+    /// Bind key sequence to command.
+    #[arg(short = 'x')]
+    key_seq_bindings: Vec<String>,
+    /// 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<crate::builtins::ExitCode, crate::error::Error> {
+        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 931d091..a38169b 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<String>,
 }
 
@@ -439,7 +466,9 @@ impl builtins::Command for CompGenCommand {
         &self,
         context: commands::ExecutionContext<'_>,
     ) -> Result<crate::builtins::ExitCode, crate::error::Error> {
-        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 3454a48..4261552 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::<echo::EchoCommand>());
         m.insert("enable".into(), builtin::<enable::EnableCommand>());
         m.insert("let".into(), builtin::<let_::LetCommand>());
+        m.insert("mapfile".into(), builtin::<mapfile::MapFileCommand>());
         m.insert("printf".into(), builtin::<printf::PrintfCommand>());
         m.insert("shopt".into(), builtin::<shopt::ShoptCommand>());
         m.insert("source".into(), special_builtin::<dot::DotCommand>());
@@ -277,13 +278,14 @@ pub(crate) fn get_default_builtins(
         m.insert("popd".into(), builtin::<popd::PopdCommand>());
         m.insert("pushd".into(), builtin::<pushd::PushdCommand>());
 
+        // Input configuration builtins
+        m.insert("bind".into(), builtin::<bind::BindCommand>());
+
         // TODO: Unimplemented builtins
-        m.insert("bind".into(), builtin::<unimp::UnimplementedCommand>());
         m.insert("caller".into(), builtin::<unimp::UnimplementedCommand>());
         m.insert("disown".into(), builtin::<unimp::UnimplementedCommand>());
         m.insert("history".into(), builtin::<unimp::UnimplementedCommand>());
         m.insert("logout".into(), builtin::<unimp::UnimplementedCommand>());
-        m.insert("mapfile".into(), builtin::<unimp::UnimplementedCommand>());
         m.insert("readarray".into(), builtin::<unimp::UnimplementedCommand>());
         m.insert("suspend".into(), builtin::<unimp::UnimplementedCommand>());
     }
diff --git a/brush-core/src/builtins/mapfile.rs b/brush-core/src/builtins/mapfile.rs
new file mode 100644
index 0000000..cb26ba2
--- /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<i64>,
+
+    /// 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<String>,
+
+    /// 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<crate::builtins::ExitCode, error::Error> {
+        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<variables::ArrayLiteral, error::Error> {
+        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<Option<sys::terminal::TerminalSettings>, 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 87c6dc8..9c0df04 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 9fa6448..237f23a 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<String>,
-    #[arg(long = concat!("+o"), name = "setopt_disable", hide = true)]
-    disable: Vec<String>,
+    #[arg(short = 'o', name = "setopt_enable", num_args=0..=1)]
+    enable: Option<Vec<String>>,
+    #[arg(long = concat!("+o"), name = "setopt_disable", hide = true, num_args=0..=1)]
+    disable: Option<Vec<String>>,
 }
 
 /// Manage set-based shell options.
@@ -142,28 +144,36 @@ impl builtins::Command for SetCommand {
     ) -> Result<builtins::ExitCode, error::Error> {
         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<String, bool> = 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 fbdb1c6..e1aef8f 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<String, error::Error> {
+    // 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, &params).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 5cd97ad..a1184b5 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<IndexSet<String>, 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::<Vec<String>>().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<brush_parser::Token> {
+    fn tokenize_input_for_completion(shell: &mut Shell, input: &str) -> Vec<brush_parser::Token> {
         // 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<brush_
 
     tokens
 }
+
+fn completion_filter_pattern_matches(
+    pattern: &str,
+    candidate: &str,
+    token_being_completed: &str,
+    shell: &mut Shell,
+) -> Result<bool, error::Error> {
+    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 946e082..07c6ed2 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 6ebea54..7aaf9c5 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 de04f73..36ec8f9 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<String>,
     ) -> Result<Option<patterns::Pattern>, 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, &params).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(&parameter, 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(&parameter, 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(&parameter, 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/interp.rs b/brush-core/src/interp.rs
index 4ce77c6..a89acd9 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<i32> = 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 66da327..6e9c32d 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<usize>,
 }
 
 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 7ad501b..5c1a9de 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 f567568..8c44ebc 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<fancy_regex::Regex, error::Error> {
-    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<str> {
+    // We may see a character class with an unescaped '[' (open bracket) character. We need
+    // to escape that character.
+    let mut in_escape = false;
+    let mut in_brackets = false;
+    let mut insertion_positions = vec![];
+
+    let mut peekable = s.char_indices().peekable();
+    while let Some((byte_offset, c)) = peekable.next() {
+        let next_is_colon = peekable.peek().is_some_and(|(_, c)| *c == ':');
+
+        match c {
+            '[' if !in_escape && !in_brackets => {
+                in_brackets = true;
+            }
+            '[' if !in_escape && in_brackets && !next_is_colon => {
+                // Need to escape.
+                insertion_positions.push(byte_offset);
+            }
+            ']' if !in_escape && in_brackets => {
+                in_brackets = false;
+            }
+            _ => (),
+        }
+
+        in_escape = !in_escape && c == '\\';
+    }
+
+    if insertion_positions.is_empty() {
+        return s.into();
+    }
+
+    let mut updated = s.to_owned();
+    for pos in insertion_positions.iter().rev() {
+        updated.insert(*pos, '\\');
+    }
+
+    updated.into()
+}
+
 fn escape_literal_regex_piece(s: &str) -> Cow<str> {
     let mut result = String::new();
 
@@ -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 7e5db48..19ea020 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<usize>,
 }
 
 /// 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<brush_parser::ast::FunctionDefinition>,
     ) -> 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 6b816cf..91b10c0 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 71f1aeb..9bb748d 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<Fd>(
     Ok(())
 }
 
-pub(crate) fn is_stdin_a_terminal() -> Result<bool, error::Error> {
-    Ok(false)
-}
-
 pub(crate) fn get_parent_process_id() -> Option<sys::process::ProcessId> {
     None
 }
diff --git a/brush-core/src/sys/unix/terminal.rs b/brush-core/src/sys/unix/terminal.rs
index 643566f..d710661 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<Fd: AsFd>(
     Ok(())
 }
 
-pub(crate) fn is_stdin_a_terminal() -> Result<bool, error::Error> {
-    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<sys::process::ProcessId> {
     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 e683611..4bf7144 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<String, error::Error> {
 
 #[allow(clippy::unnecessary_wraps)]
 pub(crate) fn get_all_users() -> Result<Vec<String>, 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<Vec<String>, 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 9b579e2..8f37999 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 f8ca392..c9a7990 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 f86dff1..b18eb99 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<ReadResult, ShellError> {
-        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<ReadResult, ShellError> {
+        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<brush_core::completion::Completions, ShellError> {
+        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<brush_core::completion::Completions, ShellError> {
+        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 2631530..4404fa6 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 0000000..a3e7436
--- /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<Self, ShellError> {
+        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 0000000..14cce83
--- /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<brush_core::completion::Completions, ShellError>,
+) -> Result<ReadResult, ShellError> {
+    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<Self, ShellError> {
+        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<brush_core::completion::Completions, ShellError>,
+    ) -> Result<Option<ReadResult>, 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 f36b666..3c40516 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 0000000..afddbf1
--- /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<Self, ShellError> {
+        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<brush_core::Shell> {
+        self.shell.as_ref()
+    }
+
+    /// Returns a mutable reference to the inner shell object.
+    fn shell_mut(&mut self) -> impl AsMut<brush_core::Shell> {
+        self.shell.as_mut()
+    }
+
+    fn read_line(&mut self, prompt: InteractivePrompt) -> Result<ReadResult, ShellError> {
+        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<ReadResult, ShellError> {
+        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 0000000..e157ed9
--- /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 0361d6c..7f38187 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 a666ef7..b4fab32 100644
--- a/brush-interactive/src/trace_categories.rs
+++ b/brush-interactive/src/trace_categories.rs
@@ -1,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 55bb9dd..3f90229 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 1d1c9af..f2be1e2 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<R> {
     reader: R,
@@ -60,13 +71,7 @@ impl<R: std::io::BufRead> Parser<R> {
         //
 
         // 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 b8803b6..cfce493 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 02b3136..a62c1e7 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<Vec<Token>, 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<Vec<Token>, 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<Vec<Token>, TokenizerError> {
+fn cacheable_tokenize_str(
+    input: String,
+    options: TokenizerOptions,
+) -> Result<Vec<Token>, 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 6208a69..bb63488 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 0284ec7..9f215a0 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 e4e6ae2..84bd526 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 e3faee8..ed533fc 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 1a770ac..ed333ed 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<Self::ShellType, brush_interactive::ShellError> {
+        #[cfg(feature = "minimal")]
+        {
+            brush_interactive::MinimalShell::new(options).await
+        }
+        #[cfg(not(feature = "minimal"))]
+        {
+            Err(brush_interactive::ShellError::InputBackendNotSupported)
+        }
+    }
+}


Test patch:
diff --git a/brush-core/src/extendedtests.rs b/brush-core/src/extendedtests.rs
index fc8a3dc..bf87537 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<i64, _> = left.parse();
     let right: Result<i64, _> = right.parse();
 
diff --git a/brush-shell/tests/cases/builtins/compgen.yaml b/brush-shell/tests/cases/builtins/compgen.yaml
index 91d5305..f799fcf 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 0000000..c55231c
--- /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 0000000..f85e7c2
--- /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 4c6b8e0..f1999c9 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 8c00a3e..38d007f 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 81d342d..b960680 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 ca57bfd..04a3e28 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 7e08285..3a6903f 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 d5ae0fc..0d75fb5 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<RunResult> {
         use expectrl::Expect;
 
diff --git a/brush-shell/tests/completion_tests.rs b/brush-shell/tests/completion_tests.rs
index 777651f..c00d9c7 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<Vec<String>> {
+        self.complete(line, line.len()).await
+    }
+
     pub async fn complete(&mut self, line: &str, pos: usize) -> Result<Vec<String>> {
         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::<String>::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::<String>::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 6a4973f..9c5dc5d 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<ShellSession> {
     // 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)
 }