summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--Cargo.lock769
-rw-r--r--Cargo.toml30
-rw-r--r--src/action.rs123
-rw-r--r--src/builtins.rs44
-rw-r--r--src/env.rs151
-rw-r--r--src/format.rs43
-rw-r--r--src/history.rs407
-rw-r--r--src/info.rs54
-rw-r--r--src/main.rs146
-rw-r--r--src/mutex.rs18
-rw-r--r--src/parse.rs168
-rw-r--r--src/parse/ast.rs549
-rw-r--r--src/parse/test_ast.rs483
-rw-r--r--src/prelude.rs11
-rw-r--r--src/readline.rs198
-rw-r--r--src/runner/builtins/command.rs394
-rw-r--r--src/runner/builtins/mod.rs288
-rw-r--r--src/runner/command.rs204
-rw-r--r--src/runner/mod.rs531
-rw-r--r--src/runner/prelude.rs1
-rw-r--r--src/shell.pest65
-rw-r--r--src/shell/event.rs140
-rw-r--r--src/shell/git.rs201
-rw-r--r--src/shell/history/entry.rs393
-rw-r--r--src/shell/history/mod.rs384
-rw-r--r--src/shell/history/pty.rs106
-rw-r--r--src/shell/mod.rs665
-rw-r--r--src/shell/prelude.rs2
-rw-r--r--src/shell/readline.rs223
-rw-r--r--src/state.rs181
-rw-r--r--src/util.rs5
31 files changed, 5795 insertions, 1182 deletions
diff --git a/Cargo.lock b/Cargo.lock
index e47ce2c..edcef5f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4,9 +4,9 @@ version = 3
[[package]]
name = "anyhow"
-version = "1.0.45"
+version = "1.0.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ee10e43ae4a853c0a3591d4e2ada1719e553be18199d9da9d4a83f5927c2f5c7"
+checksum = "84450d0b4a8bd1ba4144ce8ce718fbc5d071358b1e5384bace6536b3d1f2d5b3"
[[package]]
name = "arrayvec"
@@ -71,7 +71,7 @@ dependencies = [
"slab",
"socket2",
"waker-fn",
- "winapi",
+ "winapi 0.3.9",
]
[[package]]
@@ -95,18 +95,17 @@ dependencies = [
[[package]]
name = "async-process"
version = "1.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "83137067e3a2a6a06d67168e49e68a0957d215410473a740cea95a2425c0b7c6"
+source = "git+https://github.com/doy/async-process#5e25598d6fcf3865f2b9e106ba049a26a490a884"
dependencies = [
"async-io",
"blocking",
- "cfg-if",
+ "cfg-if 1.0.0",
"event-listener",
"futures-lite",
"libc",
"once_cell",
"signal-hook",
- "winapi",
+ "winapi 0.3.9",
]
[[package]]
@@ -156,12 +155,42 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a"
[[package]]
+name = "bincode"
+version = "1.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
+dependencies = [
+ "serde",
+]
+
+[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
+name = "block-buffer"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0940dc441f31689269e10ac70eb1002a3a1d3ad1390e030043662eb7fe4688b"
+dependencies = [
+ "block-padding",
+ "byte-tools",
+ "byteorder",
+ "generic-array",
+]
+
+[[package]]
+name = "block-padding"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa79dedbb091f449f1f39e53edf88d5dbe95f895dae6135a8d7b881fb5af73f5"
+dependencies = [
+ "byte-tools",
+]
+
+[[package]]
name = "blocking"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -182,35 +211,43 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1e260c3a9040a7c19a12468758f4c16f31a81a1fe087482be9570ec864bb6c"
[[package]]
+name = "byte-tools"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3b5ca7a04898ad4bcd41c90c5285445ff5b791899bb1b0abdd2a2aa791211d7"
+
+[[package]]
+name = "byteorder"
+version = "1.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610"
+
+[[package]]
name = "cache-padded"
-version = "1.1.1"
+version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "631ae5198c9be5e753e5cc215e1bd73c2b466a3565173db433f52bb9d3e66dba"
+checksum = "c1db59621ec70f09c5e9b597b220c7a2b43611f4710dc03ceb8748637775692c"
[[package]]
name = "cc"
version = "1.0.72"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22a9137b95ea06864e018375b72adfb7db6e6f68cfc8df5a04d00288050485ee"
+dependencies = [
+ "jobserver",
+]
[[package]]
name = "cfg-if"
-version = "1.0.0"
+version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
+checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822"
[[package]]
-name = "chrono"
-version = "0.4.19"
+name = "cfg-if"
+version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73"
-dependencies = [
- "libc",
- "num-integer",
- "num-traits",
- "time",
- "winapi",
-]
+checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "concurrent-queue"
@@ -227,7 +264,7 @@ version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d82cfc11ce7f2c3faef78d8a684447b40d503d9681acebed6cb728d45940c4db"
dependencies = [
- "cfg-if",
+ "cfg-if 1.0.0",
"lazy_static",
]
@@ -242,40 +279,112 @@ dependencies = [
]
[[package]]
+name = "digest"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3d0c8c8752312f9713efd397ff63acb9f85585afbf179282e720e7704954dd5"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
name = "event-listener"
version = "2.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7531096570974c3a9dcf9e4b8e1cede1ec26cf5046219fb3b9d897503b9be59"
[[package]]
+name = "fake-simd"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e88a8acf291dafb59c2d96e8f59828f3838bb1a70398823ade51a84de6a6deed"
+
+[[package]]
name = "fastrand"
-version = "1.5.0"
+version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b394ed3d285a429378d3b384b9eb1285267e7df4b166df24b7a6939a04dc392e"
+checksum = "779d043b6a0b90cc4c0ed7ee380a6504394cee7efd7db050e3774eee387324b2"
dependencies = [
"instant",
]
[[package]]
+name = "filetime"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "975ccf83d8d9d0d84682850a38c8169027be83368805971cc4f238c2b245bc98"
+dependencies = [
+ "cfg-if 1.0.0",
+ "libc",
+ "redox_syscall",
+ "winapi 0.3.9",
+]
+
+[[package]]
+name = "form_urlencoded"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5fc25a87fa4fd2094bffb06925852034d90a17f0d1e05197d4956d3555752191"
+dependencies = [
+ "matches",
+ "percent-encoding",
+]
+
+[[package]]
+name = "fsevent"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ab7d1bd1bd33cc98b0889831b72da23c0aa4df9cec7e0702f46ecea04b35db6"
+dependencies = [
+ "bitflags",
+ "fsevent-sys",
+]
+
+[[package]]
+name = "fsevent-sys"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f41b048a94555da0f42f1d632e2e19510084fb8e303b0daa2816e733fb3644a0"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "fuchsia-zircon"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e9763c69ebaae630ba35f74888db465e49e259ba1bc0eda7d06f4a067615d82"
+dependencies = [
+ "bitflags",
+ "fuchsia-zircon-sys",
+]
+
+[[package]]
+name = "fuchsia-zircon-sys"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3dcaa9ae7725d12cdb85b3ad99a434db70b468c09ded17e012d86b5c1010f7a7"
+
+[[package]]
name = "futures-channel"
-version = "0.3.17"
+version = "0.3.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5da6ba8c3bb3c165d3c7319fc1cc8304facf1fb8db99c5de877183c08a273888"
+checksum = "ba3dda0b6588335f360afc675d0564c17a77a2bda81ca178a4b6081bd86c7f0b"
dependencies = [
"futures-core",
]
[[package]]
name = "futures-core"
-version = "0.3.17"
+version = "0.3.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "88d1c26957f23603395cd326b0ffe64124b818f4449552f960d815cfba83a53d"
+checksum = "d0c8ff0461b82559810cdccfde3215c3f373807f5e5232b71479bff7bb2583d7"
[[package]]
name = "futures-io"
-version = "0.3.17"
+version = "0.3.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "522de2a0fe3e380f1bc577ba0474108faf3f6b18321dbf60b3b9c39a75073377"
+checksum = "b1f9d34af5a1aac6fb380f735fe510746c38067c5bf16c7fd250280503c971b2"
[[package]]
name = "futures-lite"
@@ -293,10 +402,71 @@ dependencies = [
]
[[package]]
+name = "futures-macro"
+version = "0.3.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dbd947adfffb0efc70599b3ddcf7b5597bb5fa9e245eb99f62b3a5f7bb8bd3c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "futures-task"
+version = "0.3.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ee7c6485c30167ce4dfb83ac568a849fe53274c831081476ee13e0dce1aad72"
+
+[[package]]
+name = "futures-util"
+version = "0.3.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9b5cf40b47a271f77a8b1bec03ca09044d99d2372c0de244e66430761127164"
+dependencies = [
+ "futures-core",
+ "futures-macro",
+ "futures-task",
+ "pin-project-lite",
+ "pin-utils",
+ "slab",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.12.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ffdf9f34f1447443d37393cc6c2b8313aebddcd96906caf34e54c68d8e57d7bd"
+dependencies = [
+ "typenum",
+]
+
+[[package]]
+name = "git2"
+version = "0.13.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f29229cc1b24c0e6062f6e742aa3e256492a5323365e5ed3413599f8a5eff7d6"
+dependencies = [
+ "bitflags",
+ "libc",
+ "libgit2-sys",
+ "log",
+ "openssl-probe",
+ "openssl-sys",
+ "url",
+]
+
+[[package]]
+name = "glob"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b919933a397b79c37e33b77bb2aa3dc8eb6e165ad809e58ff75bc7db2e34574"
+
+[[package]]
name = "gloo-timers"
-version = "0.2.1"
+version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "47204a46aaff920a1ea58b11d03dec6f704287d27561724a4631e450654a891f"
+checksum = "6f16c88aa13d2656ef20d1c042086b8767bbe2bdb62526894275a1b062161b2e"
dependencies = [
"futures-channel",
"futures-core",
@@ -322,7 +492,38 @@ checksum = "3c731c3e10504cc8ed35cfe2f1db4c9274c3d35fa486e3b31df46f068ef3e867"
dependencies = [
"libc",
"match_cfg",
- "winapi",
+ "winapi 0.3.9",
+]
+
+[[package]]
+name = "idna"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "418a0a6fab821475f634efe3ccc45c013f742efe03d853e8d3355d5cb850ecf8"
+dependencies = [
+ "matches",
+ "unicode-bidi",
+ "unicode-normalization",
+]
+
+[[package]]
+name = "inotify"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4816c66d2c8ae673df83366c18341538f234a26d65a9ecea5c348b453ac1d02f"
+dependencies = [
+ "bitflags",
+ "inotify-sys",
+ "libc",
+]
+
+[[package]]
+name = "inotify-sys"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb"
+dependencies = [
+ "libc",
]
[[package]]
@@ -331,7 +532,16 @@ version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c"
dependencies = [
- "cfg-if",
+ "cfg-if 1.0.0",
+]
+
+[[package]]
+name = "iovec"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2b3ea6ff95e175473f8ffe6a7eb7c00d054240321b84c57051175fe3c1e075e"
+dependencies = [
+ "libc",
]
[[package]]
@@ -341,6 +551,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b71991ff56294aa922b450139ee08b3bfc70982c6b2c7562771375cf73542dd4"
[[package]]
+name = "itoa"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1aab8fc367588b89dcee83ab0fd66b72b50b72fa1904d7095045ace2b0c81c35"
+
+[[package]]
+name = "jobserver"
+version = "0.1.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af25a77299a7f711a01975c35a6a424eb6862092cc2d6c72c4ed6cbc56dfc1fa"
+dependencies = [
+ "libc",
+]
+
+[[package]]
name = "js-sys"
version = "0.3.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -350,6 +575,16 @@ dependencies = [
]
[[package]]
+name = "kernel32-sys"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7507624b29483431c0ba2d82aece8ca6cdba9382bff4ddd0f7490560c056098d"
+dependencies = [
+ "winapi 0.2.8",
+ "winapi-build",
+]
+
+[[package]]
name = "kv-log-macro"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -365,10 +600,56 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
[[package]]
+name = "lazycell"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55"
+
+[[package]]
name = "libc"
-version = "0.2.107"
+version = "0.2.112"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b03d17f364a3a042d5e5d46b053bbbf82c92c9430c592dd4c064dc6ee997125"
+
+[[package]]
+name = "libgit2-sys"
+version = "0.12.26+1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19e1c899248e606fbfe68dcb31d8b0176ebab833b103824af31bddf4b7457494"
+dependencies = [
+ "cc",
+ "libc",
+ "libssh2-sys",
+ "libz-sys",
+ "openssl-sys",
+ "pkg-config",
+]
+
+[[package]]
+name = "libssh2-sys"
+version = "0.2.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fbe5e23404da5b4f555ef85ebed98fb4083e55a00c317800bc2a50ede9f3d219"
+checksum = "b094a36eb4b8b8c8a7b4b8ae43b2944502be3e59cd87687595cf6b0a71b3f4ca"
+dependencies = [
+ "cc",
+ "libc",
+ "libz-sys",
+ "openssl-sys",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "libz-sys"
+version = "1.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de5435b8549c16d423ed0c03dbaafe57cf6c3344744f1242520d59c9d8ecec66"
+dependencies = [
+ "cc",
+ "libc",
+ "pkg-config",
+ "vcpkg",
+]
[[package]]
name = "log"
@@ -376,17 +657,29 @@ version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51b9bbe6c47d51fc3e1a9b945965946b4c44142ab8792c50835a980d362c2710"
dependencies = [
- "cfg-if",
+ "cfg-if 1.0.0",
"value-bag",
]
[[package]]
+name = "maplit"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
+
+[[package]]
name = "match_cfg"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ffbee8634e0d45d258acb448e7eaab3fce7a0a467395d4d9f228e3c1f01fb2e4"
[[package]]
+name = "matches"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a3e378b66a060d48947b590737b30a1be76706c8dd7b8ba0f2fe3989c68a853f"
+
+[[package]]
name = "memchr"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -394,71 +687,133 @@ checksum = "308cc39be01b73d0d18f82a0e7b2a3df85245f84af96fdddc5d202d27e47b86a"
[[package]]
name = "memoffset"
-version = "0.6.4"
+version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "59accc507f1338036a0477ef61afdae33cde60840f4dfe481319ce3ad116ddf9"
+checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce"
dependencies = [
"autocfg",
]
[[package]]
+name = "mio"
+version = "0.6.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4afd66f5b91bf2a3bc13fad0e21caedac168ca4c707504e75585648ae80e4cc4"
+dependencies = [
+ "cfg-if 0.1.10",
+ "fuchsia-zircon",
+ "fuchsia-zircon-sys",
+ "iovec",
+ "kernel32-sys",
+ "libc",
+ "log",
+ "miow",
+ "net2",
+ "slab",
+ "winapi 0.2.8",
+]
+
+[[package]]
+name = "mio-extras"
+version = "2.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52403fe290012ce777c4626790c8951324a2b9e3316b3143779c72b029742f19"
+dependencies = [
+ "lazycell",
+ "log",
+ "mio",
+ "slab",
+]
+
+[[package]]
+name = "miow"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebd808424166322d4a38da87083bfddd3ac4c131334ed55856112eb06d46944d"
+dependencies = [
+ "kernel32-sys",
+ "net2",
+ "winapi 0.2.8",
+ "ws2_32-sys",
+]
+
+[[package]]
name = "nbsh"
version = "0.1.0"
dependencies = [
"anyhow",
"async-std",
- "chrono",
+ "bincode",
+ "blocking",
"futures-lite",
+ "futures-util",
+ "git2",
+ "glob",
"hostname",
"libc",
"nix",
+ "notify",
+ "once_cell",
+ "pest",
+ "pest_derive",
"pty-process",
- "signal-hook",
+ "serde",
"signal-hook-async-std",
"terminal_size",
"textmode",
+ "time",
"unicode-width",
"users",
"vt100",
]
[[package]]
-name = "nix"
-version = "0.23.0"
+name = "net2"
+version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f305c2c2e4c39a82f7bf0bf65fb557f9070ce06781d4f2454295cc34b1c43188"
+checksum = "391630d12b68002ae1e25e8f974306474966550ad82dac6886fb8910c19568ae"
dependencies = [
- "bitflags",
- "cc",
- "cfg-if",
+ "cfg-if 0.1.10",
"libc",
- "memoffset",
+ "winapi 0.3.9",
]
[[package]]
-name = "num-integer"
-version = "0.1.44"
+name = "nix"
+version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d2cc698a63b549a70bc047073d2949cce27cd1c7b0a4a862d08a8031bc2801db"
+checksum = "9f866317acbd3a240710c63f065ffb1e4fd466259045ccb504130b7f668f35c6"
dependencies = [
- "autocfg",
- "num-traits",
+ "bitflags",
+ "cc",
+ "cfg-if 1.0.0",
+ "libc",
+ "memoffset",
]
[[package]]
-name = "num-traits"
-version = "0.2.14"
+name = "notify"
+version = "4.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290"
+checksum = "ae03c8c853dba7bfd23e571ff0cff7bc9dceb40a4cd684cd1681824183f45257"
dependencies = [
- "autocfg",
+ "bitflags",
+ "filetime",
+ "fsevent",
+ "fsevent-sys",
+ "inotify",
+ "libc",
+ "mio",
+ "mio-extras",
+ "walkdir",
+ "winapi 0.3.9",
]
[[package]]
name = "num_cpus"
-version = "1.13.0"
+version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "05499f3756671c15885fee9034446956fff3f243d6077b91e5767df161f766b3"
+checksum = "19e64526ebdee182341572e50e9ad03965aa510cd94427a4549448f285e957a1"
dependencies = [
"hermit-abi",
"libc",
@@ -466,9 +821,34 @@ dependencies = [
[[package]]
name = "once_cell"
-version = "1.8.0"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da32515d9f6e6e489d7bc9d84c71b060db7247dc035bbe44eac88cf87486d8d5"
+
+[[package]]
+name = "opaque-debug"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2839e79665f131bdb5782e51f2c6c9599c133c6098982a54c794358bf432529c"
+
+[[package]]
+name = "openssl-probe"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "28988d872ab76095a6e6ac88d99b54fd267702734fd7ffe610ca27f533ddb95a"
+
+[[package]]
+name = "openssl-sys"
+version = "0.9.72"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "692fcb63b64b1758029e0a96ee63e049ce8c5948587f2f7208df04625e5f6b56"
+checksum = "7e46109c383602735fa0a2e48dd2b7c892b048e1bf69e5c3b1d804b7d9c203cb"
+dependencies = [
+ "autocfg",
+ "cc",
+ "libc",
+ "pkg-config",
+ "vcpkg",
+]
[[package]]
name = "parking"
@@ -477,6 +857,55 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "427c3892f9e783d91cc128285287e70a59e206ca452770ece88a76f7a3eddd72"
[[package]]
+name = "percent-encoding"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e"
+
+[[package]]
+name = "pest"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10f4872ae94d7b90ae48754df22fd42ad52ce740b8f370b03da4835417403e53"
+dependencies = [
+ "ucd-trie",
+]
+
+[[package]]
+name = "pest_derive"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "833d1ae558dc601e9a60366421196a8d94bc0ac980476d0b67e1d0988d72b2d0"
+dependencies = [
+ "pest",
+ "pest_generator",
+]
+
+[[package]]
+name = "pest_generator"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "99b8db626e31e5b81787b9783425769681b347011cc59471e33ea46d2ea0cf55"
+dependencies = [
+ "pest",
+ "pest_meta",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "pest_meta"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "54be6e404f5317079812fc8f9f5279de376d8856929e21c184ecf6bbd692a11d"
+dependencies = [
+ "maplit",
+ "pest",
+ "sha-1",
+]
+
+[[package]]
name = "pin-project-lite"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -489,52 +918,108 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
+name = "pkg-config"
+version = "0.3.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "58893f751c9b0412871a09abd62ecd2a00298c6c83befa223ef98c52aef40cbe"
+
+[[package]]
name = "polling"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "685404d509889fade3e86fe3a5803bca2ec09b0c0778d5ada6ec8bf7a8de5259"
dependencies = [
- "cfg-if",
+ "cfg-if 1.0.0",
"libc",
"log",
"wepoll-ffi",
- "winapi",
+ "winapi 0.3.9",
]
[[package]]
name = "proc-macro2"
-version = "1.0.32"
+version = "1.0.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ba508cc11742c0dc5c1659771673afbab7a0efab23aa17e854cbab0837ed0b43"
+checksum = "c7342d5883fbccae1cc37a2353b09c87c9b0f3afd73f5fb9bba687a1f733b029"
dependencies = [
"unicode-xid",
]
[[package]]
name = "pty-process"
-version = "0.1.1"
+version = "0.2.0"
dependencies = [
"async-io",
"async-process",
+ "futures-io",
"libc",
"nix",
- "thiserror",
]
[[package]]
name = "quote"
-version = "1.0.10"
+version = "1.0.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47aa80447ce4daf1717500037052af176af5d38cc3e571d9ec1c7353fc10c87d"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.2.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8383f39639269cde97d255a32bdb68c047337295414940c68bdd30c2e13203ff"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.133"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97565067517b60e2d1ea8b268e59ce036de907ac523ad83a0475da04e818989a"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.133"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "38bc8cc6a5f2e3655e0899c1b848643b2562f853f114bfec7be120678e3ace05"
+checksum = "ed201699328568d8d08208fdd080e3ff594e6c422e438b6705905da01005d537"
dependencies = [
"proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "sha-1"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7d94d0bede923b3cea61f3f1ff57ff8cdfd77b400fb8f9998949e0cf04163df"
+dependencies = [
+ "block-buffer",
+ "digest",
+ "fake-simd",
+ "opaque-debug",
]
[[package]]
name = "signal-hook"
-version = "0.3.10"
+version = "0.3.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9c98891d737e271a2954825ef19e46bd16bdb98e2746f2eec4f7a4ef7946efd1"
+checksum = "647c97df271007dcea485bb74ffdb57f2e683f1306c854f468a0c244badabf2d"
dependencies = [
"libc",
"signal-hook-registry",
@@ -574,14 +1059,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5dc90fe6c7be1a323296982db1836d1ea9e47b6839496dde9a541bc496df3516"
dependencies = [
"libc",
- "winapi",
+ "winapi 0.3.9",
]
[[package]]
name = "syn"
-version = "1.0.81"
+version = "1.0.84"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f2afee18b8beb5a596ecb4a2dce128c719b4ba399d34126b9e4396e3f9860966"
+checksum = "ecb2e6da8ee5eb9a61068762a32fa9619cc591ceb055b3687f4cd4051ec2e06b"
dependencies = [
"proc-macro2",
"quote",
@@ -595,51 +1080,73 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "633c1a546cee861a1a6d0dc69ebeca693bf4296661ba7852b9d21d159e0506df"
dependencies = [
"libc",
- "winapi",
+ "winapi 0.3.9",
]
[[package]]
name = "textmode"
-version = "0.2.0"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "caf9ecdb23aae465624a900ae8c795e17f46e081b8454f8ea5b3b5c27a9e7884"
dependencies = [
"blocking",
"futures-lite",
- "itoa",
+ "itoa 1.0.1",
"nix",
"terminal_size",
- "thiserror",
"vt100",
]
[[package]]
-name = "thiserror"
-version = "1.0.30"
+name = "time"
+version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "854babe52e4df1653706b98fcfc05843010039b406875930a70e4d9644e5c417"
+checksum = "41effe7cfa8af36f439fac33861b66b049edc6f9a32331e2312660529c1c24ad"
dependencies = [
- "thiserror-impl",
+ "itoa 0.4.8",
+ "libc",
]
[[package]]
-name = "thiserror-impl"
-version = "1.0.30"
+name = "tinyvec"
+version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "aa32fd3f627f367fe16f893e2597ae3c05020f8bba2666a4e6ea73d377e5714b"
+checksum = "2c1c1d5a42b6245520c249549ec267180beaffcc0615401ac8e31853d4b6d8d2"
dependencies = [
- "proc-macro2",
- "quote",
- "syn",
+ "tinyvec_macros",
]
[[package]]
-name = "time"
-version = "0.1.44"
+name = "tinyvec_macros"
+version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6db9e6914ab8b1ae1c260a4ae7a49b6c5611b40328a735b21862567685e73255"
+checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c"
+
+[[package]]
+name = "typenum"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dcf81ac59edc17cc8697ff311e8f5ef2d99fcbd9817b34cec66f90b6c3dfd987"
+
+[[package]]
+name = "ucd-trie"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56dee185309b50d1f11bfedef0fe6d036842e3fb77413abef29f8f8d1c5d4c1c"
+
+[[package]]
+name = "unicode-bidi"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a01404663e3db436ed2746d9fefef640d868edae3cceb81c3b8d5732fda678f"
+
+[[package]]
+name = "unicode-normalization"
+version = "0.1.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d54590932941a9e9266f0832deed84ebe1bf2e4c9e4a3554d393d18f5e854bf9"
dependencies = [
- "libc",
- "wasi",
- "winapi",
+ "tinyvec",
]
[[package]]
@@ -655,6 +1162,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3"
[[package]]
+name = "url"
+version = "2.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a507c383b2d33b5fc35d1861e77e6b383d158b2da5e14fe51b83dfedf6fd578c"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "matches",
+ "percent-encoding",
+]
+
+[[package]]
name = "users"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -681,16 +1200,24 @@ dependencies = [
]
[[package]]
+name = "vcpkg"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+
+[[package]]
name = "version_check"
-version = "0.9.3"
+version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5fecdca9a5291cc2b8dcf7dc02453fee791a280f3743cb0905f8822ae463b3fe"
+checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f"
[[package]]
name = "vt100"
-version = "0.13.0"
+version = "0.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7541312ce0411d878458abf25175d878e8edc38f9f12ee8eed1d65870cacf540"
dependencies = [
- "itoa",
+ "itoa 1.0.1",
"log",
"unicode-width",
"vte",
@@ -724,10 +1251,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d5b2c62b4012a3e1eca5a7e077d13b3bf498c4073e33ccd58626607748ceeca"
[[package]]
-name = "wasi"
-version = "0.10.0+wasi-snapshot-preview1"
+name = "walkdir"
+version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f"
+checksum = "808cf2735cd4b6866113f648b791c6adc5714537bc222d9347bb203386ffda56"
+dependencies = [
+ "same-file",
+ "winapi 0.3.9",
+ "winapi-util",
+]
[[package]]
name = "wasm-bindgen"
@@ -735,7 +1267,7 @@ version = "0.2.78"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "632f73e236b219150ea279196e54e610f5dbafa5d61786303d4da54f84e47fce"
dependencies = [
- "cfg-if",
+ "cfg-if 1.0.0",
"wasm-bindgen-macro",
]
@@ -760,7 +1292,7 @@ version = "0.4.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e8d7523cb1f2a4c96c1317ca690031b714a51cc14e05f712446691f413f5d39"
dependencies = [
- "cfg-if",
+ "cfg-if 1.0.0",
"js-sys",
"wasm-bindgen",
"web-sys",
@@ -816,6 +1348,12 @@ dependencies = [
[[package]]
name = "winapi"
+version = "0.2.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "167dc9d6949a9b857f3451275e911c3f44255842c1f7a76f33c55103a909087a"
+
+[[package]]
+name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
@@ -825,13 +1363,38 @@ dependencies = [
]
[[package]]
+name = "winapi-build"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2d315eee3b34aca4797b2da6b13ed88266e6d612562a0c46390af8299fc699bc"
+
+[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
+name = "winapi-util"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178"
+dependencies = [
+ "winapi 0.3.9",
+]
+
+[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
+name = "ws2_32-sys"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d59cefebd0c892fa2dd6de581e937301d8552cb44489cdff035c6187cb63fa5e"
+dependencies = [
+ "winapi 0.2.8",
+ "winapi-build",
+]
diff --git a/Cargo.toml b/Cargo.toml
index 38ac03e..5bf19fb 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -6,20 +6,32 @@ edition = "2021"
license = "MIT"
[dependencies]
-anyhow = "1.0.45"
+anyhow = "1.0.52"
async-std = { version = "1.10.0", features = ["unstable"] }
-chrono = "0.4.19"
+bincode = "1.3.3"
+blocking = "1.1.0"
futures-lite = "1.12.0"
+futures-util = "0.3.19"
+git2 = "0.13.25"
+glob = "0.3.0"
hostname = "0.3.1"
-libc = "0.2.107"
-nix = "0.23.0"
-pty-process = { path = "../pty-process", version = "0.1.1", features = ["backend-async-std"] }
-signal-hook = "0.3.10"
+libc = "0.2.112"
+nix = "0.23.1"
+notify = "4.0.17"
+once_cell = "1.9.0"
+pest = "2.1.3"
+pest_derive = "2.1.0"
+pty-process = { version = "0.2.0", features = ["async"] }
+serde = { version = "1.0.133", features = ["derive"] }
signal-hook-async-std = "0.2.1"
terminal_size = "0.1.17"
-textmode = { path = "../textmode", version = "0.2.0", features = ["async"] }
+textmode = { version = "0.3.0", features = ["async"] }
+time = { version = "0.3.5", features = ["formatting", "parsing"] }
unicode-width = "0.1.9"
users = "0.11.0"
-vt100 = { path = "../vt100-rust", version = "0.13.0" }
+vt100 = "0.15.1"
-[features]
+[patch.crates-io]
+# https://github.com/smol-rs/async-process/pull/19
+async-process = { git = "https://github.com/doy/async-process" }
+pty-process = { path = "../pty-process" }
diff --git a/src/action.rs b/src/action.rs
deleted file mode 100644
index 22f088e..0000000
--- a/src/action.rs
+++ /dev/null
@@ -1,123 +0,0 @@
-#[derive(Debug)]
-pub enum Action {
- Render,
- ForceRedraw,
- Run(String),
- UpdateFocus(crate::state::Focus),
- ToggleFullscreen(usize),
- Resize((u16, u16)),
- Quit,
-}
-
-pub struct Debouncer {
- pending: async_std::sync::Mutex<Pending>,
- cvar: async_std::sync::Condvar,
-}
-
-impl Debouncer {
- pub async fn recv(&self) -> Option<Action> {
- let mut pending = self
- .cvar
- .wait_until(self.pending.lock().await, |pending| {
- pending.has_event()
- })
- .await;
- pending.get_event()
- }
-
- async fn send(&self, action: Option<Action>) {
- let mut pending = self.pending.lock().await;
- pending.new_event(&action);
- self.cvar.notify_one();
- }
-}
-
-#[derive(Default)]
-struct Pending {
- render: Option<()>,
- force_redraw: Option<()>,
- run: std::collections::VecDeque<String>,
- focus: Option<crate::state::Focus>,
- fullscreen: std::collections::VecDeque<usize>,
- size: Option<(u16, u16)>,
- done: bool,
-}
-
-impl Pending {
- fn new() -> Self {
- Self::default()
- }
-
- fn has_event(&self) -> bool {
- self.done
- || self.render.is_some()
- || self.force_redraw.is_some()
- || !self.run.is_empty()
- || self.focus.is_some()
- || !self.fullscreen.is_empty()
- || self.size.is_some()
- }
-
- fn get_event(&mut self) -> Option<Action> {
- if self.size.is_some() {
- return Some(Action::Resize(self.size.take().unwrap()));
- }
- if !self.run.is_empty() {
- return Some(Action::Run(self.run.pop_front().unwrap()));
- }
- if self.focus.is_some() {
- return Some(Action::UpdateFocus(self.focus.take().unwrap()));
- }
- if !self.fullscreen.is_empty() {
- return Some(Action::ToggleFullscreen(
- self.fullscreen.pop_front().unwrap(),
- ));
- }
- if self.force_redraw.is_some() {
- self.force_redraw.take();
- self.render.take();
- return Some(Action::ForceRedraw);
- }
- if self.render.is_some() {
- self.render.take();
- return Some(Action::Render);
- }
- if self.done {
- return None;
- }
- unreachable!()
- }
-
- fn new_event(&mut self, action: &Option<Action>) {
- match action {
- Some(Action::Render) => self.render = Some(()),
- Some(Action::ForceRedraw) => self.force_redraw = Some(()),
- Some(Action::Run(cmd)) => self.run.push_back(cmd.to_string()),
- Some(Action::UpdateFocus(focus)) => self.focus = Some(*focus),
- Some(Action::ToggleFullscreen(idx)) => {
- self.fullscreen.push_back(*idx);
- }
- Some(Action::Resize(size)) => self.size = Some(*size),
- Some(Action::Quit) | None => self.done = true,
- }
- }
-}
-
-pub fn debounce(
- input: async_std::channel::Receiver<Action>,
-) -> async_std::sync::Arc<Debouncer> {
- let debouncer = std::sync::Arc::new(Debouncer {
- pending: async_std::sync::Mutex::new(Pending::new()),
- cvar: async_std::sync::Condvar::new(),
- });
- {
- let debouncer = std::sync::Arc::clone(&debouncer);
- async_std::task::spawn(async move {
- while let Ok(action) = input.recv().await {
- debouncer.send(Some(action)).await;
- }
- debouncer.send(None).await;
- });
- }
- debouncer
-}
diff --git a/src/builtins.rs b/src/builtins.rs
deleted file mode 100644
index 225ef5b..0000000
--- a/src/builtins.rs
+++ /dev/null
@@ -1,44 +0,0 @@
-pub fn is(exe: &str) -> bool {
- matches!(exe, "cd")
-}
-
-pub fn run(exe: &str, args: &[String]) -> u8 {
- match exe {
- "cd" => {
- impls::cd(args.iter().map(|s| s.as_ref()).next().unwrap_or(""))
- }
- _ => unreachable!(),
- }
-}
-
-mod impls {
- pub fn cd(dir: &str) -> u8 {
- let dir = if dir.is_empty() {
- home()
- } else if dir.starts_with('~') {
- let path: std::path::PathBuf = dir.into();
- if let std::path::Component::Normal(prefix) =
- path.components().next().unwrap()
- {
- if prefix.to_str() == Some("~") {
- home().join(path.strip_prefix(prefix).unwrap())
- } else {
- // TODO
- return 1;
- }
- } else {
- unreachable!()
- }
- } else {
- dir.into()
- };
- match std::env::set_current_dir(dir) {
- Ok(()) => 0,
- Err(_) => 1,
- }
- }
-
- fn home() -> std::path::PathBuf {
- std::env::var_os("HOME").unwrap().into()
- }
-}
diff --git a/src/env.rs b/src/env.rs
new file mode 100644
index 0000000..3a6d8b5
--- /dev/null
+++ b/src/env.rs
@@ -0,0 +1,151 @@
+use crate::prelude::*;
+
+#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
+pub enum Env {
+ V0(V0),
+}
+
+#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
+pub struct V0 {
+ pwd: std::path::PathBuf,
+ vars: std::collections::HashMap<std::ffi::OsString, std::ffi::OsString>,
+}
+
+const __NBSH_IDX: &str = "__NBSH_IDX";
+const __NBSH_LATEST_STATUS: &str = "__NBSH_LATEST_STATUS";
+const __NBSH_PREV_PWD: &str = "__NBSH_PREV_PWD";
+
+impl Env {
+ pub fn new() -> anyhow::Result<Self> {
+ let pwd = std::env::current_dir()?;
+ Ok(Self::V0(V0 {
+ pwd: pwd.clone(),
+ vars: std::env::vars_os()
+ .chain(Self::defaults(pwd).into_iter())
+ .collect(),
+ }))
+ }
+
+ pub fn new_from_env() -> anyhow::Result<Self> {
+ let pwd = std::env::current_dir()?;
+ Ok(Self::V0(V0 {
+ pwd: pwd.clone(),
+ vars: Self::defaults(pwd)
+ .into_iter()
+ .chain(std::env::vars_os())
+ .collect(),
+ }))
+ }
+
+ pub fn pwd(&self) -> &std::path::Path {
+ match self {
+ Self::V0(env) => &env.pwd,
+ }
+ }
+
+ pub fn var(&self, k: &str) -> Option<String> {
+ match self {
+ Self::V0(env) => self.special_var(k).or_else(|| {
+ env.vars
+ .get(std::ffi::OsStr::new(k))
+ .map(|v| v.to_str().unwrap().to_string())
+ }),
+ }
+ }
+
+ pub fn set_var<
+ K: Into<std::ffi::OsString>,
+ V: Into<std::ffi::OsString>,
+ >(
+ &mut self,
+ k: K,
+ v: V,
+ ) {
+ match self {
+ Self::V0(env) => {
+ env.vars.insert(k.into(), v.into());
+ }
+ }
+ }
+
+ pub fn idx(&self) -> usize {
+ self.var(__NBSH_IDX).unwrap().parse().unwrap()
+ }
+
+ pub fn set_idx(&mut self, idx: usize) {
+ self.set_var(__NBSH_IDX, format!("{}", idx));
+ }
+
+ pub fn latest_status(&self) -> std::process::ExitStatus {
+ std::process::ExitStatus::from_raw(
+ self.var(__NBSH_LATEST_STATUS).unwrap().parse().unwrap(),
+ )
+ }
+
+ pub fn set_status(&mut self, status: std::process::ExitStatus) {
+ self.set_var(
+ __NBSH_LATEST_STATUS,
+ format!(
+ "{}",
+ (status.code().unwrap_or(0) << 8)
+ | status.signal().unwrap_or(0)
+ ),
+ );
+ }
+
+ pub fn prev_pwd(&self) -> std::path::PathBuf {
+ std::path::PathBuf::from(self.var(__NBSH_PREV_PWD).unwrap())
+ }
+
+ pub fn set_prev_pwd(&mut self, prev_pwd: std::path::PathBuf) {
+ self.set_var(__NBSH_PREV_PWD, prev_pwd);
+ }
+
+ pub fn apply(&self, cmd: &mut pty_process::Command) {
+ match self {
+ Self::V0(env) => {
+ cmd.current_dir(&env.pwd);
+ cmd.env_clear();
+ cmd.envs(env.vars.iter());
+ }
+ }
+ }
+
+ pub fn update(&mut self) -> anyhow::Result<()> {
+ let idx = self.idx();
+ let status = self.latest_status();
+ let prev_pwd = self.prev_pwd();
+ *self = Self::new()?;
+ self.set_idx(idx);
+ self.set_status(status);
+ self.set_prev_pwd(prev_pwd);
+ Ok(())
+ }
+
+ fn special_var(&self, k: &str) -> Option<String> {
+ Some(match k {
+ "$" => crate::info::pid(),
+ "?" => {
+ let status = self.latest_status();
+ status
+ .signal()
+ .map_or_else(
+ || status.code().unwrap(),
+ |signal| signal + 128,
+ )
+ .to_string()
+ }
+ _ => return None,
+ })
+ }
+
+ fn defaults(
+ pwd: std::path::PathBuf,
+ ) -> [(std::ffi::OsString, std::ffi::OsString); 3] {
+ [
+ (__NBSH_IDX.into(), "0".into()),
+ (__NBSH_LATEST_STATUS.into(), "0".into()),
+ (__NBSH_PREV_PWD.into(), pwd.into()),
+ ]
+ }
+}
diff --git a/src/format.rs b/src/format.rs
index 50424f0..55757c4 100644
--- a/src/format.rs
+++ b/src/format.rs
@@ -1,15 +1,31 @@
-use std::os::unix::process::ExitStatusExt as _;
+use crate::prelude::*;
-pub fn exit_status(status: std::process::ExitStatus) -> String {
- if let Some(sig) = status.signal() {
- if let Some(name) = signal_hook::low_level::signal_name(sig) {
- format!("{:4} ", &name[3..])
- } else {
- format!("SIG{} ", sig)
+pub fn path(path: &std::path::Path) -> String {
+ let mut path = path.display().to_string();
+ if let Ok(home) = std::env::var("HOME") {
+ if path.starts_with(&home) {
+ path.replace_range(..home.len(), "~");
}
- } else {
- format!("{:03} ", status.code().unwrap())
}
+ path
+}
+
+pub fn exit_status(status: std::process::ExitStatus) -> String {
+ status.signal().map_or_else(
+ || format!("{:03} ", status.code().unwrap()),
+ |sig| {
+ nix::sys::signal::Signal::try_from(sig).map_or_else(
+ |_| format!("SIG{} ", sig),
+ |sig| format!("{:4} ", &sig.as_str()[3..]),
+ )
+ },
+ )
+}
+
+pub fn time(time: time::OffsetDateTime) -> String {
+ let format =
+ time::format_description::parse("[hour]:[minute]:[second]").unwrap();
+ time.format(&format).unwrap()
}
pub fn duration(dur: std::time::Duration) -> String {
@@ -31,3 +47,12 @@ pub fn duration(dur: std::time::Duration) -> String {
format!("{}ns", nanos)
}
}
+
+pub fn io_error(e: &std::io::Error) -> String {
+ let mut s = format!("{}", e);
+ if e.raw_os_error().is_some() {
+ let i = s.rfind('(').unwrap();
+ s.truncate(i - 1);
+ }
+ s
+}
diff --git a/src/history.rs b/src/history.rs
deleted file mode 100644
index d6f198a..0000000
--- a/src/history.rs
+++ /dev/null
@@ -1,407 +0,0 @@
-use async_std::io::{ReadExt as _, WriteExt as _};
-use futures_lite::future::FutureExt as _;
-use pty_process::Command as _;
-use std::os::unix::process::ExitStatusExt as _;
-use textmode::Textmode as _;
-
-pub struct History {
- size: (u16, u16),
- entries: Vec<crate::util::Mutex<HistoryEntry>>,
-}
-
-impl History {
- pub fn new() -> Self {
- Self {
- size: (24, 80),
- entries: vec![],
- }
- }
-
- pub async fn handle_key(&self, key: textmode::Key, idx: usize) {
- let entry = self.entries[idx].lock_arc().await;
- if entry.running() {
- entry.input.send(key.into_bytes()).await.unwrap();
- }
- }
-
- pub async fn render(
- &self,
- out: &mut textmode::Output,
- repl_lines: usize,
- focus: Option<usize>,
- ) -> anyhow::Result<()> {
- if let Some(idx) = focus {
- let mut entry = self.entries[idx].lock_arc().await;
- if entry.should_fullscreen() {
- entry.render_fullscreen(out);
- return Ok(());
- }
- }
-
- let mut used_lines = repl_lines;
- let mut pos = None;
- for (idx, entry) in self.entries.iter().enumerate().rev() {
- let mut entry = entry.lock_arc().await;
- let focused = focus.map_or(false, |focus| idx == focus);
- let last_row = entry.lines(self.size.1, focused);
- used_lines += 1 + std::cmp::min(6, last_row);
- if used_lines > self.size.0 as usize {
- break;
- }
- if focused && used_lines == 1 && entry.running() {
- used_lines = 2;
- }
- out.move_to(
- (self.size.0 as usize - used_lines).try_into().unwrap(),
- 0,
- );
- entry.render(out, self.size.1, focused);
- if focused {
- pos = Some(out.screen().cursor_position());
- }
- }
- if let Some(pos) = pos {
- out.move_to(pos.0, pos.1);
- }
- Ok(())
- }
-
- pub async fn resize(&mut self, size: (u16, u16)) {
- self.size = size;
- for entry in &self.entries {
- let entry = entry.lock_arc().await;
- if entry.running() {
- entry.resize.send(size).await.unwrap();
- }
- }
- }
-
- pub async fn run(
- &mut self,
- cmd: &str,
- action_w: async_std::channel::Sender<crate::action::Action>,
- ) -> anyhow::Result<usize> {
- let (exe, args) = crate::parse::cmd(cmd);
- let (input_w, input_r) = async_std::channel::unbounded();
- let (resize_w, resize_r) = async_std::channel::unbounded();
- let entry = crate::util::mutex(HistoryEntry::new(
- cmd, self.size, input_w, resize_w,
- ));
- if crate::builtins::is(&exe) {
- let code: i32 = crate::builtins::run(&exe, &args).into();
- entry.lock_arc().await.exit_info = Some(ExitInfo::new(
- async_std::process::ExitStatus::from_raw(code << 8),
- ));
- action_w
- .send(crate::action::Action::UpdateFocus(
- crate::state::Focus::Readline,
- ))
- .await
- .unwrap();
- } else {
- let mut process = async_std::process::Command::new(&exe);
- process.args(&args);
- let child = process
- .spawn_pty(Some(&pty_process::Size::new(
- self.size.0,
- self.size.1,
- )))
- .unwrap();
- run_process(
- child,
- async_std::sync::Arc::clone(&entry),
- input_r,
- resize_r,
- action_w,
- );
- }
- self.entries.push(entry);
- Ok(self.entries.len() - 1)
- }
-
- pub async fn toggle_fullscreen(&mut self, idx: usize) {
- self.entries[idx].lock_arc().await.toggle_fullscreen();
- }
-
- pub async fn is_fullscreen(&self, idx: usize) -> bool {
- self.entries[idx].lock_arc().await.should_fullscreen()
- }
-
- pub fn entry_count(&self) -> usize {
- self.entries.len()
- }
-}
-
-struct HistoryEntry {
- cmd: String,
- vt: vt100::Parser,
- audible_bell_state: usize,
- visual_bell_state: usize,
- fullscreen: Option<bool>,
- input: async_std::channel::Sender<Vec<u8>>,
- resize: async_std::channel::Sender<(u16, u16)>,
- start_time: chrono::DateTime<chrono::Local>,
- start_instant: std::time::Instant,
- exit_info: Option<ExitInfo>,
-}
-
-impl HistoryEntry {
- fn new(
- cmd: &str,
- size: (u16, u16),
- input: async_std::channel::Sender<Vec<u8>>,
- resize: async_std::channel::Sender<(u16, u16)>,
- ) -> Self {
- Self {
- cmd: cmd.into(),
- vt: vt100::Parser::new(size.0, size.1, 0),
- audible_bell_state: 0,
- visual_bell_state: 0,
- input,
- resize,
- fullscreen: None,
- start_time: chrono::Local::now(),
- start_instant: std::time::Instant::now(),
- exit_info: None,
- }
- }
-
- fn render(
- &mut self,
- out: &mut textmode::Output,
- width: u16,
- focused: bool,
- ) {
- out.set_bgcolor(textmode::Color::Rgb(32, 32, 32));
- if let Some(info) = self.exit_info {
- if info.status.signal().is_some() {
- out.set_fgcolor(textmode::color::MAGENTA);
- } else if info.status.success() {
- out.set_fgcolor(textmode::color::DARKGREY);
- } else {
- out.set_fgcolor(textmode::color::RED);
- }
- out.write_str(&crate::format::exit_status(info.status));
- } else {
- out.write_str(" ");
- }
- out.reset_attributes();
- if focused {
- out.set_fgcolor(textmode::color::BLACK);
- out.set_bgcolor(textmode::color::CYAN);
- } else {
- out.set_bgcolor(textmode::Color::Rgb(32, 32, 32));
- }
- out.write_str("$ ");
- out.reset_attributes();
- out.set_bgcolor(textmode::Color::Rgb(32, 32, 32));
- if self.running() {
- out.set_bgcolor(textmode::Color::Rgb(16, 64, 16));
- }
- out.write_str(&self.cmd);
- out.reset_attributes();
- out.set_bgcolor(textmode::Color::Rgb(32, 32, 32));
- let time = if let Some(info) = self.exit_info {
- format!(
- "[{} ({:6})]",
- self.start_time.time().format("%H:%M:%S"),
- crate::format::duration(info.instant - self.start_instant)
- )
- } else {
- format!("[{}]", self.start_time.time().format("%H:%M:%S"))
- };
- let cur_pos = out.screen().cursor_position();
- out.write_str(
- &" ".repeat(width as usize - time.len() - 1 - cur_pos.1 as usize),
- );
- out.write_str(&time);
- out.write_str(" ");
- out.reset_attributes();
- let last_row = self.lines(width, focused);
- if last_row > 5 {
- out.write(b"\r\n");
- out.set_fgcolor(textmode::color::BLUE);
- out.write(b"...");
- out.reset_attributes();
- }
- let mut out_row = out.screen().cursor_position().0 + 1;
- let screen = self.vt.screen();
- let pos = screen.cursor_position();
- let mut wrapped = false;
- let mut cursor_found = None;
- for (idx, row) in screen
- .rows_formatted(0, width)
- .enumerate()
- .take(last_row)
- .skip(last_row.saturating_sub(5))
- {
- let idx: u16 = idx.try_into().unwrap();
- out.write(b"\x1b[m");
- if !wrapped {
- out.write(format!("\x1b[{}H", out_row + 1).as_bytes());
- }
- out.write(&row);
- wrapped = screen.row_wrapped(idx);
- if pos.0 == idx {
- cursor_found = Some(out_row);
- }
- out_row += 1;
- }
- if focused {
- if let Some(row) = cursor_found {
- if screen.hide_cursor() {
- out.write(b"\x1b[?25l");
- } else {
- out.write(b"\x1b[?25h");
- out.move_to(row, pos.1);
- }
- } else {
- out.write(b"\x1b[?25l");
- }
- }
- out.reset_attributes();
- }
-
- fn render_fullscreen(&mut self, out: &mut textmode::Output) {
- let screen = self.vt.screen();
- let new_audible_bell_state = screen.audible_bell_count();
- let new_visual_bell_state = screen.visual_bell_count();
-
- out.write(&screen.state_formatted());
-
- if self.audible_bell_state != new_audible_bell_state {
- out.write(b"\x07");
- self.audible_bell_state = new_audible_bell_state;
- }
-
- if self.visual_bell_state != new_visual_bell_state {
- out.write(b"\x1bg");
- self.visual_bell_state = new_visual_bell_state;
- }
-
- out.reset_attributes();
- }
-
- fn toggle_fullscreen(&mut self) {
- if let Some(fullscreen) = self.fullscreen {
- self.fullscreen = Some(!fullscreen);
- } else {
- self.fullscreen = Some(!self.vt.screen().alternate_screen());
- }
- }
-
- fn running(&self) -> bool {
- self.exit_info.is_none()
- }
-
- fn lines(&self, width: u16, focused: bool) -> usize {
- let screen = self.vt.screen();
- let mut last_row = 0;
- for (idx, row) in screen.rows(0, width).enumerate() {
- if !row.is_empty() {
- last_row = idx + 1;
- }
- }
- if focused && self.running() {
- last_row = std::cmp::max(
- last_row,
- screen.cursor_position().0 as usize + 1,
- );
- }
- last_row
- }
-
- fn should_fullscreen(&self) -> bool {
- self.fullscreen
- .unwrap_or_else(|| self.vt.screen().alternate_screen())
- }
-}
-
-#[derive(Copy, Clone)]
-struct ExitInfo {
- status: async_std::process::ExitStatus,
- instant: std::time::Instant,
-}
-
-impl ExitInfo {
- fn new(status: async_std::process::ExitStatus) -> Self {
- Self {
- status,
- instant: std::time::Instant::now(),
- }
- }
-}
-
-fn run_process(
- mut child: pty_process::async_std::Child,
- entry: crate::util::Mutex<HistoryEntry>,
- input_r: async_std::channel::Receiver<Vec<u8>>,
- resize_r: async_std::channel::Receiver<(u16, u16)>,
- action_w: async_std::channel::Sender<crate::action::Action>,
-) {
- async_std::task::spawn(async move {
- loop {
- enum Res {
- Read(Result<usize, std::io::Error>),
- Write(Result<Vec<u8>, async_std::channel::RecvError>),
- Resize(Result<(u16, u16), async_std::channel::RecvError>),
- }
- let mut buf = [0_u8; 4096];
- let mut pty = child.pty();
- let read = async { Res::Read(pty.read(&mut buf).await) };
- let write = async { Res::Write(input_r.recv().await) };
- let resize = async { Res::Resize(resize_r.recv().await) };
- match read.race(write).race(resize).await {
- Res::Read(res) => {
- match res {
- Ok(bytes) => {
- entry.lock_arc().await.vt.process(&buf[..bytes]);
- }
- Err(e) => {
- if e.raw_os_error() != Some(libc::EIO) {
- eprintln!("pty read failed: {:?}", e);
- }
- // XXX not sure if this is safe - are we sure
- // the child exited?
- entry.lock_arc().await.exit_info = Some(
- ExitInfo::new(child.status().await.unwrap()),
- );
- action_w
- .send(crate::action::Action::UpdateFocus(
- crate::state::Focus::Readline,
- ))
- .await
- .unwrap();
- break;
- }
- }
- action_w
- .send(crate::action::Action::Render)
- .await
- .unwrap();
- }
- Res::Write(res) => match res {
- Ok(bytes) => {
- pty.write(&bytes).await.unwrap();
- }
- Err(e) => {
- panic!("failed to read from input channel: {}", e);
- }
- },
- Res::Resize(res) => match res {
- Ok(size) => {
- child
- .resize_pty(&pty_process::Size::new(
- size.0, size.1,
- ))
- .unwrap();
- entry.lock_arc().await.vt.set_size(size.0, size.1);
- }
- Err(e) => {
- panic!("failed to read from resize channel: {}", e);
- }
- },
- }
- }
- });
-}
diff --git a/src/info.rs b/src/info.rs
new file mode 100644
index 0000000..bd94205
--- /dev/null
+++ b/src/info.rs
@@ -0,0 +1,54 @@
+pub fn user() -> anyhow::Result<String> {
+ Ok(users::get_current_username()
+ .ok_or_else(|| anyhow::anyhow!("couldn't get username"))?
+ .to_string_lossy()
+ .into_owned())
+}
+
+#[allow(clippy::unnecessary_wraps)]
+pub fn prompt_char() -> anyhow::Result<String> {
+ if users::get_current_uid() == 0 {
+ Ok("#".into())
+ } else {
+ Ok("$".into())
+ }
+}
+
+pub fn hostname() -> anyhow::Result<String> {
+ let mut hostname = hostname::get()?.to_string_lossy().into_owned();
+ if let Some(idx) = hostname.find('.') {
+ hostname.truncate(idx);
+ }
+ Ok(hostname)
+}
+
+#[allow(clippy::unnecessary_wraps)]
+pub fn time(offset: time::UtcOffset) -> anyhow::Result<String> {
+ Ok(crate::format::time(
+ time::OffsetDateTime::now_utc().to_offset(offset),
+ ))
+}
+
+pub fn pid() -> String {
+ nix::unistd::getpid().to_string()
+}
+
+// the time crate is currently unable to get the local offset on unix due to
+// soundness concerns, so we have to do it manually/:
+//
+// https://github.com/time-rs/time/issues/380
+pub fn get_offset() -> time::UtcOffset {
+ let offset_str =
+ std::process::Command::new("date").args(&["+%:z"]).output();
+ if let Ok(offset_str) = offset_str {
+ let offset_str = String::from_utf8(offset_str.stdout).unwrap();
+ time::UtcOffset::parse(
+ offset_str.trim(),
+ &time::format_description::parse("[offset_hour]:[offset_minute]")
+ .unwrap(),
+ )
+ .unwrap_or(time::UtcOffset::UTC)
+ } else {
+ time::UtcOffset::UTC
+ }
+}
diff --git a/src/main.rs b/src/main.rs
index 94d6f90..48d2b2b 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,113 +1,67 @@
+// will uncomment this once it is closer to release
+// #![warn(clippy::cargo)]
#![warn(clippy::pedantic)]
#![warn(clippy::nursery)]
+#![warn(clippy::as_conversions)]
+#![warn(clippy::get_unwrap)]
+#![allow(clippy::cognitive_complexity)]
#![allow(clippy::missing_const_for_fn)]
+#![allow(clippy::similar_names)]
+#![allow(clippy::struct_excessive_bools)]
+#![allow(clippy::too_many_arguments)]
#![allow(clippy::too_many_lines)]
-#![allow(clippy::unused_self)]
+#![allow(clippy::type_complexity)]
+// this isn't super relevant in a binary - if it's actually a problem, we'll
+// just get a compilation failure
+#![allow(clippy::future_not_send)]
-mod action;
-mod builtins;
+mod env;
mod format;
-mod history;
+mod info;
+mod mutex;
mod parse;
-mod readline;
-mod state;
-mod util;
+mod prelude;
+mod runner;
+mod shell;
-use async_std::stream::StreamExt as _;
+use prelude::*;
-async fn resize(
- action_w: &async_std::channel::Sender<crate::action::Action>,
-) {
- let size = terminal_size::terminal_size().map_or(
- (24, 80),
- |(terminal_size::Width(w), terminal_size::Height(h))| (h, w),
- );
- action_w
- .send(crate::action::Action::Resize(size))
- .await
- .unwrap();
-}
-
-async fn async_main() -> anyhow::Result<()> {
- let mut input = textmode::Input::new().await?;
- let mut output = textmode::Output::new().await?;
-
- // avoid the guards getting stuck in a task that doesn't run to
- // completion
- let _input_guard = input.take_raw_guard();
- let _output_guard = output.take_screen_guard();
-
- let (action_w, action_r) = async_std::channel::unbounded();
-
- let state = state::State::new();
- state.render(&mut output, true).await.unwrap();
-
- let state = util::mutex(state);
-
- {
- let mut signals = signal_hook_async_std::Signals::new(&[
- signal_hook::consts::signal::SIGWINCH,
- ])?;
- let action_w = action_w.clone();
- async_std::task::spawn(async move {
- while signals.next().await.is_some() {
- resize(&action_w).await;
- }
- });
+async fn async_main(
+ shell_write: Option<&async_std::fs::File>,
+) -> anyhow::Result<i32> {
+ if std::env::args().nth(1).as_deref() == Some("-c") {
+ return runner::run(
+ std::env::args().nth(2).as_deref().unwrap(),
+ shell_write,
+ )
+ .await;
}
- resize(&action_w).await;
-
- {
- let state = async_std::sync::Arc::clone(&state);
- let action_w = action_w.clone();
- async_std::task::spawn(async move {
- while let Some(key) = input.read_key().await.unwrap() {
- if let Some(action) =
- state.lock_arc().await.handle_key(key).await
- {
- action_w.send(action).await.unwrap();
- }
- }
- });
- }
-
- // redraw the clock every second
- {
- let action_w = action_w.clone();
- async_std::task::spawn(async move {
- let first_sleep = 1_000_000_000_u64.saturating_sub(
- chrono::Local::now().timestamp_subsec_nanos().into(),
- );
- async_std::task::sleep(std::time::Duration::from_nanos(
- first_sleep,
- ))
- .await;
- let mut interval = async_std::stream::interval(
- std::time::Duration::from_secs(1),
- );
- action_w.send(crate::action::Action::Render).await.unwrap();
- while interval.next().await.is_some() {
- action_w.send(crate::action::Action::Render).await.unwrap();
- }
- });
- }
-
- let debouncer = crate::action::debounce(action_r);
- while let Some(action) = debouncer.recv().await {
- state
- .lock_arc()
- .await
- .handle_action(action, &mut output, &action_w)
- .await;
- }
-
- Ok(())
+ shell::main().await
}
fn main() {
- match async_std::task::block_on(async_main()) {
- Ok(_) => (),
+ // need to do this here because the async-std executor allocates some fds,
+ // and so in the case where we aren't being called from the main shell and
+ // fd 3 wasn't preallocated in advance, we need to be able to tell that
+ // before async-std opens something on fd 3
+ let shell_write = if nix::sys::stat::fstat(3).is_ok() {
+ nix::fcntl::fcntl(
+ 3,
+ nix::fcntl::FcntlArg::F_SETFD(nix::fcntl::FdFlag::FD_CLOEXEC),
+ )
+ .unwrap();
+ // Safety: we don't create File instances for or read/write data on fd
+ // 3 anywhere else
+ Some(unsafe { async_std::fs::File::from_raw_fd(3) })
+ } else {
+ None
+ };
+
+ match async_std::task::block_on(async_main(shell_write.as_ref())) {
+ Ok(code) => {
+ std::process::exit(code);
+ }
Err(e) => {
eprintln!("nbsh: {}", e);
std::process::exit(1);
diff --git a/src/mutex.rs b/src/mutex.rs
new file mode 100644
index 0000000..1c6faff
--- /dev/null
+++ b/src/mutex.rs
@@ -0,0 +1,18 @@
+pub type Mutex<T> = async_std::sync::Arc<async_std::sync::Mutex<T>>;
+pub type Guard<T> = async_std::sync::MutexGuardArc<T>;
+
+pub fn new<T>(t: T) -> async_std::sync::Arc<async_std::sync::Mutex<T>> {
+ async_std::sync::Arc::new(async_std::sync::Mutex::new(t))
+}
+
+pub fn clone<T>(m: &Mutex<T>) -> Mutex<T> {
+ async_std::sync::Arc::clone(m)
+}
+
+pub fn unwrap<T: std::fmt::Debug>(t: Mutex<T>) -> Option<T> {
+ if let Ok(mutex) = async_std::sync::Arc::try_unwrap(t) {
+ Some(async_std::sync::Mutex::into_inner(mutex))
+ } else {
+ None
+ }
+}
diff --git a/src/parse.rs b/src/parse.rs
index 84e8daa..cc6d92b 100644
--- a/src/parse.rs
+++ b/src/parse.rs
@@ -1,8 +1,162 @@
-pub fn cmd(full_cmd: &str) -> (String, Vec<String>) {
- let mut parts = full_cmd.split(' ');
- let cmd = parts.next().unwrap();
- (
- cmd.to_string(),
- parts.map(std::string::ToString::to_string).collect(),
- )
+pub mod ast;
+
+#[derive(Debug, Eq, PartialEq)]
+pub struct Pipeline {
+ exes: Vec<Exe>,
+}
+
+impl Pipeline {
+ pub fn into_exes(self) -> impl Iterator<Item = Exe> {
+ self.exes.into_iter()
+ }
+}
+
+#[derive(Debug, Eq, PartialEq)]
+pub struct Exe {
+ exe: std::path::PathBuf,
+ args: Vec<String>,
+ redirects: Vec<Redirect>,
+}
+
+impl Exe {
+ pub fn exe(&self) -> &std::path::Path {
+ &self.exe
+ }
+
+ pub fn args(&self) -> &[String] {
+ &self.args
+ }
+
+ pub fn redirects(&self) -> &[Redirect] {
+ &self.redirects
+ }
+
+ pub fn shift(&mut self) {
+ self.exe = std::path::PathBuf::from(self.args.remove(0));
+ }
+}
+
+#[derive(Debug, Clone, Eq, PartialEq)]
+pub struct Redirect {
+ pub from: std::os::unix::io::RawFd,
+ pub to: RedirectTarget,
+ pub dir: Direction,
+}
+
+#[derive(Debug, Clone, Eq, PartialEq)]
+pub enum RedirectTarget {
+ Fd(std::os::unix::io::RawFd),
+ File(std::path::PathBuf),
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Direction {
+ In,
+ Out,
+ Append,
+}
+
+impl Direction {
+ pub fn open(
+ self,
+ path: &std::path::Path,
+ ) -> nix::Result<std::os::unix::io::RawFd> {
+ use nix::fcntl::OFlag;
+ use nix::sys::stat::Mode;
+ Ok(match self {
+ Self::In => nix::fcntl::open(
+ path,
+ OFlag::O_NOCTTY | OFlag::O_RDONLY,
+ Mode::empty(),
+ )?,
+ Self::Out => nix::fcntl::open(
+ path,
+ OFlag::O_CREAT
+ | OFlag::O_NOCTTY
+ | OFlag::O_WRONLY
+ | OFlag::O_TRUNC,
+ Mode::S_IRUSR
+ | Mode::S_IWUSR
+ | Mode::S_IRGRP
+ | Mode::S_IWGRP
+ | Mode::S_IROTH
+ | Mode::S_IWOTH,
+ )?,
+ Self::Append => nix::fcntl::open(
+ path,
+ OFlag::O_APPEND
+ | OFlag::O_CREAT
+ | OFlag::O_NOCTTY
+ | OFlag::O_WRONLY,
+ Mode::S_IRUSR
+ | Mode::S_IWUSR
+ | Mode::S_IRGRP
+ | Mode::S_IWGRP
+ | Mode::S_IROTH
+ | Mode::S_IWOTH,
+ )?,
+ })
+ }
+}
+
+#[derive(Debug, Eq, PartialEq)]
+pub struct Error {
+ input: String,
+ e: pest::error::Error<ast::Rule>,
+}
+
+impl Error {
+ fn new(input: &str, e: pest::error::Error<ast::Rule>) -> Self {
+ Self {
+ input: input.to_string(),
+ e,
+ }
+ }
+}
+
+impl std::fmt::Display for Error {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match &self.e.variant {
+ pest::error::ErrorVariant::ParsingError {
+ positives,
+ negatives,
+ } => {
+ if !positives.is_empty() {
+ write!(f, "expected {:?}", positives[0])?;
+ for rule in &positives[1..] {
+ write!(f, ", {:?}", rule)?;
+ }
+ if !negatives.is_empty() {
+ write!(f, "; ")?;
+ }
+ }
+ if !negatives.is_empty() {
+ write!(f, "unexpected {:?}", negatives[0])?;
+ for rule in &negatives[1..] {
+ write!(f, ", {:?}", rule)?;
+ }
+ }
+ writeln!(f)?;
+ writeln!(f, "{}", self.input)?;
+ match &self.e.location {
+ pest::error::InputLocation::Pos(i) => {
+ write!(f, "{}^", " ".repeat(*i))?;
+ }
+ pest::error::InputLocation::Span((i, j)) => {
+ write!(f, "{}{}", " ".repeat(*i), "^".repeat(j - i))?;
+ }
+ }
+ }
+ pest::error::ErrorVariant::CustomError { message } => {
+ write!(f, "{}", message)?;
+ }
+ }
+ Ok(())
+ }
+}
+
+impl std::error::Error for Error {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ Some(&self.e)
+ }
}
diff --git a/src/parse/ast.rs b/src/parse/ast.rs
new file mode 100644
index 0000000..9d74331
--- /dev/null
+++ b/src/parse/ast.rs
@@ -0,0 +1,549 @@
+use crate::prelude::*;
+
+use pest::Parser as _;
+
+#[derive(pest_derive::Parser)]
+#[grammar = "shell.pest"]
+struct Shell;
+
+#[derive(Debug, PartialEq, Eq)]
+pub struct Commands {
+ commands: Vec<Command>,
+}
+
+impl Commands {
+ pub fn parse(full_cmd: &str) -> Result<Self, super::Error> {
+ Ok(Self::build_ast(
+ Shell::parse(Rule::line, full_cmd)
+ .map_err(|e| super::Error::new(full_cmd, e))?
+ .next()
+ .unwrap()
+ .into_inner()
+ .next()
+ .unwrap(),
+ ))
+ }
+
+ pub fn commands(&self) -> &[Command] {
+ &self.commands
+ }
+
+ fn build_ast(commands: pest::iterators::Pair<Rule>) -> Self {
+ assert!(matches!(commands.as_rule(), Rule::commands));
+ Self {
+ commands: commands.into_inner().map(Command::build_ast).collect(),
+ }
+ }
+}
+
+#[derive(Debug, PartialEq, Eq)]
+pub enum Command {
+ Pipeline(Pipeline),
+ If(Pipeline),
+ While(Pipeline),
+ For(String, Vec<Word>),
+ End,
+}
+
+impl Command {
+ fn build_ast(command: pest::iterators::Pair<Rule>) -> Self {
+ assert!(matches!(command.as_rule(), Rule::command));
+ let next = command.into_inner().next().unwrap();
+ match next.as_rule() {
+ Rule::pipeline => Self::Pipeline(Pipeline::build_ast(next)),
+ Rule::control => {
+ let ty = next.into_inner().next().unwrap();
+ match ty.as_rule() {
+ Rule::control_if => Self::If(Pipeline::build_ast(
+ ty.into_inner().next().unwrap(),
+ )),
+ Rule::control_while => Self::While(Pipeline::build_ast(
+ ty.into_inner().next().unwrap(),
+ )),
+ Rule::control_for => {
+ let mut inner = ty.into_inner();
+ let var = inner.next().unwrap();
+ assert!(matches!(var.as_rule(), Rule::bareword));
+ let list = inner.next().unwrap();
+ assert!(matches!(list.as_rule(), Rule::list));
+ let vals =
+ list.into_inner().map(Word::build_ast).collect();
+ Self::For(var.as_str().to_string(), vals)
+ }
+ Rule::control_end => Self::End,
+ _ => unreachable!(),
+ }
+ }
+ _ => unreachable!(),
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Pipeline {
+ exes: Vec<Exe>,
+ span: (usize, usize),
+}
+
+impl Pipeline {
+ pub fn eval(self, env: &Env) -> anyhow::Result<super::Pipeline> {
+ Ok(super::Pipeline {
+ exes: self
+ .exes
+ .into_iter()
+ .map(|exe| exe.eval(env))
+ .collect::<Result<_, _>>()?,
+ })
+ }
+
+ pub fn span(&self) -> (usize, usize) {
+ self.span
+ }
+
+ fn build_ast(pipeline: pest::iterators::Pair<Rule>) -> Self {
+ assert!(matches!(pipeline.as_rule(), Rule::pipeline));
+ let span = (pipeline.as_span().start(), pipeline.as_span().end());
+ Self {
+ exes: pipeline.into_inner().map(Exe::build_ast).collect(),
+ span,
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct Exe {
+ exe: Word,
+ args: Vec<Word>,
+ redirects: Vec<Redirect>,
+}
+
+impl Exe {
+ fn eval(self, env: &Env) -> anyhow::Result<super::Exe> {
+ let exe = self.exe.eval(env)?;
+ assert_eq!(exe.len(), 1); // TODO
+ let exe = &exe[0];
+ Ok(super::Exe {
+ exe: std::path::PathBuf::from(exe),
+ args: self
+ .args
+ .into_iter()
+ .map(|arg| arg.eval(env).map(IntoIterator::into_iter))
+ .collect::<Result<Vec<_>, _>>()?
+ .into_iter()
+ .flatten()
+ .collect(),
+ redirects: self
+ .redirects
+ .into_iter()
+ .map(|arg| arg.eval(env))
+ .collect::<Result<_, _>>()?,
+ })
+ }
+
+ fn build_ast(pair: pest::iterators::Pair<Rule>) -> Self {
+ assert!(matches!(pair.as_rule(), Rule::subshell | Rule::exe));
+ if matches!(pair.as_rule(), Rule::subshell) {
+ return Self {
+ exe: Word {
+ parts: vec![WordPart::SingleQuoted(
+ std::env::current_exe()
+ .unwrap()
+ .to_str()
+ .unwrap()
+ .to_string(),
+ )],
+ },
+ args: vec![
+ Word {
+ parts: vec![WordPart::SingleQuoted("-c".to_string())],
+ },
+ Word {
+ parts: vec![WordPart::SingleQuoted(
+ pair.as_str()
+ .strip_prefix('(')
+ .unwrap()
+ .strip_suffix(')')
+ .unwrap()
+ .to_string(),
+ )],
+ },
+ ],
+ redirects: vec![],
+ };
+ }
+ let mut iter = pair.into_inner();
+ let exe = match WordOrRedirect::build_ast(iter.next().unwrap()) {
+ WordOrRedirect::Word(word) => word,
+ WordOrRedirect::Redirect(_) => todo!(),
+ };
+ let (args, redirects): (_, Vec<_>) = iter
+ .map(WordOrRedirect::build_ast)
+ .partition(|word| matches!(word, WordOrRedirect::Word(_)));
+ let args = args
+ .into_iter()
+ .map(|word| match word {
+ WordOrRedirect::Word(word) => word,
+ WordOrRedirect::Redirect(_) => unreachable!(),
+ })
+ .collect();
+ let redirects = redirects
+ .into_iter()
+ .map(|word| match word {
+ WordOrRedirect::Word(_) => unreachable!(),
+ WordOrRedirect::Redirect(redirect) => redirect,
+ })
+ .collect();
+ Self {
+ exe,
+ args,
+ redirects,
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Word {
+ parts: Vec<WordPart>,
+}
+
+impl Word {
+ fn build_ast(pair: pest::iterators::Pair<Rule>) -> Self {
+ assert!(matches!(
+ pair.as_rule(),
+ Rule::word | Rule::alternation_word
+ ));
+ Self {
+ parts: pair.into_inner().flat_map(WordPart::build_ast).collect(),
+ }
+ }
+
+ pub fn eval(self, env: &Env) -> anyhow::Result<Vec<String>> {
+ let mut opts = glob::MatchOptions::new();
+ opts.require_literal_separator = true;
+ opts.require_literal_leading_dot = true;
+
+ let mut alternations: Vec<Vec<Vec<WordPart>>> = vec![];
+ let mut cur: Vec<WordPart> = vec![];
+ for part in self.parts {
+ if let WordPart::Alternation(words) = part {
+ if !cur.is_empty() {
+ alternations.push(vec![cur.clone()]);
+ cur.clear();
+ }
+ alternations
+ .push(words.into_iter().map(|word| word.parts).collect());
+ } else {
+ cur.push(part.clone());
+ }
+ }
+ if !cur.is_empty() {
+ alternations.push(vec![cur]);
+ }
+ let mut words: Vec<Vec<WordPart>> = std::iter::repeat(vec![])
+ .take(alternations.iter().map(Vec::len).product())
+ .collect();
+ for i in 0..words.len() {
+ let mut len = words.len();
+ for alternation in &alternations {
+ let idx = (i * alternation.len() / len) % alternation.len();
+ words[i].extend(alternation[idx].clone().into_iter());
+ len /= alternation.len();
+ }
+ }
+
+ let mut expanded_words = vec![];
+ for word in words {
+ let mut s = String::new();
+ let mut pat = String::new();
+ let mut is_glob = false;
+ let initial_bareword = word
+ .get(0)
+ .map_or(false, |part| matches!(part, WordPart::Bareword(_)));
+ for part in word {
+ match part {
+ WordPart::Alternation(_) => unreachable!(),
+ WordPart::Bareword(_) => {
+ let part = part.eval(env);
+ s.push_str(&part);
+ pat.push_str(&part);
+ if part.contains(&['*', '?', '['][..]) {
+ is_glob = true;
+ }
+ }
+ WordPart::Var(_)
+ | WordPart::DoubleQuoted(_)
+ | WordPart::SingleQuoted(_) => {
+ let part = part.eval(env);
+ s.push_str(&part);
+ pat.push_str(&glob::Pattern::escape(&part));
+ }
+ }
+ }
+ if initial_bareword {
+ s = expand_home(&s)?;
+ pat = expand_home(&pat)?;
+ }
+ if is_glob {
+ let mut found = false;
+ for file in glob::glob_with(&pat, opts)? {
+ let file = file?;
+ let s = file.to_str().unwrap();
+ if s == "."
+ || s == ".."
+ || s.ends_with("/.")
+ || s.ends_with("/..")
+ {
+ continue;
+ }
+ found = true;
+ expanded_words.push(s.to_string());
+ }
+ if !found {
+ anyhow::bail!("no matches for {}", s);
+ }
+ } else {
+ expanded_words.push(s);
+ }
+ }
+ Ok(expanded_words)
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+enum WordPart {
+ Alternation(Vec<Word>),
+ Var(String),
+ Bareword(String),
+ DoubleQuoted(String),
+ SingleQuoted(String),
+}
+
+impl WordPart {
+ #[allow(clippy::needless_pass_by_value)]
+ fn build_ast(
+ pair: pest::iterators::Pair<Rule>,
+ ) -> impl Iterator<Item = Self> + '_ {
+ assert!(matches!(
+ pair.as_rule(),
+ Rule::word_part | Rule::alternation_word_part
+ ));
+ pair.into_inner().map(|pair| match pair.as_rule() {
+ Rule::var => {
+ let s = pair.as_str();
+ let inner = s.strip_prefix('$').unwrap();
+ Self::Var(
+ inner
+ .strip_prefix('{')
+ .map_or(inner, |inner| {
+ inner.strip_suffix('}').unwrap()
+ })
+ .to_string(),
+ )
+ }
+ Rule::bareword | Rule::alternation_bareword => {
+ Self::Bareword(strip_escape(pair.as_str()))
+ }
+ Rule::double_string => {
+ Self::DoubleQuoted(strip_escape(pair.as_str()))
+ }
+ Rule::single_string => {
+ Self::SingleQuoted(strip_basic_escape(pair.as_str()))
+ }
+ Rule::alternation => Self::Alternation(
+ pair.into_inner().map(Word::build_ast).collect(),
+ ),
+ _ => unreachable!(),
+ })
+ }
+
+ fn eval(self, env: &Env) -> String {
+ match self {
+ Self::Alternation(_) => unreachable!(),
+ Self::Var(name) => {
+ env.var(&name).unwrap_or_else(|| "".to_string())
+ }
+ Self::Bareword(s)
+ | Self::DoubleQuoted(s)
+ | Self::SingleQuoted(s) => s,
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct Redirect {
+ from: std::os::unix::io::RawFd,
+ to: Word,
+ dir: super::Direction,
+}
+
+impl Redirect {
+ fn parse(prefix: &str, to: Word) -> Self {
+ let (from, dir) = if let Some(from) = prefix.strip_suffix(">>") {
+ (from, super::Direction::Append)
+ } else if let Some(from) = prefix.strip_suffix('>') {
+ (from, super::Direction::Out)
+ } else if let Some(from) = prefix.strip_suffix('<') {
+ (from, super::Direction::In)
+ } else {
+ unreachable!()
+ };
+ let from = if from.is_empty() {
+ match dir {
+ super::Direction::In => 0,
+ super::Direction::Out | super::Direction::Append => 1,
+ }
+ } else {
+ parse_fd(from)
+ };
+ Self { from, to, dir }
+ }
+
+ fn eval(self, env: &Env) -> anyhow::Result<super::Redirect> {
+ let to = if self.to.parts.len() == 1 {
+ if let WordPart::Bareword(s) = &self.to.parts[0] {
+ if let Some(fd) = s.strip_prefix('&') {
+ super::RedirectTarget::Fd(parse_fd(fd))
+ } else {
+ let to = self.to.eval(env)?;
+ assert_eq!(to.len(), 1); // TODO
+ let to = &to[0];
+ super::RedirectTarget::File(std::path::PathBuf::from(to))
+ }
+ } else {
+ let to = self.to.eval(env)?;
+ assert_eq!(to.len(), 1); // TODO
+ let to = &to[0];
+ super::RedirectTarget::File(std::path::PathBuf::from(to))
+ }
+ } else {
+ let to = self.to.eval(env)?;
+ assert_eq!(to.len(), 1); // TODO
+ let to = &to[0];
+ super::RedirectTarget::File(std::path::PathBuf::from(to))
+ };
+ Ok(super::Redirect {
+ from: self.from,
+ to,
+ dir: self.dir,
+ })
+ }
+}
+
+enum WordOrRedirect {
+ Word(Word),
+ Redirect(Redirect),
+}
+
+impl WordOrRedirect {
+ fn build_ast(pair: pest::iterators::Pair<Rule>) -> Self {
+ assert!(matches!(pair.as_rule(), Rule::word_or_redirect));
+ let mut inner = pair.into_inner().peekable();
+ let prefix = if matches!(
+ inner.peek().map(pest::iterators::Pair::as_rule),
+ Some(Rule::redir_prefix)
+ ) {
+ Some(inner.next().unwrap().as_str().trim().to_string())
+ } else {
+ None
+ };
+ let word = Word::build_ast(inner.next().unwrap());
+ if let Some(prefix) = prefix {
+ Self::Redirect(Redirect::parse(&prefix, word))
+ } else {
+ Self::Word(word)
+ }
+ }
+}
+
+fn strip_escape(s: &str) -> String {
+ let mut new = String::new();
+ let mut escape = false;
+ for c in s.chars() {
+ if escape {
+ new.push(c);
+ escape = false;
+ } else {
+ match c {
+ '\\' => escape = true,
+ _ => new.push(c),
+ }
+ }
+ }
+ new
+}
+
+fn strip_basic_escape(s: &str) -> String {
+ let mut new = String::new();
+ let mut escape = false;
+ for c in s.chars() {
+ if escape {
+ match c {
+ '\\' | '\'' => {}
+ _ => new.push('\\'),
+ }
+ new.push(c);
+ escape = false;
+ } else {
+ match c {
+ '\\' => escape = true,
+ _ => new.push(c),
+ }
+ }
+ }
+ new
+}
+
+fn parse_fd(s: &str) -> std::os::unix::io::RawFd {
+ match s {
+ "in" => 0,
+ "out" => 1,
+ "err" => 2,
+ _ => s.parse().unwrap(),
+ }
+}
+
+fn expand_home(dir: &str) -> anyhow::Result<String> {
+ if dir.starts_with('~') {
+ let path: std::path::PathBuf = dir.into();
+ if let std::path::Component::Normal(prefix) =
+ path.components().next().unwrap()
+ {
+ let prefix_bytes = prefix.as_bytes();
+ let name = if prefix_bytes == b"~" {
+ None
+ } else {
+ Some(std::ffi::OsStr::from_bytes(&prefix_bytes[1..]))
+ };
+ if let Some(home) = home(name) {
+ Ok(home
+ .join(path.strip_prefix(prefix).unwrap())
+ .to_str()
+ .unwrap()
+ .to_string())
+ } else {
+ anyhow::bail!(
+ "no such user: {}",
+ name.map(std::ffi::OsStr::to_string_lossy)
+ .as_ref()
+ .unwrap_or(&std::borrow::Cow::Borrowed("(deleted)"))
+ );
+ }
+ } else {
+ unreachable!()
+ }
+ } else {
+ Ok(dir.to_string())
+ }
+}
+
+fn home(user: Option<&std::ffi::OsStr>) -> Option<std::path::PathBuf> {
+ let user = user.map_or_else(
+ || users::get_user_by_uid(users::get_current_uid()),
+ users::get_user_by_name,
+ );
+ user.map(|user| user.home_dir().to_path_buf())
+}
+
+#[cfg(test)]
+#[path = "test_ast.rs"]
+mod test;
diff --git a/src/parse/test_ast.rs b/src/parse/test_ast.rs
new file mode 100644
index 0000000..f762615
--- /dev/null
+++ b/src/parse/test_ast.rs
@@ -0,0 +1,483 @@
+use super::*;
+
+impl From<Pipeline> for Command {
+ fn from(pipeline: Pipeline) -> Self {
+ Self::Pipeline(pipeline)
+ }
+}
+
+macro_rules! cs {
+ ($($commands:expr),*) => {
+ Commands {
+ commands: [$($commands),*]
+ .into_iter()
+ .map(|c| c.into())
+ .collect(),
+ }
+ };
+}
+
+macro_rules! p {
+ ($span:expr, $($exes:expr),*) => {
+ Pipeline {
+ exes: vec![$($exes),*],
+ span: $span,
+ }
+ };
+}
+
+macro_rules! ep {
+ ($($exes:expr),*) => {
+ super::super::Pipeline {
+ exes: vec![$($exes),*],
+ }
+ };
+}
+
+macro_rules! e {
+ ($word:expr) => {
+ Exe {
+ exe: $word,
+ args: vec![],
+ redirects: vec![],
+ }
+ };
+ ($word:expr, $($args:expr),*) => {
+ Exe {
+ exe: $word,
+ args: vec![$($args),*],
+ redirects: vec![],
+ }
+ };
+ ($word:expr ; $($redirects:expr),*) => {
+ Exe {
+ exe: $word,
+ args: vec![],
+ redirects: vec![$($redirects),*],
+ }
+ };
+ ($word:expr, $($args:expr),* ; $($redirects:expr),*) => {
+ Exe {
+ exe: $word,
+ args: vec![$($args),*],
+ redirects: vec![$($redirects),*],
+ }
+ };
+}
+
+macro_rules! ee {
+ ($exe:expr) => {
+ super::super::Exe {
+ exe: std::path::PathBuf::from($exe.to_string()),
+ args: vec![],
+ redirects: vec![],
+ }
+ };
+ ($exe:expr, $($args:expr),*) => {
+ super::super::Exe {
+ exe: std::path::PathBuf::from($exe.to_string()),
+ args: [$($args),*]
+ .into_iter()
+ .map(|s| s.to_string())
+ .collect(),
+ redirects: vec![],
+ }
+ };
+}
+
+macro_rules! r {
+ ($from:literal, $to:expr, $dir:ident) => {
+ Redirect {
+ from: $from,
+ to: $to,
+ dir: super::super::Direction::$dir,
+ }
+ };
+}
+
+macro_rules! w {
+ ($word:literal) => {
+ Word {
+ parts: vec![WordPart::Bareword($word.to_string())],
+ }
+ };
+ ($($word:expr),*) => {
+ Word {
+ parts: vec![$($word),*],
+ }
+ }
+}
+
+macro_rules! wpa {
+ ($($word:expr),*) => {
+ WordPart::Alternation(vec![$($word),*])
+ }
+}
+
+macro_rules! wpv {
+ ($var:literal) => {
+ WordPart::Var($var.to_string())
+ };
+}
+
+macro_rules! wpb {
+ ($bareword:expr) => {
+ WordPart::Bareword($bareword.to_string())
+ };
+}
+
+macro_rules! wpd {
+ ($doublequoted:expr) => {
+ WordPart::DoubleQuoted($doublequoted.to_string())
+ };
+}
+
+macro_rules! wps {
+ ($singlequoted:expr) => {
+ WordPart::SingleQuoted($singlequoted.to_string())
+ };
+}
+
+macro_rules! parse_eq {
+ ($line:literal, $parsed:expr) => {
+ assert_eq!(&Commands::parse($line).unwrap(), &$parsed)
+ };
+}
+
+macro_rules! eval_eq {
+ ($line:literal, $env:expr, $($evaled:expr),*) => {{
+ let ast = Commands::parse($line).unwrap();
+ let mut expected: Vec<super::super::Pipeline>
+ = vec![$($evaled),*];
+ for command in ast.commands {
+ let pipeline = match command {
+ Command::Pipeline(p)
+ | Command::If(p)
+ | Command::While(p) => p,
+ _ => continue,
+ };
+ assert_eq!(pipeline.eval(&$env).unwrap(), expected.remove(0));
+ }
+ }};
+}
+
+macro_rules! eval_fails {
+ ($line:literal, $env:expr) => {{
+ let ast = Commands::parse($line).unwrap();
+ let mut fail = false;
+ for command in ast.commands {
+ let pipeline = match command {
+ Command::Pipeline(p) | Command::If(p) | Command::While(p) => {
+ p
+ }
+ _ => continue,
+ };
+ if pipeline.eval(&$env).is_err() {
+ fail = true;
+ }
+ }
+ assert!(fail)
+ }};
+}
+
+#[test]
+fn test_basic() {
+ parse_eq!("foo", cs!(p!((0, 3), e!(w!("foo")))));
+ parse_eq!("foo bar", cs!(p!((0, 7), e!(w!("foo"), w!("bar")))));
+ parse_eq!(
+ "foo bar baz",
+ cs!(p!((0, 11), e!(w!("foo"), w!("bar"), w!("baz"))))
+ );
+ parse_eq!("foo | bar", cs!(p!((0, 9), e!(w!("foo")), e!(w!("bar")))));
+ parse_eq!(
+ "command ls; perl -E 'say foo' | tr a-z A-Z; builtin echo bar",
+ cs!(
+ p!((0, 10), e!(w!("command"), w!("ls"))),
+ p!(
+ (12, 42),
+ e!(w!("perl"), w!("-E"), w!(wps!("say foo"))),
+ e!(w!("tr"), w!("a-z"), w!("A-Z"))
+ ),
+ p!((44, 60), e!(w!("builtin"), w!("echo"), w!("bar")))
+ )
+ );
+
+ // XXX this parse may change in the future
+ let exe = std::env::current_exe()
+ .unwrap()
+ .into_os_string()
+ .into_string()
+ .unwrap();
+ parse_eq!(
+ "seq 1 5 | (while read line; echo \"line: $line\"; end)",
+ cs!(p!(
+ (0, 52),
+ e!(w!("seq"), w!("1"), w!("5")),
+ e!(
+ w!(wps!(exe)),
+ w!(wps!("-c")),
+ w!(wps!("while read line; echo \"line: $line\"; end"))
+ )
+ ))
+ );
+}
+
+#[test]
+fn test_whitespace() {
+ parse_eq!(" foo ", cs!(p!((3, 6), e!(w!("foo")))));
+ parse_eq!(
+ " foo # this is a comment",
+ cs!(p!((3, 6), e!(w!("foo"))))
+ );
+ parse_eq!("foo#comment", cs!(p!((0, 3), e!(w!("foo")))));
+ parse_eq!(
+ "foo;bar|baz;quux#comment",
+ cs!(
+ p!((0, 3), e!(w!("foo"))),
+ p!((4, 11), e!(w!("bar")), e!(w!("baz"))),
+ p!((12, 16), e!(w!("quux")))
+ )
+ );
+ parse_eq!(
+ "foo | bar ",
+ cs!(p!((0, 12), e!(w!("foo")), e!(w!("bar"))))
+ );
+ parse_eq!(
+ " abc def ghi |jkl mno| pqr stu; vwxyz # comment",
+ cs!(
+ p!(
+ (2, 36),
+ e!(w!("abc"), w!("def"), w!("ghi")),
+ e!(w!("jkl"), w!("mno")),
+ e!(w!("pqr"), w!("stu"))
+ ),
+ p!((38, 43), e!(w!("vwxyz")))
+ )
+ );
+ parse_eq!(
+ "foo 'bar # baz' \"quux # not a comment\" # comment",
+ cs!(p!(
+ (0, 38),
+ e!(
+ w!("foo"),
+ w!(wps!("bar # baz")),
+ w!(wpd!("quux # not a comment"))
+ )
+ ))
+ );
+}
+
+#[test]
+fn test_redirect() {
+ parse_eq!(
+ "foo > bar",
+ cs!(p!((0, 9), e!(w!("foo") ; r!(1, w!("bar"), Out))))
+ );
+ parse_eq!(
+ "foo <bar",
+ cs!(p!((0, 8), e!(w!("foo") ; r!(0, w!("bar"), In))))
+ );
+ parse_eq!(
+ "foo > /dev/null 2>&1",
+ cs!(p!(
+ (0, 20),
+ e!(
+ w!("foo") ;
+ r!(1, w!("/dev/null"), Out), r!(2, w!("&1"), Out)
+ )
+ ))
+ );
+ parse_eq!(
+ "foo >>bar",
+ cs!(p!((0, 9), e!(w!("foo") ; r!(1, w!("bar"), Append))))
+ );
+ parse_eq!(
+ "foo >> bar",
+ cs!(p!((0, 10), e!(w!("foo") ; r!(1, w!("bar"), Append))))
+ );
+ parse_eq!(
+ "foo > 'bar baz'",
+ cs!(p!((0, 15), e!(w!("foo") ; r!(1, w!(wps!("bar baz")), Out))))
+ );
+}
+
+#[test]
+fn test_escape() {
+ parse_eq!("foo\\ bar", cs!(p!((0, 8), e!(w!("foo bar")))));
+ parse_eq!("'foo\\ bar'", cs!(p!((0, 10), e!(w!(wps!("foo\\ bar"))))));
+ parse_eq!("\"foo\\ bar\"", cs!(p!((0, 10), e!(w!(wpd!("foo bar"))))));
+ parse_eq!("\"foo\\\"bar\"", cs!(p!((0, 10), e!(w!(wpd!("foo\"bar"))))));
+ parse_eq!(
+ "'foo\\'bar\\\\'",
+ cs!(p!((0, 12), e!(w!(wps!("foo'bar\\")))))
+ );
+ parse_eq!(
+ "foo > bar\\ baz",
+ cs!(p!((0, 14), e!(w!("foo") ; r!(1, w!("bar baz"), Out))))
+ );
+}
+
+#[test]
+fn test_parts() {
+ parse_eq!(
+ "echo \"$HOME/bin\"",
+ cs!(p!((0, 16), e!(w!("echo"), w!(wpv!("HOME"), wpd!("/bin")))))
+ );
+ parse_eq!(
+ "echo \"dir: $HOME/bin\"",
+ cs!(p!(
+ (0, 21),
+ e!(w!("echo"), w!(wpd!("dir: "), wpv!("HOME"), wpd!("/bin")))
+ ))
+ );
+ parse_eq!(
+ "echo $HOME/bin",
+ cs!(p!((0, 14), e!(w!("echo"), w!(wpv!("HOME"), wpb!("/bin")))))
+ );
+ parse_eq!(
+ "echo '$HOME/bin'",
+ cs!(p!((0, 16), e!(w!("echo"), w!(wps!("$HOME/bin")))))
+ );
+ parse_eq!(
+ "echo \"foo\"\"bar\"",
+ cs!(p!((0, 15), e!(w!("echo"), w!(wpd!("foo"), wpd!("bar")))))
+ );
+ parse_eq!(
+ "echo $foo$bar$baz",
+ cs!(p!(
+ (0, 17),
+ e!(w!("echo"), w!(wpv!("foo"), wpv!("bar"), wpv!("baz")))
+ ))
+ );
+ parse_eq!(
+ "perl -E'say \"foo\"'",
+ cs!(p!(
+ (0, 18),
+ e!(w!("perl"), w!(wpb!("-E"), wps!("say \"foo\"")))
+ ))
+ );
+}
+
+#[test]
+fn test_alternation() {
+ parse_eq!(
+ "echo {foo,bar}",
+ cs!(p!((0, 14), e!(w!("echo"), w!(wpa!(w!("foo"), w!("bar"))))))
+ );
+ parse_eq!(
+ "echo {foo,bar}.rs",
+ cs!(p!(
+ (0, 17),
+ e!(w!("echo"), w!(wpa!(w!("foo"), w!("bar")), wpb!(".rs")))
+ ))
+ );
+ parse_eq!(
+ "echo {foo,bar,baz}.rs",
+ cs!(p!(
+ (0, 21),
+ e!(
+ w!("echo"),
+ w!(wpa!(w!("foo"), w!("bar"), w!("baz")), wpb!(".rs"))
+ )
+ ))
+ );
+ parse_eq!(
+ "echo {foo,}.rs",
+ cs!(p!(
+ (0, 14),
+ e!(w!("echo"), w!(wpa!(w!("foo"), w!()), wpb!(".rs")))
+ ))
+ );
+ parse_eq!(
+ "echo {foo}",
+ cs!(p!((0, 10), e!(w!("echo"), w!(wpa!(w!("foo"))))))
+ );
+ parse_eq!("echo {}", cs!(p!((0, 7), e!(w!("echo"), w!(wpa!(w!()))))));
+ parse_eq!(
+ "echo {foo,bar}.{rs,c}",
+ cs!(p!(
+ (0, 21),
+ e!(
+ w!("echo"),
+ w!(
+ wpa!(w!("foo"), w!("bar")),
+ wpb!("."),
+ wpa!(w!("rs"), w!("c"))
+ )
+ )
+ ))
+ );
+ parse_eq!(
+ "echo {$foo,\"${HOME}/bin\"}.{'r'\"s\",c}",
+ cs!(p!(
+ (0, 36),
+ e!(
+ w!("echo"),
+ w!(
+ wpa!(w!(wpv!("foo")), w!(wpv!("HOME"), wpd!("/bin"))),
+ wpb!("."),
+ wpa!(w!(wps!("r"), wpd!("s")), w!("c"))
+ )
+ )
+ ))
+ );
+}
+
+#[test]
+fn test_eval_alternation() {
+ let mut env = Env::new().unwrap();
+ env.set_var("HOME", "/home/test");
+ env.set_var("foo", "value-of-foo");
+
+ eval_eq!("echo {foo,bar}", env, ep!(ee!("echo", "foo", "bar")));
+ eval_eq!(
+ "echo {foo,bar}.rs",
+ env,
+ ep!(ee!("echo", "foo.rs", "bar.rs"))
+ );
+ eval_eq!(
+ "echo {foo,bar,baz}.rs",
+ env,
+ ep!(ee!("echo", "foo.rs", "bar.rs", "baz.rs"))
+ );
+ eval_eq!("echo {foo,}.rs", env, ep!(ee!("echo", "foo.rs", ".rs")));
+ eval_eq!("echo {foo}", env, ep!(ee!("echo", "foo")));
+ eval_eq!("echo {}", env, ep!(ee!("echo", "")));
+ eval_eq!(
+ "echo {foo,bar}.{rs,c}",
+ env,
+ ep!(ee!("echo", "foo.rs", "foo.c", "bar.rs", "bar.c"))
+ );
+ eval_eq!(
+ "echo {$foo,\"${HOME}/bin\"}.{'r'\"s\",c}",
+ env,
+ ep!(ee!(
+ "echo",
+ "value-of-foo.rs",
+ "value-of-foo.c",
+ "/home/test/bin.rs",
+ "/home/test/bin.c"
+ ))
+ );
+}
+
+#[test]
+fn test_eval_glob() {
+ let env = Env::new().unwrap();
+
+ eval_eq!(
+ "echo *.toml",
+ env,
+ ep!(ee!("echo", "Cargo.toml", "deny.toml"))
+ );
+ eval_eq!("echo .*.toml", env, ep!(ee!("echo", ".rustfmt.toml")));
+ eval_eq!(
+ "echo *.{lock,toml}",
+ env,
+ ep!(ee!("echo", "Cargo.lock", "Cargo.toml", "deny.toml"))
+ );
+ eval_eq!("echo foo]", env, ep!(ee!("echo", "foo]")));
+ eval_fails!("echo foo[", env);
+ eval_fails!("echo *.doesnotexist", env);
+ eval_fails!("echo *.{toml,doesnotexist}", env);
+}
diff --git a/src/prelude.rs b/src/prelude.rs
new file mode 100644
index 0000000..6789a1f
--- /dev/null
+++ b/src/prelude.rs
@@ -0,0 +1,11 @@
+pub use crate::env::Env;
+
+pub use async_std::io::{ReadExt as _, WriteExt as _};
+pub use async_std::stream::StreamExt as _;
+pub use futures_lite::future::FutureExt as _;
+
+pub use async_std::os::unix::process::CommandExt as _;
+pub use std::os::unix::ffi::{OsStrExt as _, OsStringExt as _};
+pub use std::os::unix::io::{AsRawFd as _, FromRawFd as _, IntoRawFd as _};
+pub use std::os::unix::process::ExitStatusExt as _;
+pub use users::os::unix::UserExt as _;
diff --git a/src/readline.rs b/src/readline.rs
deleted file mode 100644
index 8b3c847..0000000
--- a/src/readline.rs
+++ /dev/null
@@ -1,198 +0,0 @@
-use textmode::Textmode as _;
-use unicode_width::{UnicodeWidthChar as _, UnicodeWidthStr as _};
-
-pub struct Readline {
- size: (u16, u16),
- prompt: String,
- input_line: String,
- pos: usize,
-}
-
-impl Readline {
- pub fn new() -> Self {
- Self {
- size: (24, 80),
- prompt: if users::get_current_uid() == 0 {
- "# "
- } else {
- "$ "
- }
- .into(),
- input_line: "".into(),
- pos: 0,
- }
- }
-
- pub async fn handle_key(
- &mut self,
- key: textmode::Key,
- ) -> Option<crate::action::Action> {
- match key {
- textmode::Key::String(s) => self.add_input(&s),
- textmode::Key::Char(c) => {
- self.add_input(&c.to_string());
- }
- textmode::Key::Ctrl(b'c') => self.clear_input(),
- textmode::Key::Ctrl(b'd') => {
- return Some(crate::action::Action::Quit);
- }
- textmode::Key::Ctrl(b'l') => {
- return Some(crate::action::Action::ForceRedraw);
- }
- textmode::Key::Ctrl(b'm') => {
- let cmd = self.input();
- self.clear_input();
- return Some(crate::action::Action::Run(cmd));
- }
- textmode::Key::Ctrl(b'u') => self.clear_backwards(),
- textmode::Key::Backspace => self.backspace(),
- textmode::Key::Left => self.cursor_left(),
- textmode::Key::Right => self.cursor_right(),
- _ => {}
- }
- Some(crate::action::Action::Render)
- }
-
- pub async fn render(
- &self,
- out: &mut textmode::Output,
- focus: bool,
- ) -> anyhow::Result<()> {
- let mut pwd = std::env::current_dir()?.display().to_string();
- let home = std::env::var("HOME")?;
- if pwd.starts_with(&home) {
- pwd.replace_range(..home.len(), "~");
- }
- let user = users::get_current_username()
- .unwrap()
- .to_string_lossy()
- .into_owned();
- let mut hostname =
- hostname::get().unwrap().to_string_lossy().into_owned();
- if let Some(idx) = hostname.find('.') {
- hostname.truncate(idx);
- }
- let id = format!("{}@{}", user, hostname);
- let idlen: u16 = id.len().try_into().unwrap();
- let time = chrono::Local::now().format("%H:%M:%S").to_string();
- let timelen: u16 = time.len().try_into().unwrap();
-
- out.move_to(self.size.0 - 2, 0);
- out.set_bgcolor(textmode::Color::Rgb(32, 32, 64));
- out.write(b"\x1b[K");
- out.write(b" (");
- out.write_str(&pwd);
- out.write(b")");
- out.move_to(self.size.0 - 2, self.size.1 - 4 - idlen - timelen);
- out.write_str(&id);
- out.write_str(" [");
- out.write_str(&time);
- out.write_str("]");
-
- out.move_to(self.size.0 - 1, 0);
- if focus {
- out.set_fgcolor(textmode::color::BLACK);
- out.set_bgcolor(textmode::color::CYAN);
- } else {
- out.set_bgcolor(textmode::Color::Rgb(32, 32, 32));
- }
- out.write_str(&self.prompt);
- out.reset_attributes();
- out.set_bgcolor(textmode::Color::Rgb(32, 32, 32));
- out.write(b"\x1b[K");
- out.write_str(&self.input_line);
- out.reset_attributes();
- out.move_to(self.size.0 - 1, self.prompt_width() + self.pos_width());
- if focus {
- out.write(b"\x1b[?25h");
- }
- Ok(())
- }
-
- pub async fn resize(&mut self, size: (u16, u16)) {
- self.size = size;
- }
-
- pub fn lines(&self) -> usize {
- 2 // XXX handle wrapping
- }
-
- fn input(&self) -> String {
- self.input_line.clone()
- }
-
- fn add_input(&mut self, s: &str) {
- self.input_line.insert_str(self.byte_pos(), s);
- self.pos += s.chars().count();
- }
-
- fn backspace(&mut self) {
- while self.pos > 0 {
- self.pos -= 1;
- let width =
- self.input_line.remove(self.byte_pos()).width().unwrap_or(0);
- if width > 0 {
- break;
- }
- }
- }
-
- fn clear_input(&mut self) {
- self.input_line.clear();
- self.pos = 0;
- }
-
- fn clear_backwards(&mut self) {
- self.input_line = self.input_line.chars().skip(self.pos).collect();
- self.pos = 0;
- }
-
- fn cursor_left(&mut self) {
- if self.pos == 0 {
- return;
- }
- self.pos -= 1;
- while let Some(c) = self.input_line.chars().nth(self.pos) {
- if c.width().unwrap_or(0) == 0 {
- self.pos -= 1;
- } else {
- break;
- }
- }
- }
-
- fn cursor_right(&mut self) {
- if self.pos == self.input_line.chars().count() {
- return;
- }
- self.pos += 1;
- while let Some(c) = self.input_line.chars().nth(self.pos) {
- if c.width().unwrap_or(0) == 0 {
- self.pos += 1;
- } else {
- break;
- }
- }
- }
-
- fn prompt_width(&self) -> u16 {
- self.prompt.width().try_into().unwrap()
- }
-
- fn pos_width(&self) -> u16 {
- self.input_line
- .chars()
- .take(self.pos)
- .collect::<String>()
- .width()
- .try_into()
- .unwrap()
- }
-
- fn byte_pos(&self) -> usize {
- self.input_line
- .char_indices()
- .nth(self.pos)
- .map_or(self.input_line.len(), |(i, _)| i)
- }
-}
diff --git a/src/runner/builtins/command.rs b/src/runner/builtins/command.rs
new file mode 100644
index 0000000..afba100
--- /dev/null
+++ b/src/runner/builtins/command.rs
@@ -0,0 +1,394 @@
+use crate::runner::prelude::*;
+
+pub struct Command {
+ exe: crate::parse::Exe,
+ f: super::Builtin,
+ cfg: Cfg,
+}
+
+impl Command {
+ pub fn new(
+ exe: crate::parse::Exe,
+ io: Io,
+ ) -> Result<Self, crate::parse::Exe> {
+ if let Some(s) = exe.exe().to_str() {
+ if let Some(f) = super::BUILTINS.get(s) {
+ Ok(Self {
+ exe,
+ f,
+ cfg: Cfg::new(io),
+ })
+ } else {
+ Err(exe)
+ }
+ } else {
+ Err(exe)
+ }
+ }
+
+ pub fn stdin(&mut self, fh: std::fs::File) {
+ self.cfg.io.set_stdin(fh);
+ }
+
+ pub fn stdout(&mut self, fh: std::fs::File) {
+ self.cfg.io.set_stdout(fh);
+ }
+
+ pub fn stderr(&mut self, fh: std::fs::File) {
+ self.cfg.io.set_stderr(fh);
+ }
+
+ // Safety: see pre_exec in async_std::os::unix::process::CommandExt (this
+ // is just a wrapper)
+ pub unsafe fn pre_exec<F>(&mut self, f: F)
+ where
+ F: 'static + FnMut() -> std::io::Result<()> + Send + Sync,
+ {
+ self.cfg.pre_exec(f);
+ }
+
+ pub fn apply_redirects(&mut self, redirects: &[crate::parse::Redirect]) {
+ self.cfg.io.apply_redirects(redirects);
+ }
+
+ pub fn spawn(self, env: &Env) -> anyhow::Result<Child> {
+ let Self { f, exe, cfg } = self;
+ (f)(exe, env, cfg)
+ }
+}
+
+pub struct Cfg {
+ io: Io,
+ pre_exec: Option<
+ Box<dyn 'static + FnMut() -> std::io::Result<()> + Send + Sync>,
+ >,
+}
+
+impl Cfg {
+ fn new(io: Io) -> Self {
+ Self { io, pre_exec: None }
+ }
+
+ pub fn io(&self) -> &Io {
+ &self.io
+ }
+
+ // Safety: see pre_exec in async_std::os::unix::process::CommandExt (this
+ // is just a wrapper)
+ pub unsafe fn pre_exec<F>(&mut self, f: F)
+ where
+ F: 'static + FnMut() -> std::io::Result<()> + Send + Sync,
+ {
+ self.pre_exec = Some(Box::new(f));
+ }
+
+ pub fn setup_command(mut self, cmd: &mut crate::runner::Command) {
+ self.io.setup_command(cmd);
+ if let Some(pre_exec) = self.pre_exec.take() {
+ // Safety: pre_exec can only have been set by calling the pre_exec
+ // method, which is itself unsafe, so the safety comments at the
+ // point where that is called are the relevant ones
+ unsafe { cmd.pre_exec(pre_exec) };
+ }
+ }
+}
+
+#[derive(Debug, Clone)]
+pub struct Io {
+ fds: std::collections::HashMap<
+ std::os::unix::io::RawFd,
+ crate::mutex::Mutex<File>,
+ >,
+}
+
+impl Io {
+ pub fn new() -> Self {
+ Self {
+ fds: std::collections::HashMap::new(),
+ }
+ }
+
+ fn stdin(&self) -> Option<crate::mutex::Mutex<File>> {
+ self.fds.get(&0).map(crate::mutex::clone)
+ }
+
+ pub fn set_stdin<T: std::os::unix::io::IntoRawFd>(&mut self, stdin: T) {
+ if let Some(file) = self.fds.remove(&0) {
+ File::maybe_drop(file);
+ }
+ self.fds.insert(
+ 0,
+ // Safety: we just acquired stdin via into_raw_fd, which acquires
+ // ownership of the fd, so we are now the sole owner
+ crate::mutex::new(unsafe { File::input(stdin.into_raw_fd()) }),
+ );
+ }
+
+ fn stdout(&self) -> Option<crate::mutex::Mutex<File>> {
+ self.fds.get(&1).map(crate::mutex::clone)
+ }
+
+ pub fn set_stdout<T: std::os::unix::io::IntoRawFd>(&mut self, stdout: T) {
+ if let Some(file) = self.fds.remove(&1) {
+ File::maybe_drop(file);
+ }
+ self.fds.insert(
+ 1,
+ // Safety: we just acquired stdout via into_raw_fd, which acquires
+ // ownership of the fd, so we are now the sole owner
+ crate::mutex::new(unsafe { File::output(stdout.into_raw_fd()) }),
+ );
+ }
+
+ fn stderr(&self) -> Option<crate::mutex::Mutex<File>> {
+ self.fds.get(&2).map(crate::mutex::clone)
+ }
+
+ pub fn set_stderr<T: std::os::unix::io::IntoRawFd>(&mut self, stderr: T) {
+ if let Some(file) = self.fds.remove(&2) {
+ File::maybe_drop(file);
+ }
+ self.fds.insert(
+ 2,
+ // Safety: we just acquired stderr via into_raw_fd, which acquires
+ // ownership of the fd, so we are now the sole owner
+ crate::mutex::new(unsafe { File::output(stderr.into_raw_fd()) }),
+ );
+ }
+
+ pub fn apply_redirects(&mut self, redirects: &[crate::parse::Redirect]) {
+ for redirect in redirects {
+ let to = match &redirect.to {
+ crate::parse::RedirectTarget::Fd(fd) => {
+ crate::mutex::clone(&self.fds[fd])
+ }
+ crate::parse::RedirectTarget::File(path) => {
+ let fd = redirect.dir.open(path).unwrap();
+ match redirect.dir {
+ crate::parse::Direction::In => {
+ // Safety: we just opened fd, and nothing else has
+ // or can use it
+ crate::mutex::new(unsafe { File::input(fd) })
+ }
+ crate::parse::Direction::Out
+ | crate::parse::Direction::Append => {
+ // Safety: we just opened fd, and nothing else has
+ // or can use it
+ crate::mutex::new(unsafe { File::output(fd) })
+ }
+ }
+ }
+ };
+ self.fds.insert(redirect.from, to);
+ }
+ }
+
+ pub async fn read_line_stdin(&self) -> anyhow::Result<(String, bool)> {
+ let mut buf = vec![];
+ if let Some(fh) = self.stdin() {
+ if let File::In(fh) = &mut *fh.lock_arc().await {
+ // we have to read only a single character at a time here
+ // because stdin needs to be shared across all commands in the
+ // command list, some of which may be builtins and others of
+ // which may be external commands - if we read past the end of
+ // a line, then the characters past the end of that line will
+ // no longer be available to the next command, since we have
+ // them buffered in memory rather than them being on the stdin
+ // pipe.
+ let mut c = [0_u8];
+ loop {
+ match fh.read_exact(&mut c[..]).await {
+ Ok(()) => {}
+ Err(e) => {
+ if e.kind() == std::io::ErrorKind::UnexpectedEof {
+ break;
+ }
+ return Err(e.into());
+ }
+ }
+ if c[0] == b'\n' {
+ break;
+ }
+ buf.push(c[0]);
+ }
+ }
+ }
+ let done = buf.is_empty();
+ let mut buf = String::from_utf8(buf).unwrap();
+ if buf.ends_with('\n') {
+ buf.truncate(buf.len() - 1);
+ }
+ Ok((buf, done))
+ }
+
+ pub async fn write_stdout(&self, buf: &[u8]) -> anyhow::Result<()> {
+ if let Some(fh) = self.stdout() {
+ if let File::Out(fh) = &mut *fh.lock_arc().await {
+ Ok(fh.write_all(buf).await.map(|_| ())?)
+ } else {
+ Ok(())
+ }
+ } else {
+ Ok(())
+ }
+ }
+
+ pub async fn write_stderr(&self, buf: &[u8]) -> anyhow::Result<()> {
+ if let Some(fh) = self.stderr() {
+ if let File::Out(fh) = &mut *fh.lock_arc().await {
+ Ok(fh.write_all(buf).await.map(|_| ())?)
+ } else {
+ Ok(())
+ }
+ } else {
+ Ok(())
+ }
+ }
+
+ pub fn setup_command(mut self, cmd: &mut crate::runner::Command) {
+ if let Some(stdin) = self.fds.remove(&0) {
+ if let Some(stdin) = crate::mutex::unwrap(stdin) {
+ let stdin = stdin.into_raw_fd();
+ if stdin != 0 {
+ // Safety: we just acquired stdin via into_raw_fd, which
+ // acquires ownership of the fd, so we are now the sole
+ // owner
+ cmd.stdin(unsafe { std::fs::File::from_raw_fd(stdin) });
+ self.fds.remove(&0);
+ }
+ }
+ }
+ if let Some(stdout) = self.fds.remove(&1) {
+ if let Some(stdout) = crate::mutex::unwrap(stdout) {
+ let stdout = stdout.into_raw_fd();
+ if stdout != 1 {
+ // Safety: we just acquired stdout via into_raw_fd, which
+ // acquires ownership of the fd, so we are now the sole
+ // owner
+ cmd.stdout(unsafe { std::fs::File::from_raw_fd(stdout) });
+ self.fds.remove(&1);
+ }
+ }
+ }
+ if let Some(stderr) = self.fds.remove(&2) {
+ if let Some(stderr) = crate::mutex::unwrap(stderr) {
+ let stderr = stderr.into_raw_fd();
+ if stderr != 2 {
+ // Safety: we just acquired stderr via into_raw_fd, which
+ // acquires ownership of the fd, so we are now the sole
+ // owner
+ cmd.stderr(unsafe { std::fs::File::from_raw_fd(stderr) });
+ self.fds.remove(&2);
+ }
+ }
+ }
+ }
+}
+
+impl Drop for Io {
+ fn drop(&mut self) {
+ for (_, file) in self.fds.drain() {
+ File::maybe_drop(file);
+ }
+ }
+}
+
+#[derive(Debug)]
+pub enum File {
+ In(async_std::fs::File),
+ Out(async_std::fs::File),
+}
+
+impl File {
+ // Safety: fd must not be owned by any other File object
+ pub unsafe fn input(fd: std::os::unix::io::RawFd) -> Self {
+ Self::In(async_std::fs::File::from_raw_fd(fd))
+ }
+
+ // Safety: fd must not be owned by any other File object
+ pub unsafe fn output(fd: std::os::unix::io::RawFd) -> Self {
+ Self::Out(async_std::fs::File::from_raw_fd(fd))
+ }
+
+ fn maybe_drop(file: crate::mutex::Mutex<Self>) {
+ if let Some(file) = crate::mutex::unwrap(file) {
+ if file.as_raw_fd() <= 2 {
+ let _ = file.into_raw_fd();
+ }
+ }
+ }
+}
+
+impl std::os::unix::io::AsRawFd for File {
+ fn as_raw_fd(&self) -> std::os::unix::io::RawFd {
+ match self {
+ Self::In(fh) | Self::Out(fh) => fh.as_raw_fd(),
+ }
+ }
+}
+
+impl std::os::unix::io::IntoRawFd for File {
+ fn into_raw_fd(self) -> std::os::unix::io::RawFd {
+ match self {
+ Self::In(fh) | Self::Out(fh) => fh.into_raw_fd(),
+ }
+ }
+}
+
+pub struct Child<'a> {
+ fut: std::pin::Pin<
+ Box<
+ dyn std::future::Future<Output = std::process::ExitStatus>
+ + Sync
+ + Send
+ + 'a,
+ >,
+ >,
+ wrapped_child: Option<Box<crate::runner::Child<'a>>>,
+}
+
+impl<'a> Child<'a> {
+ pub fn new_fut<F>(fut: F) -> Self
+ where
+ F: std::future::Future<Output = std::process::ExitStatus>
+ + Sync
+ + Send
+ + 'a,
+ {
+ Self {
+ fut: Box::pin(fut),
+ wrapped_child: None,
+ }
+ }
+
+ pub fn new_wrapped(child: crate::runner::Child<'a>) -> Self {
+ Self {
+ fut: Box::pin(async move { unreachable!() }),
+ wrapped_child: Some(Box::new(child)),
+ }
+ }
+
+ pub fn id(&self) -> Option<u32> {
+ self.wrapped_child.as_ref().and_then(|cmd| cmd.id())
+ }
+
+ pub fn status(
+ self,
+ ) -> std::pin::Pin<
+ Box<
+ dyn std::future::Future<
+ Output = anyhow::Result<async_std::process::ExitStatus>,
+ > + Send
+ + Sync
+ + 'a,
+ >,
+ > {
+ Box::pin(async move {
+ if let Some(child) = self.wrapped_child {
+ child.status().await
+ } else {
+ Ok(self.fut.await)
+ }
+ })
+ }
+}
diff --git a/src/runner/builtins/mod.rs b/src/runner/builtins/mod.rs
new file mode 100644
index 0000000..5205856
--- /dev/null
+++ b/src/runner/builtins/mod.rs
@@ -0,0 +1,288 @@
+use crate::runner::prelude::*;
+
+pub mod command;
+pub use command::{Child, Command, File, Io};
+
+type Builtin = &'static (dyn for<'a> Fn(
+ crate::parse::Exe,
+ &'a Env,
+ command::Cfg,
+) -> anyhow::Result<command::Child<'a>>
+ + Sync
+ + Send);
+
+#[allow(clippy::as_conversions)]
+static BUILTINS: once_cell::sync::Lazy<
+ std::collections::HashMap<&'static str, Builtin>,
+> = once_cell::sync::Lazy::new(|| {
+ let mut builtins = std::collections::HashMap::new();
+ builtins.insert("cd", &cd as Builtin);
+ builtins.insert("set", &set);
+ builtins.insert("unset", &unset);
+ builtins.insert("echo", &echo);
+ builtins.insert("read", &read);
+ builtins.insert("and", &and);
+ builtins.insert("or", &or);
+ builtins.insert("command", &command);
+ builtins.insert("builtin", &builtin);
+ builtins
+});
+
+macro_rules! bail {
+ ($cfg:expr, $exe:expr, $msg:expr $(,)?) => {
+ $cfg.io().write_stderr(
+ format!("{}: {}\n", $exe.exe().display(), $msg).as_bytes()
+ )
+ .await
+ .unwrap();
+ return std::process::ExitStatus::from_raw(1 << 8);
+ };
+ ($cfg:expr, $exe:expr, $msg:expr, $($arg:tt)*) => {
+ $cfg.io().write_stderr(
+ format!("{}: ", $exe.exe().display()).as_bytes()
+ )
+ .await
+ .unwrap();
+ $cfg.io().write_stderr(format!($msg, $($arg)*).as_bytes())
+ .await
+ .unwrap();
+ $cfg.io().write_stderr(b"\n").await.unwrap();
+ return std::process::ExitStatus::from_raw(1 << 8);
+ };
+}
+
+// clippy can't tell that the type is necessary
+#[allow(clippy::unnecessary_wraps)]
+fn cd(
+ exe: crate::parse::Exe,
+ env: &Env,
+ cfg: command::Cfg,
+) -> anyhow::Result<command::Child> {
+ async fn async_cd(
+ exe: crate::parse::Exe,
+ env: &Env,
+ cfg: command::Cfg,
+ ) -> std::process::ExitStatus {
+ let dir = if let Some(dir) = exe.args().get(0) {
+ if dir.is_empty() {
+ ".".to_string().into()
+ } else if dir == "-" {
+ env.prev_pwd()
+ } else {
+ dir.into()
+ }
+ } else {
+ let dir = env.var("HOME");
+ if let Some(dir) = dir {
+ dir.into()
+ } else {
+ bail!(cfg, exe, "could not find home directory");
+ }
+ };
+ if let Err(e) = std::env::set_current_dir(&dir) {
+ bail!(
+ cfg,
+ exe,
+ "{}: {}",
+ crate::format::io_error(&e),
+ dir.display()
+ );
+ }
+ async_std::process::ExitStatus::from_raw(0)
+ }
+
+ Ok(command::Child::new_fut(async move {
+ async_cd(exe, env, cfg).await
+ }))
+}
+
+#[allow(clippy::unnecessary_wraps)]
+fn set(
+ exe: crate::parse::Exe,
+ env: &Env,
+ cfg: command::Cfg,
+) -> anyhow::Result<command::Child> {
+ async fn async_set(
+ exe: crate::parse::Exe,
+ _env: &Env,
+ cfg: command::Cfg,
+ ) -> std::process::ExitStatus {
+ let k = if let Some(k) = exe.args().get(0).map(String::as_str) {
+ k
+ } else {
+ bail!(cfg, exe, "usage: set key value");
+ };
+ let v = if let Some(v) = exe.args().get(1).map(String::as_str) {
+ v
+ } else {
+ bail!(cfg, exe, "usage: set key value");
+ };
+
+ std::env::set_var(k, v);
+ async_std::process::ExitStatus::from_raw(0)
+ }
+
+ Ok(command::Child::new_fut(async move {
+ async_set(exe, env, cfg).await
+ }))
+}
+
+#[allow(clippy::unnecessary_wraps)]
+fn unset(
+ exe: crate::parse::Exe,
+ env: &Env,
+ cfg: command::Cfg,
+) -> anyhow::Result<command::Child> {
+ async fn async_unset(
+ exe: crate::parse::Exe,
+ _env: &Env,
+ cfg: command::Cfg,
+ ) -> std::process::ExitStatus {
+ let k = if let Some(k) = exe.args().get(0).map(String::as_str) {
+ k
+ } else {
+ bail!(cfg, exe, "usage: unset key");
+ };
+
+ std::env::remove_var(k);
+ async_std::process::ExitStatus::from_raw(0)
+ }
+
+ Ok(command::Child::new_fut(async move {
+ async_unset(exe, env, cfg).await
+ }))
+}
+
+// clippy can't tell that the type is necessary
+#[allow(clippy::unnecessary_wraps)]
+// mostly just for testing and ensuring that builtins work, i'll likely remove
+// this later, since the binary seems totally fine
+fn echo(
+ exe: crate::parse::Exe,
+ env: &Env,
+ cfg: command::Cfg,
+) -> anyhow::Result<command::Child> {
+ async fn async_echo(
+ exe: crate::parse::Exe,
+ _env: &Env,
+ cfg: command::Cfg,
+ ) -> std::process::ExitStatus {
+ macro_rules! write_stdout {
+ ($bytes:expr) => {
+ if let Err(e) = cfg.io().write_stdout($bytes).await {
+ cfg.io()
+ .write_stderr(format!("echo: {}", e).as_bytes())
+ .await
+ .unwrap();
+ return async_std::process::ExitStatus::from_raw(1 << 8);
+ }
+ };
+ }
+ let count = exe.args().len();
+ for (i, arg) in exe.args().iter().enumerate() {
+ write_stdout!(arg.as_bytes());
+ if i == count - 1 {
+ write_stdout!(b"\n");
+ } else {
+ write_stdout!(b" ");
+ }
+ }
+
+ async_std::process::ExitStatus::from_raw(0)
+ }
+
+ Ok(command::Child::new_fut(async move {
+ async_echo(exe, env, cfg).await
+ }))
+}
+
+#[allow(clippy::unnecessary_wraps)]
+fn read(
+ exe: crate::parse::Exe,
+ env: &Env,
+ cfg: command::Cfg,
+) -> anyhow::Result<command::Child> {
+ async fn async_read(
+ exe: crate::parse::Exe,
+ _env: &Env,
+ cfg: command::Cfg,
+ ) -> std::process::ExitStatus {
+ let var = if let Some(var) = exe.args().get(0).map(String::as_str) {
+ var
+ } else {
+ bail!(cfg, exe, "usage: read var");
+ };
+
+ let (val, done) = match cfg.io().read_line_stdin().await {
+ Ok((line, done)) => (line, done),
+ Err(e) => {
+ bail!(cfg, exe, e);
+ }
+ };
+
+ std::env::set_var(var, val);
+ async_std::process::ExitStatus::from_raw(if done {
+ 1 << 8
+ } else {
+ 0
+ })
+ }
+
+ Ok(command::Child::new_fut(async move {
+ async_read(exe, env, cfg).await
+ }))
+}
+
+fn and(
+ mut exe: crate::parse::Exe,
+ env: &Env,
+ cfg: command::Cfg,
+) -> anyhow::Result<command::Child> {
+ exe.shift();
+ if env.latest_status().success() {
+ let mut cmd = crate::runner::Command::new(exe, cfg.io().clone());
+ cfg.setup_command(&mut cmd);
+ Ok(command::Child::new_wrapped(cmd.spawn(env)?))
+ } else {
+ let status = env.latest_status();
+ Ok(command::Child::new_fut(async move { status }))
+ }
+}
+
+fn or(
+ mut exe: crate::parse::Exe,
+ env: &Env,
+ cfg: command::Cfg,
+) -> anyhow::Result<command::Child> {
+ exe.shift();
+ if env.latest_status().success() {
+ let status = env.latest_status();
+ Ok(command::Child::new_fut(async move { status }))
+ } else {
+ let mut cmd = crate::runner::Command::new(exe, cfg.io().clone());
+ cfg.setup_command(&mut cmd);
+ Ok(command::Child::new_wrapped(cmd.spawn(env)?))
+ }
+}
+
+fn command(
+ mut exe: crate::parse::Exe,
+ env: &Env,
+ cfg: command::Cfg,
+) -> anyhow::Result<command::Child> {
+ exe.shift();
+ let mut cmd = crate::runner::Command::new_binary(exe);
+ cfg.setup_command(&mut cmd);
+ Ok(command::Child::new_wrapped(cmd.spawn(env)?))
+}
+
+fn builtin(
+ mut exe: crate::parse::Exe,
+ env: &Env,
+ cfg: command::Cfg,
+) -> anyhow::Result<command::Child> {
+ exe.shift();
+ let mut cmd = crate::runner::Command::new_builtin(exe, cfg.io().clone());
+ cfg.setup_command(&mut cmd);
+ Ok(command::Child::new_wrapped(cmd.spawn(env)?))
+}
diff --git a/src/runner/command.rs b/src/runner/command.rs
new file mode 100644
index 0000000..5d4c11e
--- /dev/null
+++ b/src/runner/command.rs
@@ -0,0 +1,204 @@
+use crate::runner::prelude::*;
+
+pub struct Command {
+ inner: Inner,
+ exe: std::path::PathBuf,
+ redirects: Vec<crate::parse::Redirect>,
+ pre_exec: Option<
+ Box<dyn FnMut() -> std::io::Result<()> + Send + Sync + 'static>,
+ >,
+}
+impl Command {
+ pub fn new(exe: crate::parse::Exe, io: super::builtins::Io) -> Self {
+ let exe_path = exe.exe().to_path_buf();
+ let redirects = exe.redirects().to_vec();
+ Self {
+ inner: super::builtins::Command::new(exe, io).map_or_else(
+ |exe| Self::new_binary(exe).inner,
+ Inner::Builtin,
+ ),
+ exe: exe_path,
+ redirects,
+ pre_exec: None,
+ }
+ }
+
+ #[allow(clippy::needless_pass_by_value)]
+ pub fn new_binary(exe: crate::parse::Exe) -> Self {
+ let exe_path = exe.exe().to_path_buf();
+ let redirects = exe.redirects().to_vec();
+ let mut cmd = async_std::process::Command::new(exe.exe());
+ cmd.args(exe.args());
+ Self {
+ inner: Inner::Binary(cmd),
+ exe: exe_path,
+ redirects,
+ pre_exec: None,
+ }
+ }
+
+ pub fn new_builtin(
+ exe: crate::parse::Exe,
+ io: super::builtins::Io,
+ ) -> Self {
+ let exe_path = exe.exe().to_path_buf();
+ let redirects = exe.redirects().to_vec();
+ Self {
+ inner: super::builtins::Command::new(exe, io)
+ .map_or_else(|_| todo!(), Inner::Builtin),
+ exe: exe_path,
+ redirects,
+ pre_exec: None,
+ }
+ }
+
+ pub fn stdin(&mut self, fh: std::fs::File) {
+ match &mut self.inner {
+ Inner::Binary(cmd) => {
+ cmd.stdin(fh);
+ }
+ Inner::Builtin(cmd) => {
+ cmd.stdin(fh);
+ }
+ }
+ }
+
+ pub fn stdout(&mut self, fh: std::fs::File) {
+ match &mut self.inner {
+ Inner::Binary(cmd) => {
+ cmd.stdout(fh);
+ }
+ Inner::Builtin(cmd) => {
+ cmd.stdout(fh);
+ }
+ }
+ }
+
+ pub fn stderr(&mut self, fh: std::fs::File) {
+ match &mut self.inner {
+ Inner::Binary(cmd) => {
+ cmd.stderr(fh);
+ }
+ Inner::Builtin(cmd) => {
+ cmd.stderr(fh);
+ }
+ }
+ }
+
+ // Safety: see pre_exec in async_std::os::unix::process::CommandExt (this
+ // is just a wrapper)
+ pub unsafe fn pre_exec<F>(&mut self, f: F)
+ where
+ F: 'static + FnMut() -> std::io::Result<()> + Send + Sync,
+ {
+ self.pre_exec = Some(Box::new(f));
+ }
+
+ pub fn spawn(self, env: &Env) -> anyhow::Result<Child> {
+ let Self {
+ inner,
+ exe,
+ redirects,
+ pre_exec,
+ } = self;
+
+ #[allow(clippy::as_conversions)]
+ let pre_exec = pre_exec.map_or_else(
+ || {
+ let redirects = redirects.clone();
+ Box::new(move || {
+ apply_redirects(&redirects)?;
+ Ok(())
+ })
+ as Box<dyn FnMut() -> std::io::Result<()> + Send + Sync>
+ },
+ |mut pre_exec| {
+ let redirects = redirects.clone();
+ Box::new(move || {
+ apply_redirects(&redirects)?;
+ pre_exec()?;
+ Ok(())
+ })
+ },
+ );
+ match inner {
+ Inner::Binary(mut cmd) => {
+ // Safety: open, dup2, and close are async-signal-safe
+ // functions
+ unsafe { cmd.pre_exec(pre_exec) };
+ Ok(Child::Binary(cmd.spawn().map_err(|e| {
+ anyhow::anyhow!(
+ "{}: {}",
+ crate::format::io_error(&e),
+ exe.display()
+ )
+ })?))
+ }
+ Inner::Builtin(mut cmd) => {
+ // Safety: open, dup2, and close are async-signal-safe
+ // functions
+ unsafe { cmd.pre_exec(pre_exec) };
+ cmd.apply_redirects(&redirects);
+ Ok(Child::Builtin(cmd.spawn(env)?))
+ }
+ }
+ }
+}
+
+pub enum Inner {
+ Binary(async_std::process::Command),
+ Builtin(super::builtins::Command),
+}
+
+pub enum Child<'a> {
+ Binary(async_std::process::Child),
+ Builtin(super::builtins::Child<'a>),
+}
+
+impl<'a> Child<'a> {
+ pub fn id(&self) -> Option<u32> {
+ match self {
+ Self::Binary(child) => Some(child.id()),
+ Self::Builtin(child) => child.id(),
+ }
+ }
+
+ pub fn status(
+ self,
+ ) -> std::pin::Pin<
+ Box<
+ dyn std::future::Future<
+ Output = anyhow::Result<std::process::ExitStatus>,
+ > + Send
+ + Sync
+ + 'a,
+ >,
+ > {
+ Box::pin(async move {
+ match self {
+ Self::Binary(child) => Ok(child.status_no_drop().await?),
+ Self::Builtin(child) => Ok(child.status().await?),
+ }
+ })
+ }
+}
+
+fn apply_redirects(
+ redirects: &[crate::parse::Redirect],
+) -> std::io::Result<()> {
+ for redirect in redirects {
+ match &redirect.to {
+ crate::parse::RedirectTarget::Fd(fd) => {
+ nix::unistd::dup2(*fd, redirect.from)?;
+ }
+ crate::parse::RedirectTarget::File(path) => {
+ let fd = redirect.dir.open(path)?;
+ if fd != redirect.from {
+ nix::unistd::dup2(fd, redirect.from)?;
+ nix::unistd::close(fd)?;
+ }
+ }
+ }
+ }
+ Ok(())
+}
diff --git a/src/runner/mod.rs b/src/runner/mod.rs
new file mode 100644
index 0000000..84be9a1
--- /dev/null
+++ b/src/runner/mod.rs
@@ -0,0 +1,531 @@
+use crate::runner::prelude::*;
+
+mod builtins;
+mod command;
+pub use command::{Child, Command};
+mod prelude;
+
+const PID0: nix::unistd::Pid = nix::unistd::Pid::from_raw(0);
+
+#[derive(Debug, serde::Serialize, serde::Deserialize)]
+pub enum Event {
+ RunPipeline(usize, (usize, usize)),
+ Suspend(usize),
+ Exit(Env),
+}
+
+struct Stack {
+ frames: Vec<Frame>,
+}
+
+impl Stack {
+ fn new() -> Self {
+ Self { frames: vec![] }
+ }
+
+ fn push(&mut self, frame: Frame) {
+ self.frames.push(frame);
+ }
+
+ fn pop(&mut self) -> Frame {
+ self.frames.pop().unwrap()
+ }
+
+ fn top(&self) -> Option<&Frame> {
+ self.frames.last()
+ }
+
+ fn top_mut(&mut self) -> Option<&mut Frame> {
+ self.frames.last_mut()
+ }
+
+ fn current_pc(&self, pc: usize) -> bool {
+ match self.top() {
+ Some(Frame::If(_)) | None => false,
+ Some(Frame::While(_, start) | Frame::For(_, start, _)) => {
+ *start == pc
+ }
+ }
+ }
+
+ fn should_execute(&self) -> bool {
+ for frame in &self.frames {
+ if matches!(
+ frame,
+ Frame::If(false)
+ | Frame::While(false, ..)
+ | Frame::For(false, ..)
+ ) {
+ return false;
+ }
+ }
+ true
+ }
+}
+
+enum Frame {
+ If(bool),
+ While(bool, usize),
+ For(bool, usize, Vec<String>),
+}
+
+pub async fn run(
+ commands: &str,
+ shell_write: Option<&async_std::fs::File>,
+) -> anyhow::Result<i32> {
+ let mut env = Env::new_from_env()?;
+ run_commands(commands, &mut env, shell_write).await?;
+ let status = env.latest_status();
+ write_event(shell_write, Event::Exit(env)).await?;
+
+ if let Some(signal) = status.signal() {
+ nix::sys::signal::raise(signal.try_into().unwrap())?;
+ }
+ Ok(status.code().unwrap())
+}
+
+async fn run_commands(
+ commands: &str,
+ env: &mut Env,
+ shell_write: Option<&async_std::fs::File>,
+) -> anyhow::Result<()> {
+ let commands = crate::parse::ast::Commands::parse(commands)?;
+ let commands = commands.commands();
+ let mut pc = 0;
+ let mut stack = Stack::new();
+ while pc < commands.len() {
+ match &commands[pc] {
+ crate::parse::ast::Command::Pipeline(pipeline) => {
+ if stack.should_execute() {
+ run_pipeline(pipeline.clone(), env, shell_write).await?;
+ }
+ pc += 1;
+ }
+ crate::parse::ast::Command::If(pipeline) => {
+ let should = stack.should_execute();
+ if !stack.current_pc(pc) {
+ stack.push(Frame::If(false));
+ }
+ if should {
+ let status = env.latest_status();
+ run_pipeline(pipeline.clone(), env, shell_write).await?;
+ if let Some(Frame::If(should)) = stack.top_mut() {
+ *should = env.latest_status().success();
+ } else {
+ unreachable!();
+ }
+ env.set_status(status);
+ }
+ pc += 1;
+ }
+ crate::parse::ast::Command::While(pipeline) => {
+ let should = stack.should_execute();
+ if !stack.current_pc(pc) {
+ stack.push(Frame::While(false, pc));
+ }
+ if should {
+ let status = env.latest_status();
+ run_pipeline(pipeline.clone(), env, shell_write).await?;
+ if let Some(Frame::While(should, _)) = stack.top_mut() {
+ *should = env.latest_status().success();
+ } else {
+ unreachable!();
+ }
+ env.set_status(status);
+ }
+ pc += 1;
+ }
+ crate::parse::ast::Command::For(var, list) => {
+ let should = stack.should_execute();
+ if !stack.current_pc(pc) {
+ stack.push(Frame::For(
+ false,
+ pc,
+ if stack.should_execute() {
+ list.clone()
+ .into_iter()
+ .map(|w| {
+ w.eval(env).map(IntoIterator::into_iter)
+ })
+ .collect::<Result<Vec<_>, _>>()?
+ .into_iter()
+ .flatten()
+ .collect()
+ } else {
+ vec![]
+ },
+ ));
+ }
+ if should {
+ if let Some(Frame::For(should, _, list)) = stack.top_mut()
+ {
+ *should = !list.is_empty();
+ if *should {
+ let val = list.remove(0);
+ // XXX i really need to just pick one location and
+ // stick with it instead of trying to keep these
+ // in sync
+ env.set_var(var, &val);
+ std::env::set_var(var, &val);
+ }
+ } else {
+ unreachable!();
+ }
+ }
+ pc += 1;
+ }
+ crate::parse::ast::Command::End => match stack.top() {
+ Some(Frame::If(_)) => {
+ stack.pop();
+ pc += 1;
+ }
+ Some(
+ Frame::While(should, start)
+ | Frame::For(should, start, _),
+ ) => {
+ if *should {
+ pc = *start;
+ } else {
+ stack.pop();
+ pc += 1;
+ }
+ }
+ None => todo!(),
+ },
+ }
+ }
+ Ok(())
+}
+
+async fn run_pipeline(
+ pipeline: crate::parse::ast::Pipeline,
+ env: &mut Env,
+ shell_write: Option<&async_std::fs::File>,
+) -> anyhow::Result<()> {
+ write_event(shell_write, Event::RunPipeline(env.idx(), pipeline.span()))
+ .await?;
+ // Safety: pipelines are run serially, so only one copy of these will ever
+ // exist at once. note that reusing a single copy of these at the top
+ // level would not be safe, because in the case of a command line like
+ // "echo foo; ls", we would pass the stdout fd to the ls process while it
+ // is still open here, and may still have data buffered.
+ let stdin = unsafe { async_std::fs::File::from_raw_fd(0) };
+ let stdout = unsafe { async_std::fs::File::from_raw_fd(1) };
+ let stderr = unsafe { async_std::fs::File::from_raw_fd(2) };
+ let mut io = builtins::Io::new();
+ io.set_stdin(stdin);
+ io.set_stdout(stdout);
+ io.set_stderr(stderr);
+
+ let pwd = env.pwd().to_path_buf();
+ let (children, pg) = spawn_children(pipeline, env, &io)?;
+ let status = wait_children(children, pg, env, &io, shell_write).await;
+ set_foreground_pg(nix::unistd::getpid())?;
+ env.update()?;
+ env.set_status(status);
+ if env.pwd() != pwd {
+ env.set_prev_pwd(pwd);
+ }
+ Ok(())
+}
+
+async fn write_event(
+ fh: Option<&async_std::fs::File>,
+ event: Event,
+) -> anyhow::Result<()> {
+ if let Some(mut fh) = fh {
+ fh.write_all(&bincode::serialize(&event)?).await?;
+ fh.flush().await?;
+ }
+ Ok(())
+}
+
+fn spawn_children<'a>(
+ pipeline: crate::parse::ast::Pipeline,
+ env: &'a Env,
+ io: &builtins::Io,
+) -> anyhow::Result<(Vec<Child<'a>>, Option<nix::unistd::Pid>)> {
+ let pipeline = pipeline.eval(env)?;
+ let mut cmds: Vec<_> = pipeline
+ .into_exes()
+ .map(|exe| Command::new(exe, io.clone()))
+ .collect();
+ for i in 0..(cmds.len() - 1) {
+ let (r, w) = pipe()?;
+ cmds[i].stdout(w);
+ cmds[i + 1].stdin(r);
+ }
+
+ let mut children = vec![];
+ let mut pg_pid = None;
+ for mut cmd in cmds {
+ // Safety: setpgid is an async-signal-safe function
+ unsafe {
+ cmd.pre_exec(move || {
+ setpgid_child(pg_pid)?;
+ Ok(())
+ });
+ }
+ let child = cmd.spawn(env)?;
+ if let Some(id) = child.id() {
+ let child_pid = id_to_pid(id);
+ setpgid_parent(child_pid, pg_pid)?;
+ if pg_pid.is_none() {
+ pg_pid = Some(child_pid);
+ set_foreground_pg(child_pid)?;
+ }
+ }
+ children.push(child);
+ }
+ Ok((children, pg_pid))
+}
+
+async fn wait_children(
+ children: Vec<Child<'_>>,
+ pg: Option<nix::unistd::Pid>,
+ env: &Env,
+ io: &builtins::Io,
+ shell_write: Option<&async_std::fs::File>,
+) -> std::process::ExitStatus {
+ enum Res {
+ Child(nix::Result<nix::sys::wait::WaitStatus>),
+ Builtin(Option<(anyhow::Result<std::process::ExitStatus>, bool)>),
+ }
+
+ macro_rules! bail {
+ ($e:expr) => {
+ // if writing to stderr is not possible, we still want to exit
+ // normally with a failure exit code
+ #[allow(clippy::let_underscore_drop)]
+ let _ =
+ io.write_stderr(format!("nbsh: {}\n", $e).as_bytes()).await;
+ return std::process::ExitStatus::from_raw(1 << 8);
+ };
+ }
+
+ let mut final_status = None;
+
+ let count = children.len();
+ let (children, builtins): (Vec<_>, Vec<_>) = children
+ .into_iter()
+ .enumerate()
+ .partition(|(_, child)| child.id().is_some());
+ let mut children: std::collections::HashMap<_, _> = children
+ .into_iter()
+ .map(|(i, child)| {
+ (id_to_pid(child.id().unwrap()), (child, i == count - 1))
+ })
+ .collect();
+ let mut builtins: futures_util::stream::FuturesUnordered<_> =
+ builtins
+ .into_iter()
+ .map(|(i, child)| async move {
+ (child.status().await, i == count - 1)
+ })
+ .collect();
+
+ let (wait_w, wait_r) = async_std::channel::unbounded();
+ let new_wait = move || {
+ if let Some(pg) = pg {
+ let wait_w = wait_w.clone();
+ async_std::task::spawn(async move {
+ let res = blocking::unblock(move || {
+ nix::sys::wait::waitpid(
+ neg_pid(pg),
+ Some(nix::sys::wait::WaitPidFlag::WUNTRACED),
+ )
+ })
+ .await;
+ if wait_w.is_closed() {
+ // we shouldn't be able to drop real process terminations
+ assert!(res.is_err());
+ } else {
+ wait_w.send(res).await.unwrap();
+ }
+ });
+ }
+ };
+
+ new_wait();
+ loop {
+ if children.is_empty() && builtins.is_empty() {
+ break;
+ }
+
+ let child = async { Res::Child(wait_r.recv().await.unwrap()) };
+ let builtin = async {
+ Res::Builtin(if builtins.is_empty() {
+ std::future::pending().await
+ } else {
+ builtins.next().await
+ })
+ };
+ match child.race(builtin).await {
+ Res::Child(Ok(status)) => {
+ match status {
+ // we can't call child.status() here to unify these branches
+ // because our waitpid call already collected the status
+ nix::sys::wait::WaitStatus::Exited(pid, code) => {
+ let (_, last) = children.remove(&pid).unwrap();
+ if last {
+ final_status = Some(
+ std::process::ExitStatus::from_raw(code << 8),
+ );
+ }
+ }
+ nix::sys::wait::WaitStatus::Signaled(pid, signal, _) => {
+ let (_, last) = children.remove(&pid).unwrap();
+ if signal == nix::sys::signal::Signal::SIGINT {
+ if let Err(e) = nix::sys::signal::raise(
+ nix::sys::signal::Signal::SIGINT,
+ ) {
+ bail!(e);
+ }
+ }
+ // this conversion is safe because the Signal enum is
+ // repr(i32)
+ #[allow(clippy::as_conversions)]
+ if last {
+ final_status =
+ Some(std::process::ExitStatus::from_raw(
+ signal as i32,
+ ));
+ }
+ }
+ nix::sys::wait::WaitStatus::Stopped(pid, signal) => {
+ if signal == nix::sys::signal::Signal::SIGTSTP {
+ if let Err(e) = write_event(
+ shell_write,
+ Event::Suspend(env.idx()),
+ )
+ .await
+ {
+ bail!(e);
+ }
+ if let Err(e) = nix::sys::signal::kill(
+ pid,
+ nix::sys::signal::Signal::SIGCONT,
+ ) {
+ bail!(e);
+ }
+ }
+ }
+ _ => {}
+ }
+ new_wait();
+ }
+ Res::Child(Err(e)) => {
+ bail!(e);
+ }
+ Res::Builtin(Some((Ok(status), last))) => {
+ // this conversion is safe because the Signal enum is
+ // repr(i32)
+ #[allow(clippy::as_conversions)]
+ if status.signal()
+ == Some(nix::sys::signal::Signal::SIGINT as i32)
+ {
+ if let Err(e) = nix::sys::signal::raise(
+ nix::sys::signal::Signal::SIGINT,
+ ) {
+ bail!(e);
+ }
+ }
+ if last {
+ final_status = Some(status);
+ }
+ }
+ Res::Builtin(Some((Err(e), _))) => {
+ bail!(e);
+ }
+ Res::Builtin(None) => {}
+ }
+ }
+
+ final_status.unwrap()
+}
+
+fn pipe() -> anyhow::Result<(std::fs::File, std::fs::File)> {
+ let (r, w) = nix::unistd::pipe2(nix::fcntl::OFlag::O_CLOEXEC)?;
+ // Safety: these file descriptors were just returned by pipe2 above, and
+ // are only available in this function, so nothing else can be accessing
+ // them
+ Ok((unsafe { std::fs::File::from_raw_fd(r) }, unsafe {
+ std::fs::File::from_raw_fd(w)
+ }))
+}
+
+fn set_foreground_pg(pg: nix::unistd::Pid) -> anyhow::Result<()> {
+ let pty = nix::fcntl::open(
+ "/dev/tty",
+ nix::fcntl::OFlag::empty(),
+ nix::sys::stat::Mode::empty(),
+ )?;
+
+ // if a background process calls tcsetpgrp, the kernel will send it
+ // SIGTTOU which suspends it. if that background process is the session
+ // leader and doesn't have SIGTTOU blocked, the kernel will instead just
+ // return ENOTTY from the tcsetpgrp call rather than sending a signal to
+ // avoid deadlocking the process. therefore, we need to ensure that
+ // SIGTTOU is blocked here.
+
+ // Safety: setting a signal handler to SigIgn is always safe
+ unsafe {
+ nix::sys::signal::signal(
+ nix::sys::signal::Signal::SIGTTOU,
+ nix::sys::signal::SigHandler::SigIgn,
+ )?;
+ }
+ let res = nix::unistd::tcsetpgrp(pty, pg);
+ // Safety: setting a signal handler to SigDfl is always safe
+ unsafe {
+ nix::sys::signal::signal(
+ nix::sys::signal::Signal::SIGTTOU,
+ nix::sys::signal::SigHandler::SigDfl,
+ )?;
+ }
+ res?;
+
+ nix::unistd::close(pty)?;
+
+ nix::sys::signal::kill(neg_pid(pg), nix::sys::signal::Signal::SIGCONT)
+ .or_else(|e| {
+ // the process group has already exited
+ if e == nix::errno::Errno::ESRCH {
+ Ok(())
+ } else {
+ Err(e)
+ }
+ })?;
+
+ Ok(())
+}
+
+fn setpgid_child(pg: Option<nix::unistd::Pid>) -> std::io::Result<()> {
+ nix::unistd::setpgid(PID0, pg.unwrap_or(PID0))?;
+ Ok(())
+}
+
+fn setpgid_parent(
+ pid: nix::unistd::Pid,
+ pg: Option<nix::unistd::Pid>,
+) -> anyhow::Result<()> {
+ nix::unistd::setpgid(pid, pg.unwrap_or(PID0)).or_else(|e| {
+ // EACCES means that the child already called exec, but if it did,
+ // then it also must have already called setpgid itself, so we don't
+ // care. ESRCH means that the process already exited, which is similar
+ if e == nix::errno::Errno::EACCES || e == nix::errno::Errno::ESRCH {
+ Ok(())
+ } else {
+ Err(e)
+ }
+ })?;
+ Ok(())
+}
+
+fn id_to_pid(id: u32) -> nix::unistd::Pid {
+ nix::unistd::Pid::from_raw(id.try_into().unwrap())
+}
+
+fn neg_pid(pid: nix::unistd::Pid) -> nix::unistd::Pid {
+ nix::unistd::Pid::from_raw(-pid.as_raw())
+}
diff --git a/src/runner/prelude.rs b/src/runner/prelude.rs
new file mode 100644
index 0000000..53b67fc
--- /dev/null
+++ b/src/runner/prelude.rs
@@ -0,0 +1 @@
+pub use crate::prelude::*;
diff --git a/src/shell.pest b/src/shell.pest
new file mode 100644
index 0000000..fdfb1b1
--- /dev/null
+++ b/src/shell.pest
@@ -0,0 +1,65 @@
+basic_escape_char = @{ "\\\\" | "\\'" }
+escape_char = @{ "\\" ~ ANY }
+
+bareword_char = @{
+ escape_char |
+ !("|" | ";" | "\"" | "'" | "$" | "{" | "(" | ")" | WHITESPACE | COMMENT)
+ ~ ANY
+}
+single_string_char = @{ basic_escape_char | (!"'" ~ ANY) }
+double_string_char = @{ escape_char | (!("\"" | "$") ~ ANY) }
+
+redir_prefix = @{
+ ("in" | "out" | "err" | ASCII_DIGIT*) ~ (">>" | ">" | "<") ~ WHITESPACE*
+}
+
+var = @{
+ ("$" ~ XID_START ~ XID_CONTINUE*) |
+ ("$" ~ ("?" | "$" | "*" | ASCII_DIGIT)) |
+ ("${" ~ (!"}" ~ ANY)+ ~ "}")
+}
+bareword = @{ bareword_char+ }
+single_string = @{ single_string_char+ }
+double_string = @{ double_string_char+ }
+
+alternation_bareword_char = @{ !("," | "}") ~ bareword_char }
+alternation_bareword = @{ alternation_bareword_char+ }
+alternation_word_part = ${
+ var |
+ alternation_bareword |
+ "'" ~ single_string ~ "'" |
+ "\"" ~ (var | double_string)+ ~ "\""
+}
+alternation_word = ${ alternation_word_part* }
+alternation = ${ "{" ~ alternation_word ~ ("," ~ alternation_word)* ~ "}" }
+
+word_part = ${
+ alternation |
+ var |
+ bareword |
+ "'" ~ single_string ~ "'" |
+ "\"" ~ (var | double_string)+ ~ "\""
+}
+word = ${ word_part+ }
+
+word_or_redirect = ${ redir_prefix? ~ word }
+
+exe = ${ word_or_redirect ~ (w ~ word_or_redirect)* }
+subshell = ${ "(" ~ w? ~ commands ~ w? ~ ")" }
+list = ${ word ~ (w ~ word)* }
+pipeline = ${ (subshell | exe) ~ (w? ~ "|" ~ w? ~ (subshell | exe))* }
+
+control_if = ${ "if" ~ w ~ pipeline }
+control_while = ${ "while" ~ w ~ pipeline }
+control_for = ${ "for" ~ w ~ bareword ~ w ~ "in" ~ w ~ list }
+control_end = ${ "end" }
+control = ${ control_if | control_while | control_for | control_end }
+
+command = ${ control | pipeline }
+commands = ${ command ~ (w? ~ ";" ~ w? ~ command)* }
+
+line = ${ SOI ~ w? ~ commands ~ w? ~ EOI }
+
+w = _{ (WHITESPACE | COMMENT)+ }
+WHITESPACE = _{ (" " | "\t" | "\n") }
+COMMENT = _{ "#" ~ ANY* }
diff --git a/src/shell/event.rs b/src/shell/event.rs
new file mode 100644
index 0000000..025f3c4
--- /dev/null
+++ b/src/shell/event.rs
@@ -0,0 +1,140 @@
+#[derive(Debug)]
+pub enum Event {
+ Key(textmode::Key),
+ Resize((u16, u16)),
+ PtyOutput,
+ PtyClose,
+ ChildRunPipeline(usize, (usize, usize)),
+ ChildSuspend(usize),
+ GitInfo(Option<super::git::Info>),
+ ClockTimer,
+}
+
+pub struct Reader {
+ pending: async_std::sync::Mutex<Pending>,
+ cvar: async_std::sync::Condvar,
+}
+
+impl Reader {
+ pub fn new(
+ input: async_std::channel::Receiver<Event>,
+ ) -> async_std::sync::Arc<Self> {
+ let this = async_std::sync::Arc::new(Self {
+ pending: async_std::sync::Mutex::new(Pending::new()),
+ cvar: async_std::sync::Condvar::new(),
+ });
+ {
+ let this = async_std::sync::Arc::clone(&this);
+ async_std::task::spawn(async move {
+ while let Ok(event) = input.recv().await {
+ this.new_event(Some(event)).await;
+ }
+ this.new_event(None).await;
+ });
+ }
+ this
+ }
+
+ pub async fn recv(&self) -> Option<Event> {
+ let mut pending = self
+ .cvar
+ .wait_until(self.pending.lock().await, |pending| {
+ pending.has_event()
+ })
+ .await;
+ pending.get_event()
+ }
+
+ async fn new_event(&self, event: Option<Event>) {
+ let mut pending = self.pending.lock().await;
+ pending.new_event(event);
+ self.cvar.notify_one();
+ }
+}
+
+#[allow(clippy::option_option)]
+#[derive(Default)]
+struct Pending {
+ key: std::collections::VecDeque<textmode::Key>,
+ size: Option<(u16, u16)>,
+ pty_output: bool,
+ pty_close: bool,
+ child_run_pipeline: std::collections::VecDeque<(usize, (usize, usize))>,
+ child_suspend: std::collections::VecDeque<usize>,
+ git_info: Option<Option<super::git::Info>>,
+ clock_timer: bool,
+ done: bool,
+}
+
+impl Pending {
+ fn new() -> Self {
+ Self::default()
+ }
+
+ fn has_event(&self) -> bool {
+ self.done
+ || !self.key.is_empty()
+ || self.size.is_some()
+ || self.pty_output
+ || self.pty_close
+ || !self.child_run_pipeline.is_empty()
+ || !self.child_suspend.is_empty()
+ || self.git_info.is_some()
+ || self.clock_timer
+ }
+
+ fn get_event(&mut self) -> Option<Event> {
+ if self.done {
+ return None;
+ }
+ if let Some(key) = self.key.pop_front() {
+ return Some(Event::Key(key));
+ }
+ if let Some(size) = self.size.take() {
+ return Some(Event::Resize(size));
+ }
+ if self.pty_close {
+ self.pty_close = false;
+ return Some(Event::PtyClose);
+ }
+ if let Some((idx, span)) = self.child_run_pipeline.pop_front() {
+ return Some(Event::ChildRunPipeline(idx, span));
+ }
+ if let Some(idx) = self.child_suspend.pop_front() {
+ return Some(Event::ChildSuspend(idx));
+ }
+ if let Some(info) = self.git_info.take() {
+ return Some(Event::GitInfo(info));
+ }
+ if self.clock_timer {
+ self.clock_timer = false;
+ return Some(Event::ClockTimer);
+ }
+ // process_output should be last because it will often be the case
+ // that there is ~always new process output (cat on large files, yes,
+ // etc) and that shouldn't prevent other events from happening
+ if self.pty_output {
+ self.pty_output = false;
+ return Some(Event::PtyOutput);
+ }
+ unreachable!()
+ }
+
+ fn new_event(&mut self, event: Option<Event>) {
+ match event {
+ Some(Event::Key(key)) => self.key.push_back(key),
+ Some(Event::Resize(size)) => self.size = Some(size),
+ Some(Event::PtyOutput) => self.pty_output = true,
+ Some(Event::PtyClose) => self.pty_close = true,
+ Some(Event::ChildRunPipeline(idx, span)) => {
+ self.child_run_pipeline.push_back((idx, span));
+ }
+ Some(Event::ChildSuspend(idx)) => {
+ self.child_suspend.push_back(idx);
+ }
+ Some(Event::GitInfo(info)) => self.git_info = Some(info),
+ Some(Event::ClockTimer) => self.clock_timer = true,
+ None => self.done = true,
+ }
+ }
+}
diff --git a/src/shell/git.rs b/src/shell/git.rs
new file mode 100644
index 0000000..48e5eea
--- /dev/null
+++ b/src/shell/git.rs
@@ -0,0 +1,201 @@
+#[derive(Debug)]
+pub struct Info {
+ modified_files: bool,
+ staged_files: bool,
+ new_files: bool,
+ commits: bool,
+ active_operation: ActiveOperation,
+ branch: Option<String>,
+ remote_branch_diff: Option<(usize, usize)>,
+}
+
+const MODIFIED: git2::Status = git2::Status::WT_DELETED
+ .union(git2::Status::WT_MODIFIED)
+ .union(git2::Status::WT_RENAMED)
+ .union(git2::Status::WT_TYPECHANGE)
+ .union(git2::Status::CONFLICTED);
+const STAGED: git2::Status = git2::Status::INDEX_DELETED
+ .union(git2::Status::INDEX_MODIFIED)
+ .union(git2::Status::INDEX_NEW)
+ .union(git2::Status::INDEX_RENAMED)
+ .union(git2::Status::INDEX_TYPECHANGE);
+const NEW: git2::Status = git2::Status::WT_NEW;
+
+impl Info {
+ pub fn new(git: &git2::Repository) -> Self {
+ let mut status_options = git2::StatusOptions::new();
+ status_options.include_untracked(true);
+ status_options.update_index(true);
+
+ let statuses = git.statuses(Some(&mut status_options));
+
+ let mut modified_files = false;
+ let mut staged_files = false;
+ let mut new_files = false;
+ if let Ok(statuses) = statuses {
+ for file in statuses.iter() {
+ if file.status().intersects(MODIFIED) {
+ modified_files = true;
+ }
+ if file.status().intersects(STAGED) {
+ staged_files = true;
+ }
+ if file.status().intersects(NEW) {
+ new_files = true;
+ }
+ }
+ }
+
+ let head = git.head();
+ let mut commits = false;
+ let mut branch = None;
+ let mut remote_branch_diff = None;
+
+ if let Ok(head) = head {
+ commits = true;
+ if head.is_branch() {
+ branch = head.shorthand().map(ToString::to_string);
+ remote_branch_diff =
+ head.resolve()
+ .ok()
+ .map(|head| {
+ (
+ head.target(),
+ head.shorthand().map(ToString::to_string),
+ )
+ })
+ .and_then(|(head_id, name)| {
+ head_id.and_then(|head_id| {
+ name.and_then(|name| {
+ git.refname_to_id(&format!(
+ "refs/remotes/origin/{}",
+ name
+ ))
+ .ok()
+ .and_then(|remote_id| {
+ git.graph_ahead_behind(
+ head_id, remote_id,
+ )
+ .ok()
+ })
+ })
+ })
+ });
+ } else {
+ branch =
+ head.resolve().ok().and_then(|head| head.target()).map(
+ |oid| {
+ let mut sha: String = oid
+ .as_bytes()
+ .iter()
+ .take(4)
+ .map(|b| format!("{:02x}", b))
+ .collect();
+ sha.truncate(7);
+ sha
+ },
+ );
+ }
+ }
+
+ let active_operation = match git.state() {
+ git2::RepositoryState::Merge => ActiveOperation::Merge,
+ git2::RepositoryState::Revert
+ | git2::RepositoryState::RevertSequence => {
+ ActiveOperation::Revert
+ }
+ git2::RepositoryState::CherryPick
+ | git2::RepositoryState::CherryPickSequence => {
+ ActiveOperation::CherryPick
+ }
+ git2::RepositoryState::Bisect => ActiveOperation::Bisect,
+ git2::RepositoryState::Rebase
+ | git2::RepositoryState::RebaseInteractive
+ | git2::RepositoryState::RebaseMerge => ActiveOperation::Rebase,
+ _ => ActiveOperation::None,
+ };
+
+ Self {
+ modified_files,
+ staged_files,
+ new_files,
+ commits,
+ active_operation,
+ branch,
+ remote_branch_diff,
+ }
+ }
+}
+
+impl std::fmt::Display for Info {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "g")?;
+
+ if self.modified_files {
+ write!(f, "*")?;
+ }
+ if self.staged_files {
+ write!(f, "+")?;
+ }
+ if self.new_files {
+ write!(f, "?")?;
+ }
+ if !self.commits {
+ write!(f, "!")?;
+ return Ok(());
+ }
+
+ let branch = self.branch.as_ref().map_or("???", |branch| {
+ if branch == "master" {
+ ""
+ } else {
+ branch
+ }
+ });
+ if !branch.is_empty() {
+ write!(f, ":")?;
+ }
+ write!(f, "{}", branch)?;
+
+ if let Some((local, remote)) = self.remote_branch_diff {
+ if local > 0 || remote > 0 {
+ write!(f, ":")?;
+ }
+ if local > 0 {
+ write!(f, "+{}", local)?;
+ }
+ if remote > 0 {
+ write!(f, "-{}", remote)?;
+ }
+ } else {
+ write!(f, ":-")?;
+ }
+
+ write!(f, "{}", self.active_operation)?;
+
+ Ok(())
+ }
+}
+
+#[derive(Debug, Copy, Clone)]
+pub enum ActiveOperation {
+ None,
+ Merge,
+ Revert,
+ CherryPick,
+ Bisect,
+ Rebase,
+}
+
+impl std::fmt::Display for ActiveOperation {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ ActiveOperation::None => Ok(()),
+ ActiveOperation::Merge => write!(f, "(m)"),
+ ActiveOperation::Revert => write!(f, "(v)"),
+ ActiveOperation::CherryPick => write!(f, "(c)"),
+ ActiveOperation::Bisect => write!(f, "(b)"),
+ ActiveOperation::Rebase => write!(f, "(r)"),
+ }
+ }
+}
diff --git a/src/shell/history/entry.rs b/src/shell/history/entry.rs
new file mode 100644
index 0000000..a45d99d
--- /dev/null
+++ b/src/shell/history/entry.rs
@@ -0,0 +1,393 @@
+use crate::shell::prelude::*;
+
+enum State {
+ Running((usize, usize)),
+ Exited(ExitInfo),
+}
+
+pub struct Entry {
+ cmdline: String,
+ env: Env,
+ state: State,
+ vt: vt100::Parser,
+ audible_bell_state: usize,
+ visual_bell_state: usize,
+ audible_bell: bool,
+ visual_bell: bool,
+ real_bell_pending: bool,
+ fullscreen: Option<bool>,
+ input: async_std::channel::Sender<Vec<u8>>,
+ resize: async_std::channel::Sender<(u16, u16)>,
+ start_time: time::OffsetDateTime,
+ start_instant: std::time::Instant,
+}
+
+impl Entry {
+ pub fn new(
+ cmdline: String,
+ env: Env,
+ size: (u16, u16),
+ input: async_std::channel::Sender<Vec<u8>>,
+ resize: async_std::channel::Sender<(u16, u16)>,
+ ) -> Self {
+ let span = (0, cmdline.len());
+ Self {
+ cmdline,
+ env,
+ state: State::Running(span),
+ vt: vt100::Parser::new(size.0, size.1, 0),
+ audible_bell_state: 0,
+ visual_bell_state: 0,
+ audible_bell: false,
+ visual_bell: false,
+ real_bell_pending: false,
+ input,
+ resize,
+ fullscreen: None,
+ start_time: time::OffsetDateTime::now_utc(),
+ start_instant: std::time::Instant::now(),
+ }
+ }
+
+ pub fn render(
+ &mut self,
+ out: &mut impl textmode::Textmode,
+ idx: usize,
+ entry_count: usize,
+ size: (u16, u16),
+ focused: bool,
+ scrolling: bool,
+ offset: time::UtcOffset,
+ ) {
+ let time = self.exit_info().map_or_else(
+ || {
+ format!(
+ "[{}]",
+ crate::format::time(self.start_time.to_offset(offset))
+ )
+ },
+ |info| {
+ format!(
+ "({}) [{}]",
+ crate::format::duration(
+ info.instant - self.start_instant
+ ),
+ crate::format::time(self.start_time.to_offset(offset)),
+ )
+ },
+ );
+
+ self.bell(out);
+ if focused {
+ self.audible_bell = false;
+ self.visual_bell = false;
+ }
+
+ set_bgcolor(out, idx, focused);
+ out.set_fgcolor(textmode::color::YELLOW);
+ let entry_count_width = format!("{}", entry_count + 1).len();
+ let idx_str = format!("{}", idx + 1);
+ out.write_str(&" ".repeat(entry_count_width - idx_str.len()));
+ out.write_str(&idx_str);
+ out.write_str(" ");
+ out.reset_attributes();
+
+ set_bgcolor(out, idx, focused);
+ if let Some(info) = self.exit_info() {
+ if info.status.signal().is_some() {
+ out.set_fgcolor(textmode::color::MAGENTA);
+ } else if info.status.success() {
+ out.set_fgcolor(textmode::color::DARKGREY);
+ } else {
+ out.set_fgcolor(textmode::color::RED);
+ }
+ out.write_str(&crate::format::exit_status(info.status));
+ } else {
+ out.write_str(" ");
+ }
+ out.reset_attributes();
+
+ if self.audible_bell || self.visual_bell {
+ out.set_bgcolor(textmode::Color::Rgb(64, 16, 16));
+ } else {
+ set_bgcolor(out, idx, focused);
+ }
+ out.write_str("$ ");
+ set_bgcolor(out, idx, focused);
+ let start = usize::from(out.screen().cursor_position().1);
+ let end = usize::from(size.1) - time.len() - 2;
+ let max_len = end - start;
+ let cmd = if self.cmd().len() > max_len {
+ &self.cmd()[..(max_len - 4)]
+ } else {
+ self.cmd()
+ };
+ if let State::Running(span) = self.state {
+ let span = (span.0.min(cmd.len()), span.1.min(cmd.len()));
+ if !cmd[..span.0].is_empty() {
+ out.write_str(&cmd[..span.0]);
+ }
+ if !cmd[span.0..span.1].is_empty() {
+ out.set_bgcolor(textmode::Color::Rgb(16, 64, 16));
+ out.write_str(&cmd[span.0..span.1]);
+ set_bgcolor(out, idx, focused);
+ }
+ if !cmd[span.1..].is_empty() {
+ out.write_str(&cmd[span.1..]);
+ }
+ } else {
+ out.write_str(cmd);
+ }
+ if self.cmd().len() > max_len {
+ if let State::Running(span) = self.state {
+ if span.0 < cmd.len() && span.1 > cmd.len() {
+ out.set_bgcolor(textmode::Color::Rgb(16, 64, 16));
+ }
+ }
+ out.write_str(" ");
+ if let State::Running(span) = self.state {
+ if span.1 > cmd.len() {
+ out.set_bgcolor(textmode::Color::Rgb(16, 64, 16));
+ }
+ }
+ out.set_fgcolor(textmode::color::BLUE);
+ out.write_str("...");
+ }
+ out.reset_attributes();
+
+ set_bgcolor(out, idx, focused);
+ let cur_pos = out.screen().cursor_position();
+ out.write_str(&" ".repeat(
+ usize::from(size.1) - time.len() - 1 - usize::from(cur_pos.1),
+ ));
+ out.write_str(&time);
+ out.write_str(" ");
+ out.reset_attributes();
+
+ if self.binary() {
+ let msg = "This appears to be binary data. Fullscreen this entry to view anyway.";
+ let len: u16 = msg.len().try_into().unwrap();
+ out.move_to(
+ out.screen().cursor_position().0 + 1,
+ (size.1 - len) / 2,
+ );
+ out.set_fgcolor(textmode::color::RED);
+ out.write_str(msg);
+ out.hide_cursor(true);
+ } else {
+ let last_row = self.output_lines(focused && !scrolling);
+ let mut max_lines = self.max_lines(entry_count);
+ if last_row > max_lines {
+ out.write(b"\r\n");
+ out.set_fgcolor(textmode::color::BLUE);
+ out.write_str("...");
+ out.reset_attributes();
+ max_lines -= 1;
+ }
+ let mut out_row = out.screen().cursor_position().0 + 1;
+ let screen = self.vt.screen();
+ let pos = screen.cursor_position();
+ let mut wrapped = false;
+ let mut cursor_found = None;
+ for (idx, row) in screen
+ .rows_formatted(0, size.1)
+ .enumerate()
+ .take(last_row)
+ .skip(last_row.saturating_sub(max_lines))
+ {
+ let idx: u16 = idx.try_into().unwrap();
+ out.reset_attributes();
+ if !wrapped {
+ out.move_to(out_row, 0);
+ }
+ out.write(&row);
+ wrapped = screen.row_wrapped(idx);
+ if pos.0 == idx {
+ cursor_found = Some(out_row);
+ }
+ out_row += 1;
+ }
+ if focused && !scrolling {
+ if let Some(row) = cursor_found {
+ out.hide_cursor(screen.hide_cursor());
+ out.move_to(row, pos.1);
+ } else {
+ out.hide_cursor(true);
+ }
+ }
+ }
+ out.reset_attributes();
+ }
+
+ pub fn render_fullscreen(&mut self, out: &mut impl textmode::Textmode) {
+ out.write(&self.vt.screen().state_formatted());
+ self.bell(out);
+ self.audible_bell = false;
+ self.visual_bell = false;
+ out.reset_attributes();
+ }
+
+ pub async fn send_input(&self, bytes: Vec<u8>) {
+ if self.running() {
+ self.input.send(bytes).await.unwrap();
+ }
+ }
+
+ pub async fn resize(&mut self, size: (u16, u16)) {
+ if self.running() {
+ self.resize.send(size).await.unwrap();
+ self.vt.set_size(size.0, size.1);
+ }
+ }
+
+ pub fn size(&self) -> (u16, u16) {
+ self.vt.screen().size()
+ }
+
+ pub fn process(&mut self, input: &[u8]) {
+ self.vt.process(input);
+ let screen = self.vt.screen();
+
+ let new_audible_bell_state = screen.audible_bell_count();
+ if new_audible_bell_state != self.audible_bell_state {
+ self.audible_bell = true;
+ self.real_bell_pending = true;
+ self.audible_bell_state = new_audible_bell_state;
+ }
+
+ let new_visual_bell_state = screen.visual_bell_count();
+ if new_visual_bell_state != self.visual_bell_state {
+ self.visual_bell = true;
+ self.real_bell_pending = true;
+ self.visual_bell_state = new_visual_bell_state;
+ }
+ }
+
+ pub fn cmd(&self) -> &str {
+ &self.cmdline
+ }
+
+ pub fn env(&self) -> &Env {
+ &self.env
+ }
+
+ pub fn toggle_fullscreen(&mut self) {
+ if let Some(fullscreen) = self.fullscreen {
+ self.fullscreen = Some(!fullscreen);
+ } else {
+ self.fullscreen = Some(!self.vt.screen().alternate_screen());
+ }
+ }
+
+ pub fn set_fullscreen(&mut self, fullscreen: bool) {
+ self.fullscreen = Some(fullscreen);
+ }
+
+ pub fn running(&self) -> bool {
+ matches!(self.state, State::Running(_))
+ }
+
+ pub fn binary(&self) -> bool {
+ self.vt.screen().errors() > 5
+ }
+
+ pub fn lines(&self, entry_count: usize, focused: bool) -> usize {
+ 1 + std::cmp::min(
+ self.output_lines(focused),
+ self.max_lines(entry_count),
+ )
+ }
+
+ fn max_lines(&self, entry_count: usize) -> usize {
+ if self.env.idx() == entry_count - 1 {
+ usize::from(self.size().0) * 2 / 3
+ } else {
+ 5
+ }
+ }
+
+ pub fn output_lines(&self, focused: bool) -> usize {
+ if self.binary() {
+ return 1;
+ }
+
+ let screen = self.vt.screen();
+ let mut last_row = 0;
+ for (idx, row) in screen.rows(0, self.size().1).enumerate() {
+ if !row.is_empty() {
+ last_row = idx + 1;
+ }
+ }
+ if focused && self.running() {
+ last_row = std::cmp::max(
+ last_row,
+ usize::from(screen.cursor_position().0) + 1,
+ );
+ }
+ last_row
+ }
+
+ pub fn should_fullscreen(&self) -> bool {
+ self.fullscreen
+ .unwrap_or_else(|| self.vt.screen().alternate_screen())
+ }
+
+ pub fn set_span(&mut self, span: (usize, usize)) {
+ if matches!(self.state, State::Running(_)) {
+ self.state = State::Running(span);
+ }
+ }
+
+ pub async fn finish(
+ &mut self,
+ env: Env,
+ event_w: async_std::channel::Sender<Event>,
+ ) {
+ self.state = State::Exited(ExitInfo::new(env.latest_status()));
+ self.env = env;
+ event_w.send(Event::PtyClose).await.unwrap();
+ }
+
+ fn exit_info(&self) -> Option<&ExitInfo> {
+ match &self.state {
+ State::Running(..) => None,
+ State::Exited(exit_info) => Some(exit_info),
+ }
+ }
+
+ fn bell(&mut self, out: &mut impl textmode::Textmode) {
+ if self.real_bell_pending {
+ if self.audible_bell {
+ out.write(b"\x07");
+ }
+ if self.visual_bell {
+ out.write(b"\x1bg");
+ }
+ self.real_bell_pending = false;
+ }
+ }
+}
+
+struct ExitInfo {
+ status: async_std::process::ExitStatus,
+ instant: std::time::Instant,
+}
+
+impl ExitInfo {
+ fn new(status: async_std::process::ExitStatus) -> Self {
+ Self {
+ status,
+ instant: std::time::Instant::now(),
+ }
+ }
+}
+
+fn set_bgcolor(out: &mut impl textmode::Textmode, idx: usize, focus: bool) {
+ if focus {
+ out.set_bgcolor(textmode::Color::Rgb(0x56, 0x1b, 0x8b));
+ } else if idx % 2 == 0 {
+ out.set_bgcolor(textmode::Color::Rgb(0x24, 0x21, 0x00));
+ } else {
+ out.set_bgcolor(textmode::Color::Rgb(0x20, 0x20, 0x20));
+ }
+}
diff --git a/src/shell/history/mod.rs b/src/shell/history/mod.rs
new file mode 100644
index 0000000..ad83e92
--- /dev/null
+++ b/src/shell/history/mod.rs
@@ -0,0 +1,384 @@
+use crate::shell::prelude::*;
+
+mod entry;
+pub use entry::Entry;
+mod pty;
+
+pub struct History {
+ size: (u16, u16),
+ entries: Vec<crate::mutex::Mutex<Entry>>,
+ scroll_pos: usize,
+}
+
+impl History {
+ pub fn new() -> Self {
+ Self {
+ size: (24, 80),
+ entries: vec![],
+ scroll_pos: 0,
+ }
+ }
+
+ pub async fn render(
+ &self,
+ out: &mut impl textmode::Textmode,
+ repl_lines: usize,
+ focus: Option<usize>,
+ scrolling: bool,
+ offset: time::UtcOffset,
+ ) -> anyhow::Result<()> {
+ let mut used_lines = repl_lines;
+ let mut cursor = None;
+ for (idx, mut entry) in
+ self.visible(repl_lines, focus, scrolling).await.rev()
+ {
+ let focused = focus.map_or(false, |focus| idx == focus);
+ used_lines +=
+ entry.lines(self.entry_count(), focused && !scrolling);
+ out.move_to(
+ (usize::from(self.size.0) - used_lines).try_into().unwrap(),
+ 0,
+ );
+ entry.render(
+ out,
+ idx,
+ self.entry_count(),
+ self.size,
+ focused,
+ scrolling,
+ offset,
+ );
+ if focused && !scrolling {
+ cursor = Some((
+ out.screen().cursor_position(),
+ out.screen().hide_cursor(),
+ ));
+ }
+ }
+ if let Some((pos, hide)) = cursor {
+ out.move_to(pos.0, pos.1);
+ out.hide_cursor(hide);
+ }
+ Ok(())
+ }
+
+ pub async fn render_fullscreen(
+ &self,
+ out: &mut impl textmode::Textmode,
+ idx: usize,
+ ) {
+ let mut entry = self.entries[idx].lock_arc().await;
+ entry.render_fullscreen(out);
+ }
+
+ pub async fn send_input(&mut self, idx: usize, input: Vec<u8>) {
+ self.entry(idx).await.send_input(input).await;
+ }
+
+ pub async fn resize(&mut self, size: (u16, u16)) {
+ self.size = size;
+ for entry in &self.entries {
+ entry.lock_arc().await.resize(size).await;
+ }
+ }
+
+ pub async fn run(
+ &mut self,
+ cmdline: &str,
+ env: &Env,
+ event_w: async_std::channel::Sender<Event>,
+ ) -> anyhow::Result<usize> {
+ let (input_w, input_r) = async_std::channel::unbounded();
+ let (resize_w, resize_r) = async_std::channel::unbounded();
+
+ let entry = crate::mutex::new(Entry::new(
+ cmdline.to_string(),
+ env.clone(),
+ self.size,
+ input_w,
+ resize_w,
+ ));
+ run_commands(
+ cmdline.to_string(),
+ crate::mutex::clone(&entry),
+ env.clone(),
+ input_r,
+ resize_r,
+ event_w,
+ );
+
+ self.entries.push(entry);
+ Ok(self.entries.len() - 1)
+ }
+
+ pub async fn entry(&self, idx: usize) -> crate::mutex::Guard<Entry> {
+ self.entries[idx].lock_arc().await
+ }
+
+ pub fn entry_count(&self) -> usize {
+ self.entries.len()
+ }
+
+ pub async fn make_focus_visible(
+ &mut self,
+ repl_lines: usize,
+ focus: Option<usize>,
+ scrolling: bool,
+ ) {
+ if self.entries.is_empty() || focus.is_none() {
+ return;
+ }
+ let focus = focus.unwrap();
+
+ let mut done = false;
+ while focus
+ < self
+ .visible(repl_lines, Some(focus), scrolling)
+ .await
+ .map(|(idx, _)| idx)
+ .next()
+ .unwrap()
+ {
+ self.scroll_pos += 1;
+ done = true;
+ }
+ if done {
+ return;
+ }
+
+ while focus
+ > self
+ .visible(repl_lines, Some(focus), scrolling)
+ .await
+ .map(|(idx, _)| idx)
+ .last()
+ .unwrap()
+ {
+ self.scroll_pos -= 1;
+ }
+ }
+
+ async fn visible(
+ &self,
+ repl_lines: usize,
+ focus: Option<usize>,
+ scrolling: bool,
+ ) -> VisibleEntries {
+ let mut iter = VisibleEntries::new();
+ if self.entries.is_empty() {
+ return iter;
+ }
+
+ let mut used_lines = repl_lines;
+ for (idx, entry) in
+ self.entries.iter().enumerate().rev().skip(self.scroll_pos)
+ {
+ let entry = entry.lock_arc().await;
+ let focused = focus.map_or(false, |focus| idx == focus);
+ used_lines +=
+ entry.lines(self.entry_count(), focused && !scrolling);
+ if used_lines > usize::from(self.size.0) {
+ break;
+ }
+ iter.add(idx, entry);
+ }
+ iter
+ }
+}
+
+struct VisibleEntries {
+ entries: std::collections::VecDeque<(usize, crate::mutex::Guard<Entry>)>,
+}
+
+impl VisibleEntries {
+ fn new() -> Self {
+ Self {
+ entries: std::collections::VecDeque::new(),
+ }
+ }
+
+ fn add(&mut self, idx: usize, entry: crate::mutex::Guard<Entry>) {
+ // push_front because we are adding them in reverse order
+ self.entries.push_front((idx, entry));
+ }
+}
+
+impl std::iter::Iterator for VisibleEntries {
+ type Item = (usize, crate::mutex::Guard<Entry>);
+
+ fn next(&mut self) -> Option<Self::Item> {
+ self.entries.pop_front()
+ }
+}
+
+impl std::iter::DoubleEndedIterator for VisibleEntries {
+ fn next_back(&mut self) -> Option<Self::Item> {
+ self.entries.pop_back()
+ }
+}
+
+fn run_commands(
+ cmdline: String,
+ entry: crate::mutex::Mutex<Entry>,
+ mut env: Env,
+ input_r: async_std::channel::Receiver<Vec<u8>>,
+ resize_r: async_std::channel::Receiver<(u16, u16)>,
+ event_w: async_std::channel::Sender<Event>,
+) {
+ async_std::task::spawn(async move {
+ let pty = match pty::Pty::new(
+ entry.lock_arc().await.size(),
+ &entry,
+ input_r,
+ resize_r,
+ event_w.clone(),
+ ) {
+ Ok(pty) => pty,
+ Err(e) => {
+ let mut entry = entry.lock_arc().await;
+ entry.process(
+ format!("nbsh: failed to allocate pty: {}\r\n", e)
+ .as_bytes(),
+ );
+ env.set_status(async_std::process::ExitStatus::from_raw(
+ 1 << 8,
+ ));
+ entry.finish(env, event_w).await;
+ return;
+ }
+ };
+
+ let status =
+ match spawn_commands(&cmdline, &pty, &mut env, event_w.clone())
+ .await
+ {
+ Ok(status) => status,
+ Err(e) => {
+ let mut entry = entry.lock_arc().await;
+ entry.process(
+ format!(
+ "nbsh: failed to spawn {}: {}\r\n",
+ cmdline, e
+ )
+ .as_bytes(),
+ );
+ env.set_status(async_std::process::ExitStatus::from_raw(
+ 1 << 8,
+ ));
+ entry.finish(env, event_w).await;
+ return;
+ }
+ };
+ env.set_status(status);
+
+ entry.lock_arc().await.finish(env, event_w).await;
+ pty.close().await;
+ });
+}
+
+async fn spawn_commands(
+ cmdline: &str,
+ pty: &pty::Pty,
+ env: &mut Env,
+ event_w: async_std::channel::Sender<Event>,
+) -> anyhow::Result<async_std::process::ExitStatus> {
+ let mut cmd = pty_process::Command::new(std::env::current_exe()?);
+ cmd.args(&["-c", cmdline]);
+ env.apply(&mut cmd);
+ let (from_r, from_w) = nix::unistd::pipe2(nix::fcntl::OFlag::O_CLOEXEC)?;
+ // Safety: dup2 is an async-signal-safe function
+ unsafe {
+ cmd.pre_exec(move || {
+ nix::unistd::dup2(from_w, 3)?;
+ Ok(())
+ });
+ }
+ let child = pty.spawn(cmd)?;
+ nix::unistd::close(from_w)?;
+
+ let (read_w, read_r) = async_std::channel::unbounded();
+ let new_read = move || {
+ let read_w = read_w.clone();
+ async_std::task::spawn(async move {
+ let event = blocking::unblock(move || {
+ // Safety: from_r was just opened above and is only
+ // referenced in this closure, which takes ownership of it
+ // at the start and returns ownership of it at the end
+ let fh = unsafe { std::fs::File::from_raw_fd(from_r) };
+ let event = bincode::deserialize_from(&fh);
+ let _ = fh.into_raw_fd();
+ event
+ })
+ .await;
+ if read_w.is_closed() {
+ // we should never drop read_r while there are still valid
+ // things to read
+ assert!(event.is_err());
+ } else {
+ read_w.send(event).await.unwrap();
+ }
+ });
+ };
+
+ new_read();
+ let mut read_done = false;
+ let mut exit_done = None;
+ loop {
+ enum Res {
+ Read(bincode::Result<crate::runner::Event>),
+ Exit(std::io::Result<std::process::ExitStatus>),
+ }
+
+ let read_r = read_r.clone();
+ let read = async move { Res::Read(read_r.recv().await.unwrap()) };
+ let exit = async {
+ Res::Exit(if exit_done.is_none() {
+ child.status_no_drop().await
+ } else {
+ std::future::pending().await
+ })
+ };
+ match read.or(exit).await {
+ Res::Read(Ok(event)) => match event {
+ crate::runner::Event::RunPipeline(idx, span) => {
+ event_w
+ .send(Event::ChildRunPipeline(idx, span))
+ .await
+ .unwrap();
+ new_read();
+ }
+ crate::runner::Event::Suspend(idx) => {
+ event_w.send(Event::ChildSuspend(idx)).await.unwrap();
+ new_read();
+ }
+ crate::runner::Event::Exit(new_env) => {
+ *env = new_env;
+ read_done = true;
+ }
+ },
+ Res::Read(Err(e)) => {
+ if let bincode::ErrorKind::Io(io_e) = &*e {
+ if io_e.kind() == std::io::ErrorKind::UnexpectedEof {
+ read_done = true;
+ } else {
+ anyhow::bail!(e);
+ }
+ } else {
+ anyhow::bail!(e);
+ }
+ }
+ Res::Exit(Ok(status)) => {
+ exit_done = Some(status);
+ }
+ Res::Exit(Err(e)) => {
+ anyhow::bail!(e);
+ }
+ }
+ if let (true, Some(status)) = (read_done, exit_done) {
+ nix::unistd::close(from_r)?;
+ // nix::sys::signal::Signal is repr(i32)
+ #[allow(clippy::as_conversions)]
+ return Ok(status);
+ }
+ }
+}
diff --git a/src/shell/history/pty.rs b/src/shell/history/pty.rs
new file mode 100644
index 0000000..0fe0942
--- /dev/null
+++ b/src/shell/history/pty.rs
@@ -0,0 +1,106 @@
+use crate::shell::prelude::*;
+
+pub struct Pty {
+ pty: async_std::sync::Arc<pty_process::Pty>,
+ close_w: async_std::channel::Sender<()>,
+}
+
+impl Pty {
+ pub fn new(
+ size: (u16, u16),
+ entry: &crate::mutex::Mutex<super::Entry>,
+ input_r: async_std::channel::Receiver<Vec<u8>>,
+ resize_r: async_std::channel::Receiver<(u16, u16)>,
+ event_w: async_std::channel::Sender<Event>,
+ ) -> anyhow::Result<Self> {
+ let (close_w, close_r) = async_std::channel::unbounded();
+
+ let pty = pty_process::Pty::new()?;
+ pty.resize(pty_process::Size::new(size.0, size.1))?;
+ let pty = async_std::sync::Arc::new(pty);
+
+ async_std::task::spawn(pty_task(
+ async_std::sync::Arc::clone(&pty),
+ crate::mutex::clone(entry),
+ input_r,
+ resize_r,
+ close_r,
+ event_w,
+ ));
+
+ Ok(Self { pty, close_w })
+ }
+
+ pub fn spawn(
+ &self,
+ mut cmd: pty_process::Command,
+ ) -> anyhow::Result<async_std::process::Child> {
+ Ok(cmd.spawn(&self.pty)?)
+ }
+
+ pub async fn close(&self) {
+ self.close_w.send(()).await.unwrap();
+ }
+}
+
+async fn pty_task(
+ pty: async_std::sync::Arc<pty_process::Pty>,
+ entry: crate::mutex::Mutex<super::Entry>,
+ input_r: async_std::channel::Receiver<Vec<u8>>,
+ resize_r: async_std::channel::Receiver<(u16, u16)>,
+ close_r: async_std::channel::Receiver<()>,
+ event_w: async_std::channel::Sender<Event>,
+) {
+ loop {
+ enum Res {
+ Read(Result<usize, std::io::Error>),
+ Write(Result<Vec<u8>, async_std::channel::RecvError>),
+ Resize(Result<(u16, u16), async_std::channel::RecvError>),
+ Close(Result<(), async_std::channel::RecvError>),
+ }
+ let mut buf = [0_u8; 4096];
+ let read = async { Res::Read((&*pty).read(&mut buf).await) };
+ let write = async { Res::Write(input_r.recv().await) };
+ let resize = async { Res::Resize(resize_r.recv().await) };
+ let close = async { Res::Close(close_r.recv().await) };
+ match read.race(write).race(resize).or(close).await {
+ Res::Read(res) => match res {
+ Ok(bytes) => {
+ entry.lock_arc().await.process(&buf[..bytes]);
+ event_w.send(Event::PtyOutput).await.unwrap();
+ }
+ Err(e) => {
+ if e.raw_os_error() != Some(libc::EIO) {
+ panic!("pty read failed: {:?}", e);
+ }
+ }
+ },
+ Res::Write(res) => match res {
+ Ok(bytes) => {
+ (&*pty).write(&bytes).await.unwrap();
+ }
+ Err(e) => {
+ panic!("failed to read from input channel: {}", e);
+ }
+ },
+ Res::Resize(res) => match res {
+ Ok(size) => {
+ pty.resize(pty_process::Size::new(size.0, size.1))
+ .unwrap();
+ }
+ Err(e) => {
+ panic!("failed to read from resize channel: {}", e);
+ }
+ },
+ Res::Close(res) => match res {
+ Ok(()) => {
+ event_w.send(Event::PtyClose).await.unwrap();
+ return;
+ }
+ Err(e) => {
+ panic!("failed to read from close channel: {}", e);
+ }
+ },
+ }
+ }
+}
diff --git a/src/shell/mod.rs b/src/shell/mod.rs
new file mode 100644
index 0000000..f7080a4
--- /dev/null
+++ b/src/shell/mod.rs
@@ -0,0 +1,665 @@
+use crate::shell::prelude::*;
+
+use notify::Watcher as _;
+use textmode::Textmode as _;
+
+mod event;
+mod git;
+mod history;
+mod prelude;
+mod readline;
+
+pub async fn main() -> anyhow::Result<i32> {
+ let mut input = textmode::Input::new().await?;
+ let mut output = textmode::Output::new().await?;
+
+ // avoid the guards getting stuck in a task that doesn't run to
+ // completion
+ let _input_guard = input.take_raw_guard();
+ let _output_guard = output.take_screen_guard();
+
+ let (event_w, event_r) = async_std::channel::unbounded();
+
+ {
+ // nix::sys::signal::Signal is repr(i32)
+ #[allow(clippy::as_conversions)]
+ let signals = signal_hook_async_std::Signals::new(&[
+ nix::sys::signal::Signal::SIGWINCH as i32,
+ ])?;
+ let event_w = event_w.clone();
+ async_std::task::spawn(async move {
+ // nix::sys::signal::Signal is repr(i32)
+ #[allow(clippy::as_conversions)]
+ let mut signals = async_std::stream::once(
+ nix::sys::signal::Signal::SIGWINCH as i32,
+ )
+ .chain(signals);
+ while signals.next().await.is_some() {
+ event_w
+ .send(Event::Resize(
+ terminal_size::terminal_size().map_or(
+ (24, 80),
+ |(
+ terminal_size::Width(w),
+ terminal_size::Height(h),
+ )| { (h, w) },
+ ),
+ ))
+ .await
+ .unwrap();
+ }
+ });
+ }
+
+ {
+ let event_w = event_w.clone();
+ async_std::task::spawn(async move {
+ while let Some(key) = input.read_key().await.unwrap() {
+ event_w.send(Event::Key(key)).await.unwrap();
+ }
+ });
+ }
+
+ // redraw the clock every second
+ {
+ let event_w = event_w.clone();
+ async_std::task::spawn(async move {
+ let first_sleep = 1_000_000_000_u64.saturating_sub(
+ time::OffsetDateTime::now_utc().nanosecond().into(),
+ );
+ async_std::task::sleep(std::time::Duration::from_nanos(
+ first_sleep,
+ ))
+ .await;
+ let mut interval = async_std::stream::interval(
+ std::time::Duration::from_secs(1),
+ );
+ event_w.send(Event::ClockTimer).await.unwrap();
+ while interval.next().await.is_some() {
+ event_w.send(Event::ClockTimer).await.unwrap();
+ }
+ });
+ }
+
+ let (git_w, git_r): (async_std::channel::Sender<std::path::PathBuf>, _) =
+ async_std::channel::unbounded();
+ {
+ let event_w = event_w.clone();
+ let mut _active_watcher = None;
+ async_std::task::spawn(async move {
+ while let Ok(mut dir) = git_r.recv().await {
+ while let Ok(newer_dir) = git_r.try_recv() {
+ dir = newer_dir;
+ }
+ let repo = git2::Repository::discover(&dir).ok();
+ if repo.is_some() {
+ let (sync_watch_w, sync_watch_r) =
+ std::sync::mpsc::channel();
+ let (watch_w, watch_r) = async_std::channel::unbounded();
+ let mut watcher = notify::RecommendedWatcher::new(
+ sync_watch_w,
+ std::time::Duration::from_millis(100),
+ )
+ .unwrap();
+ watcher
+ .watch(&dir, notify::RecursiveMode::Recursive)
+ .unwrap();
+ async_std::task::spawn(blocking::unblock(move || {
+ while let Ok(event) = sync_watch_r.recv() {
+ let watch_w = watch_w.clone();
+ let send_failed =
+ async_std::task::block_on(async move {
+ watch_w.send(event).await.is_err()
+ });
+ if send_failed {
+ break;
+ }
+ }
+ }));
+ let event_w = event_w.clone();
+ async_std::task::spawn(async move {
+ while watch_r.recv().await.is_ok() {
+ let repo = git2::Repository::discover(&dir).ok();
+ let info = blocking::unblock(|| {
+ repo.map(|repo| git::Info::new(&repo))
+ })
+ .await;
+ if event_w
+ .send(Event::GitInfo(info))
+ .await
+ .is_err()
+ {
+ break;
+ }
+ }
+ });
+ _active_watcher = Some(watcher);
+ } else {
+ _active_watcher = None;
+ }
+ let info = blocking::unblock(|| {
+ repo.map(|repo| git::Info::new(&repo))
+ })
+ .await;
+ event_w.send(Event::GitInfo(info)).await.unwrap();
+ }
+ });
+ }
+
+ let mut shell = Shell::new(crate::info::get_offset())?;
+ let mut prev_dir = shell.env.pwd().to_path_buf();
+ git_w.send(prev_dir.clone()).await.unwrap();
+ let event_reader = event::Reader::new(event_r);
+ while let Some(event) = event_reader.recv().await {
+ let dir = shell.env().pwd();
+ if dir != prev_dir {
+ prev_dir = dir.to_path_buf();
+ git_w.send(dir.to_path_buf()).await.unwrap();
+ }
+ match shell.handle_event(event, &event_w).await {
+ Some(Action::Refresh) => {
+ shell.render(&mut output).await?;
+ output.refresh().await?;
+ }
+ Some(Action::HardRefresh) => {
+ shell.render(&mut output).await?;
+ output.hard_refresh().await?;
+ }
+ Some(Action::Resize(rows, cols)) => {
+ output.set_size(rows, cols);
+ shell.render(&mut output).await?;
+ output.hard_refresh().await?;
+ }
+ Some(Action::Quit) => break,
+ None => {}
+ }
+ }
+
+ Ok(0)
+}
+
+#[derive(Copy, Clone, Debug)]
+enum Focus {
+ Readline,
+ History(usize),
+ Scrolling(Option<usize>),
+}
+
+#[derive(Copy, Clone, Debug)]
+enum Scene {
+ Readline,
+ Fullscreen,
+}
+
+pub enum Action {
+ Refresh,
+ HardRefresh,
+ Resize(u16, u16),
+ Quit,
+}
+
+pub struct Shell {
+ readline: readline::Readline,
+ history: history::History,
+ env: Env,
+ git: Option<git::Info>,
+ focus: Focus,
+ scene: Scene,
+ escape: bool,
+ hide_readline: bool,
+ offset: time::UtcOffset,
+}
+
+impl Shell {
+ pub fn new(offset: time::UtcOffset) -> anyhow::Result<Self> {
+ let mut env = Env::new()?;
+ env.set_var("SHELL", std::env::current_exe()?);
+ env.set_var("TERM", "screen");
+ Ok(Self {
+ readline: readline::Readline::new(),
+ history: history::History::new(),
+ env,
+ git: None,
+ focus: Focus::Readline,
+ scene: Scene::Readline,
+ escape: false,
+ hide_readline: false,
+ offset,
+ })
+ }
+
+ pub async fn render(
+ &self,
+ out: &mut impl textmode::Textmode,
+ ) -> anyhow::Result<()> {
+ out.clear();
+ out.write(&vt100::Parser::default().screen().input_mode_formatted());
+ match self.scene {
+ Scene::Readline => match self.focus {
+ Focus::Readline => {
+ self.history
+ .render(
+ out,
+ self.readline.lines(),
+ None,
+ false,
+ self.offset,
+ )
+ .await?;
+ self.readline
+ .render(
+ out,
+ &self.env,
+ self.git.as_ref(),
+ true,
+ self.offset,
+ )
+ .await?;
+ }
+ Focus::History(idx) => {
+ if self.hide_readline {
+ self.history
+ .render(out, 0, Some(idx), false, self.offset)
+ .await?;
+ } else {
+ self.history
+ .render(
+ out,
+ self.readline.lines(),
+ Some(idx),
+ false,
+ self.offset,
+ )
+ .await?;
+ let pos = out.screen().cursor_position();
+ self.readline
+ .render(
+ out,
+ &self.env,
+ self.git.as_ref(),
+ false,
+ self.offset,
+ )
+ .await?;
+ out.move_to(pos.0, pos.1);
+ }
+ }
+ Focus::Scrolling(idx) => {
+ self.history
+ .render(
+ out,
+ self.readline.lines(),
+ idx,
+ true,
+ self.offset,
+ )
+ .await?;
+ self.readline
+ .render(
+ out,
+ &self.env,
+ self.git.as_ref(),
+ idx.is_none(),
+ self.offset,
+ )
+ .await?;
+ out.hide_cursor(true);
+ }
+ },
+ Scene::Fullscreen => {
+ if let Focus::History(idx) = self.focus {
+ self.history.render_fullscreen(out, idx).await;
+ } else {
+ unreachable!();
+ }
+ }
+ }
+ Ok(())
+ }
+
+ pub async fn handle_event(
+ &mut self,
+ event: Event,
+ event_w: &async_std::channel::Sender<Event>,
+ ) -> Option<Action> {
+ match event {
+ Event::Key(key) => {
+ return if self.escape {
+ self.escape = false;
+ self.handle_key_escape(key, event_w.clone()).await
+ } else if key == textmode::Key::Ctrl(b'e') {
+ self.escape = true;
+ None
+ } else {
+ match self.focus {
+ Focus::Readline => {
+ self.handle_key_readline(key, event_w.clone())
+ .await
+ }
+ Focus::History(idx) => {
+ self.handle_key_history(key, idx).await;
+ None
+ }
+ Focus::Scrolling(_) => {
+ self.handle_key_escape(key, event_w.clone()).await
+ }
+ }
+ };
+ }
+ Event::Resize(new_size) => {
+ self.readline.resize(new_size).await;
+ self.history.resize(new_size).await;
+ return Some(Action::Resize(new_size.0, new_size.1));
+ }
+ Event::PtyOutput => {
+ // the number of visible lines may have changed, so make sure
+ // the focus is still visible
+ self.history
+ .make_focus_visible(
+ self.readline.lines(),
+ self.focus_idx(),
+ matches!(self.focus, Focus::Scrolling(_)),
+ )
+ .await;
+ self.scene = self.default_scene(self.focus, None).await;
+ }
+ Event::PtyClose => {
+ if let Some(idx) = self.focus_idx() {
+ let entry = self.history.entry(idx).await;
+ if !entry.running() {
+ if self.hide_readline {
+ let idx = self.env.idx();
+ self.env = entry.env().clone();
+ self.env.set_idx(idx);
+ }
+ self.set_focus(
+ if self.hide_readline {
+ Focus::Readline
+ } else {
+ Focus::Scrolling(Some(idx))
+ },
+ Some(entry),
+ )
+ .await;
+ }
+ }
+ }
+ Event::ChildRunPipeline(idx, span) => {
+ self.history.entry(idx).await.set_span(span);
+ }
+ Event::ChildSuspend(idx) => {
+ if self.focus_idx() == Some(idx) {
+ self.set_focus(Focus::Readline, None).await;
+ }
+ }
+ Event::GitInfo(info) => {
+ self.git = info;
+ }
+ Event::ClockTimer => {}
+ };
+ Some(Action::Refresh)
+ }
+
+ async fn handle_key_escape(
+ &mut self,
+ key: textmode::Key,
+ event_w: async_std::channel::Sender<Event>,
+ ) -> Option<Action> {
+ match key {
+ textmode::Key::Ctrl(b'd') => {
+ return Some(Action::Quit);
+ }
+ textmode::Key::Ctrl(b'e') => {
+ self.set_focus(Focus::Scrolling(self.focus_idx()), None)
+ .await;
+ }
+ textmode::Key::Ctrl(b'l') => {
+ return Some(Action::HardRefresh);
+ }
+ textmode::Key::Ctrl(b'm') => {
+ if let Some(idx) = self.focus_idx() {
+ self.readline.clear_input();
+ let entry = self.history.entry(idx).await;
+ let input = entry.cmd();
+ let idx = self
+ .history
+ .run(input, &self.env, event_w.clone())
+ .await
+ .unwrap();
+ self.set_focus(Focus::History(idx), Some(entry)).await;
+ self.hide_readline = true;
+ self.env.set_idx(idx + 1);
+ } else {
+ self.set_focus(Focus::Readline, None).await;
+ }
+ }
+ textmode::Key::Char(' ') => {
+ let idx = self.focus_idx();
+ let (focus, entry) = if let Some(idx) = idx {
+ let entry = self.history.entry(idx).await;
+ (entry.running(), Some(entry))
+ } else {
+ (true, None)
+ };
+ if focus {
+ self.set_focus(
+ idx.map_or(Focus::Readline, |idx| {
+ Focus::History(idx)
+ }),
+ entry,
+ )
+ .await;
+ }
+ }
+ textmode::Key::Char('e') => {
+ if let Focus::History(idx) = self.focus {
+ self.handle_key_history(textmode::Key::Ctrl(b'e'), idx)
+ .await;
+ }
+ }
+ textmode::Key::Char('f') => {
+ if let Some(idx) = self.focus_idx() {
+ let mut entry = self.history.entry(idx).await;
+ let mut focus = Focus::History(idx);
+ if let Focus::Scrolling(_) = self.focus {
+ entry.set_fullscreen(true);
+ } else {
+ entry.toggle_fullscreen();
+ if !entry.should_fullscreen() && !entry.running() {
+ focus = Focus::Scrolling(Some(idx));
+ }
+ }
+ self.set_focus(focus, Some(entry)).await;
+ }
+ }
+ textmode::Key::Char('i') => {
+ if let Some(idx) = self.focus_idx() {
+ let entry = self.history.entry(idx).await;
+ self.readline.set_input(entry.cmd());
+ self.set_focus(Focus::Readline, Some(entry)).await;
+ }
+ }
+ textmode::Key::Char('j') | textmode::Key::Down => {
+ self.set_focus(
+ Focus::Scrolling(self.scroll_down(self.focus_idx())),
+ None,
+ )
+ .await;
+ }
+ textmode::Key::Char('k') | textmode::Key::Up => {
+ self.set_focus(
+ Focus::Scrolling(self.scroll_up(self.focus_idx())),
+ None,
+ )
+ .await;
+ }
+ textmode::Key::Char('n') => {
+ self.set_focus(self.next_running().await, None).await;
+ }
+ textmode::Key::Char('p') => {
+ self.set_focus(self.prev_running().await, None).await;
+ }
+ textmode::Key::Char('r') => {
+ self.set_focus(Focus::Readline, None).await;
+ }
+ _ => {
+ return None;
+ }
+ }
+ Some(Action::Refresh)
+ }
+
+ async fn handle_key_readline(
+ &mut self,
+ key: textmode::Key,
+ event_w: async_std::channel::Sender<Event>,
+ ) -> Option<Action> {
+ match key {
+ textmode::Key::Char(c) => {
+ self.readline.add_input(&c.to_string());
+ }
+ textmode::Key::Ctrl(b'c') => self.readline.clear_input(),
+ textmode::Key::Ctrl(b'd') => {
+ return Some(Action::Quit);
+ }
+ textmode::Key::Ctrl(b'l') => {
+ return Some(Action::HardRefresh);
+ }
+ textmode::Key::Ctrl(b'm') => {
+ let input = self.readline.input();
+ if !input.is_empty() {
+ let idx = self
+ .history
+ .run(input, &self.env, event_w.clone())
+ .await
+ .unwrap();
+ self.set_focus(Focus::History(idx), None).await;
+ self.hide_readline = true;
+ self.env.set_idx(idx + 1);
+ self.readline.clear_input();
+ }
+ }
+ textmode::Key::Ctrl(b'u') => self.readline.clear_backwards(),
+ textmode::Key::Backspace => self.readline.backspace(),
+ textmode::Key::Left => self.readline.cursor_left(),
+ textmode::Key::Right => self.readline.cursor_right(),
+ textmode::Key::Up => {
+ let entry_count = self.history.entry_count();
+ if entry_count > 0 {
+ self.set_focus(
+ Focus::Scrolling(Some(entry_count - 1)),
+ None,
+ )
+ .await;
+ }
+ }
+ _ => return None,
+ }
+ Some(Action::Refresh)
+ }
+
+ async fn handle_key_history(&mut self, key: textmode::Key, idx: usize) {
+ self.history.send_input(idx, key.into_bytes()).await;
+ }
+
+ async fn default_scene(
+ &self,
+ focus: Focus,
+ entry: Option<crate::mutex::Guard<history::Entry>>,
+ ) -> Scene {
+ match focus {
+ Focus::Readline | Focus::Scrolling(_) => Scene::Readline,
+ Focus::History(idx) => {
+ let fullscreen = if let Some(entry) = entry {
+ entry.should_fullscreen()
+ } else {
+ self.history.entry(idx).await.should_fullscreen()
+ };
+ if fullscreen {
+ Scene::Fullscreen
+ } else {
+ Scene::Readline
+ }
+ }
+ }
+ }
+
+ async fn set_focus(
+ &mut self,
+ new_focus: Focus,
+ entry: Option<crate::mutex::Guard<history::Entry>>,
+ ) {
+ self.focus = new_focus;
+ self.hide_readline = false;
+ self.scene = self.default_scene(new_focus, entry).await;
+ // passing entry into default_scene above consumes it, which means
+ // that the mutex lock will be dropped before we call into
+ // make_focus_visible, which is important because otherwise we might
+ // get a deadlock depending on what is visible
+ self.history
+ .make_focus_visible(
+ self.readline.lines(),
+ self.focus_idx(),
+ matches!(self.focus, Focus::Scrolling(_)),
+ )
+ .await;
+ }
+
+ fn env(&self) -> &Env {
+ &self.env
+ }
+
+ fn focus_idx(&self) -> Option<usize> {
+ match self.focus {
+ Focus::History(idx) => Some(idx),
+ Focus::Readline => None,
+ Focus::Scrolling(idx) => idx,
+ }
+ }
+
+ fn scroll_up(&self, idx: Option<usize>) -> Option<usize> {
+ idx.map_or_else(
+ || {
+ let count = self.history.entry_count();
+ if count == 0 {
+ None
+ } else {
+ Some(count - 1)
+ }
+ },
+ |idx| Some(idx.saturating_sub(1)),
+ )
+ }
+
+ fn scroll_down(&self, idx: Option<usize>) -> Option<usize> {
+ idx.and_then(|idx| {
+ if idx >= self.history.entry_count() - 1 {
+ None
+ } else {
+ Some(idx + 1)
+ }
+ })
+ }
+
+ async fn next_running(&self) -> Focus {
+ let count = self.history.entry_count();
+ let cur = self.focus_idx().unwrap_or(count);
+ for idx in ((cur + 1)..count).chain(0..cur) {
+ if self.history.entry(idx).await.running() {
+ return Focus::History(idx);
+ }
+ }
+ self.focus_idx().map_or(Focus::Readline, Focus::History)
+ }
+
+ async fn prev_running(&self) -> Focus {
+ let count = self.history.entry_count();
+ let cur = self.focus_idx().unwrap_or(count);
+ for idx in ((cur + 1)..count).chain(0..cur).rev() {
+ if self.history.entry(idx).await.running() {
+ return Focus::History(idx);
+ }
+ }
+ self.focus_idx().map_or(Focus::Readline, Focus::History)
+ }
+}
diff --git a/src/shell/prelude.rs b/src/shell/prelude.rs
new file mode 100644
index 0000000..73897bc
--- /dev/null
+++ b/src/shell/prelude.rs
@@ -0,0 +1,2 @@
+pub use super::event::Event;
+pub use crate::prelude::*;
diff --git a/src/shell/readline.rs b/src/shell/readline.rs
new file mode 100644
index 0000000..f0fb950
--- /dev/null
+++ b/src/shell/readline.rs
@@ -0,0 +1,223 @@
+use crate::shell::prelude::*;
+
+use unicode_width::{UnicodeWidthChar as _, UnicodeWidthStr as _};
+
+pub struct Readline {
+ size: (u16, u16),
+ input_line: String,
+ scroll: usize,
+ pos: usize,
+}
+
+impl Readline {
+ pub fn new() -> Self {
+ Self {
+ size: (24, 80),
+ input_line: "".into(),
+ scroll: 0,
+ pos: 0,
+ }
+ }
+
+ pub async fn render(
+ &self,
+ out: &mut impl textmode::Textmode,
+ env: &Env,
+ git: Option<&super::git::Info>,
+ focus: bool,
+ offset: time::UtcOffset,
+ ) -> anyhow::Result<()> {
+ let pwd = env.pwd();
+ let user = crate::info::user()?;
+ let hostname = crate::info::hostname()?;
+ let time = crate::info::time(offset)?;
+ let prompt_char = crate::info::prompt_char()?;
+
+ let id = format!("{}@{}", user, hostname);
+ let idlen: u16 = id.len().try_into().unwrap();
+ let timelen: u16 = time.len().try_into().unwrap();
+
+ out.move_to(self.size.0 - 2, 0);
+ if focus {
+ out.set_bgcolor(textmode::Color::Rgb(0x56, 0x1b, 0x8b));
+ } else if env.idx() % 2 == 0 {
+ out.set_bgcolor(textmode::Color::Rgb(0x24, 0x21, 0x00));
+ } else {
+ out.set_bgcolor(textmode::Color::Rgb(0x20, 0x20, 0x20));
+ }
+ out.write(b"\x1b[K");
+ out.set_fgcolor(textmode::color::YELLOW);
+ out.write_str(&format!("{}", env.idx() + 1));
+ out.reset_attributes();
+ if focus {
+ out.set_bgcolor(textmode::Color::Rgb(0x56, 0x1b, 0x8b));
+ } else if env.idx() % 2 == 0 {
+ out.set_bgcolor(textmode::Color::Rgb(0x24, 0x21, 0x00));
+ } else {
+ out.set_bgcolor(textmode::Color::Rgb(0x20, 0x20, 0x20));
+ }
+ out.write_str(" (");
+ out.write_str(&crate::format::path(pwd));
+ if let Some(info) = git {
+ out.write_str(&format!("|{}", info));
+ }
+ out.write_str(")");
+ out.move_to(self.size.0 - 2, self.size.1 - 4 - idlen - timelen);
+ out.write_str(&id);
+ out.write_str(" [");
+ out.write_str(&time);
+ out.write_str("]");
+
+ out.move_to(self.size.0 - 1, 0);
+ out.reset_attributes();
+ out.write_str(&prompt_char);
+ out.write_str(" ");
+ out.reset_attributes();
+ out.write(b"\x1b[K");
+ out.write_str(self.visible_input());
+ out.reset_attributes();
+ out.move_to(self.size.0 - 1, 2 + self.pos_width());
+ if focus {
+ out.hide_cursor(false);
+ }
+ Ok(())
+ }
+
+ pub async fn resize(&mut self, size: (u16, u16)) {
+ self.size = size;
+ }
+
+ // self will be used eventually
+ #[allow(clippy::unused_self)]
+ pub fn lines(&self) -> usize {
+ 2 // XXX handle wrapping
+ }
+
+ pub fn input(&self) -> &str {
+ &self.input_line
+ }
+
+ pub fn add_input(&mut self, s: &str) {
+ self.input_line.insert_str(self.byte_pos(), s);
+ self.inc_pos(s.chars().count());
+ }
+
+ pub fn set_input(&mut self, s: &str) {
+ self.input_line = s.to_string();
+ self.set_pos(s.chars().count());
+ }
+
+ pub fn backspace(&mut self) {
+ while self.pos > 0 {
+ self.dec_pos(1);
+ let width =
+ self.input_line.remove(self.byte_pos()).width().unwrap_or(0);
+ if width > 0 {
+ break;
+ }
+ }
+ }
+
+ pub fn clear_input(&mut self) {
+ self.input_line.clear();
+ self.set_pos(0);
+ }
+
+ pub fn clear_backwards(&mut self) {
+ self.input_line = self.input_line.chars().skip(self.pos).collect();
+ self.set_pos(0);
+ }
+
+ pub fn cursor_left(&mut self) {
+ if self.pos == 0 {
+ return;
+ }
+ self.dec_pos(1);
+ while let Some(c) = self.input_line.chars().nth(self.pos) {
+ if c.width().unwrap_or(0) == 0 {
+ self.dec_pos(1);
+ } else {
+ break;
+ }
+ }
+ }
+
+ pub fn cursor_right(&mut self) {
+ if self.pos == self.input_line.chars().count() {
+ return;
+ }
+ self.inc_pos(1);
+ while let Some(c) = self.input_line.chars().nth(self.pos) {
+ if c.width().unwrap_or(0) == 0 {
+ self.inc_pos(1);
+ } else {
+ break;
+ }
+ }
+ }
+
+ fn set_pos(&mut self, pos: usize) {
+ self.pos = pos;
+ if self.pos < self.scroll || self.pos_width() > self.size.1 - 2 {
+ self.scroll = self.pos;
+ let mut extra_scroll = usize::from(self.size.1) / 2;
+ while extra_scroll > 0 && self.scroll > 0 {
+ self.scroll -= 1;
+ extra_scroll -= self
+ .input_line
+ .chars()
+ .nth(self.scroll)
+ .unwrap()
+ .width()
+ .unwrap_or(1);
+ }
+ }
+ }
+
+ fn inc_pos(&mut self, inc: usize) {
+ self.set_pos(self.pos + inc);
+ }
+
+ fn dec_pos(&mut self, dec: usize) {
+ self.set_pos(self.pos - dec);
+ }
+
+ fn pos_width(&self) -> u16 {
+ let start = self
+ .input_line
+ .char_indices()
+ .nth(self.scroll)
+ .map_or(self.input_line.len(), |(i, _)| i);
+ let end = self
+ .input_line
+ .char_indices()
+ .nth(self.pos)
+ .map_or(self.input_line.len(), |(i, _)| i);
+ self.input_line[start..end].width().try_into().unwrap()
+ }
+
+ fn byte_pos(&self) -> usize {
+ self.input_line
+ .char_indices()
+ .nth(self.pos)
+ .map_or(self.input_line.len(), |(i, _)| i)
+ }
+
+ fn visible_input(&self) -> &str {
+ let start = self
+ .input_line
+ .char_indices()
+ .nth(self.scroll)
+ .map_or(self.input_line.len(), |(i, _)| i);
+ let mut end = self.input_line.len();
+ let mut width = 0;
+ for (i, c) in self.input_line.char_indices().skip(self.scroll) {
+ if width >= usize::from(self.size.1) - 2 {
+ end = i;
+ break;
+ }
+ width += c.width().unwrap_or(1);
+ }
+ &self.input_line[start..end]
+ }
+}
diff --git a/src/state.rs b/src/state.rs
deleted file mode 100644
index c348d7f..0000000
--- a/src/state.rs
+++ /dev/null
@@ -1,181 +0,0 @@
-use textmode::Textmode as _;
-
-pub struct State {
- readline: crate::readline::Readline,
- history: crate::history::History,
- focus: Focus,
- escape: bool,
- hide_readline: bool,
-}
-
-impl State {
- pub fn new() -> Self {
- let readline = crate::readline::Readline::new();
- let history = crate::history::History::new();
- let focus = Focus::Readline;
- Self {
- readline,
- history,
- focus,
- escape: false,
- hide_readline: false,
- }
- }
-
- pub async fn handle_key(
- &mut self,
- key: textmode::Key,
- ) -> Option<crate::action::Action> {
- if self.escape {
- self.escape = false;
- let mut fallthrough = false;
- match key {
- textmode::Key::Ctrl(b'e') => {
- fallthrough = true;
- }
- textmode::Key::Ctrl(b'l') => {
- return Some(crate::action::Action::ForceRedraw);
- }
- textmode::Key::Char('f') => {
- if let Focus::History(idx) = self.focus {
- return Some(
- crate::action::Action::ToggleFullscreen(idx),
- );
- }
- }
- textmode::Key::Char('j') => {
- let new_focus = match self.focus {
- Focus::History(idx) => {
- if idx >= self.history.entry_count() - 1 {
- Focus::Readline
- } else {
- Focus::History(idx + 1)
- }
- }
- Focus::Readline => Focus::Readline,
- };
- return Some(crate::action::Action::UpdateFocus(
- new_focus,
- ));
- }
- textmode::Key::Char('k') => {
- let new_focus = match self.focus {
- Focus::History(idx) => {
- if idx == 0 {
- Focus::History(0)
- } else {
- Focus::History(idx - 1)
- }
- }
- Focus::Readline => {
- Focus::History(self.history.entry_count() - 1)
- }
- };
- return Some(crate::action::Action::UpdateFocus(
- new_focus,
- ));
- }
- textmode::Key::Char('r') => {
- return Some(crate::action::Action::UpdateFocus(
- Focus::Readline,
- ));
- }
- _ => {}
- }
- if !fallthrough {
- return None;
- }
- } else if key == textmode::Key::Ctrl(b'e') {
- self.escape = true;
- return None;
- }
-
- match self.focus {
- Focus::Readline => self.readline.handle_key(key).await,
- Focus::History(idx) => {
- self.history.handle_key(key, idx).await;
- None
- }
- }
- }
-
- pub async fn render(
- &self,
- out: &mut textmode::Output,
- hard: bool,
- ) -> anyhow::Result<()> {
- out.clear();
- match self.focus {
- Focus::Readline => {
- self.history
- .render(out, self.readline.lines(), None)
- .await?;
- self.readline.render(out, true).await?;
- }
- Focus::History(idx) => {
- if self.hide_readline || self.history.is_fullscreen(idx).await
- {
- self.history.render(out, 0, Some(idx)).await?;
- } else {
- self.history
- .render(out, self.readline.lines(), Some(idx))
- .await?;
- let pos = out.screen().cursor_position();
- self.readline.render(out, false).await?;
- out.move_to(pos.0, pos.1);
- }
- }
- }
- if hard {
- out.hard_refresh().await?;
- } else {
- out.refresh().await?;
- }
- Ok(())
- }
-
- pub async fn handle_action(
- &mut self,
- action: crate::action::Action,
- out: &mut textmode::Output,
- action_w: &async_std::channel::Sender<crate::action::Action>,
- ) {
- let mut hard_refresh = false;
- match action {
- crate::action::Action::Render => {}
- crate::action::Action::ForceRedraw => {
- hard_refresh = true;
- }
- crate::action::Action::Run(ref cmd) => {
- let idx =
- self.history.run(cmd, action_w.clone()).await.unwrap();
- self.focus = Focus::History(idx);
- self.hide_readline = true;
- }
- crate::action::Action::UpdateFocus(new_focus) => {
- self.focus = new_focus;
- self.hide_readline = false;
- }
- crate::action::Action::ToggleFullscreen(idx) => {
- self.history.toggle_fullscreen(idx).await;
- }
- crate::action::Action::Resize(new_size) => {
- self.readline.resize(new_size).await;
- self.history.resize(new_size).await;
- out.set_size(new_size.0, new_size.1);
- out.hard_refresh().await.unwrap();
- }
- crate::action::Action::Quit => {
- // the debouncer should return None in this case
- unreachable!();
- }
- }
- self.render(out, hard_refresh).await.unwrap();
- }
-}
-
-#[derive(Copy, Clone, Debug)]
-pub enum Focus {
- Readline,
- History(usize),
-}
diff --git a/src/util.rs b/src/util.rs
deleted file mode 100644
index d792b91..0000000
--- a/src/util.rs
+++ /dev/null
@@ -1,5 +0,0 @@
-pub type Mutex<T> = async_std::sync::Arc<async_std::sync::Mutex<T>>;
-
-pub fn mutex<T>(t: T) -> Mutex<T> {
- async_std::sync::Arc::new(async_std::sync::Mutex::new(t))
-}