diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..d864174 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,6 @@ +{ + "image": "mcr.microsoft.com/devcontainers/universal:2", + "features": { + "ghcr.io/devcontainers/features/rust:1": {} + } +} diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 0000000..62dc202 --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,23 @@ +name: coverage + +on: [push] +jobs: + test: + name: coverage + runs-on: ubuntu-latest + container: + image: xd009642/tarpaulin:develop-nightly + options: --security-opt seccomp=unconfined + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + - name: Generate code coverage + run: | + cargo +nightly tarpaulin --verbose --all-features --workspace --timeout 120 --out xml + + - name: Upload to codecov.io + uses: codecov/codecov-action@v2 + with: + token: ${{secrets.CODECOV_TOKEN}} + fail_ci_if_error: true diff --git a/.github/workflows/rust-linting.yml b/.github/workflows/rust-linting.yml index dc51ad6..bff4715 100644 --- a/.github/workflows/rust-linting.yml +++ b/.github/workflows/rust-linting.yml @@ -41,6 +41,8 @@ jobs: toolchain: stable profile: minimal override: true + + - uses: Swatinem/rust-cache@v2 - name: Run cargo clippy run: cargo clippy --all-targets --workspace -- -D warnings diff --git a/.github/workflows/rust-tests.yml b/.github/workflows/rust-tests.yml index 5cf3a77..729109b 100644 --- a/.github/workflows/rust-tests.yml +++ b/.github/workflows/rust-tests.yml @@ -14,7 +14,10 @@ on: jobs: tests: - runs-on: ubuntu-latest + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} steps: - name: Checkout code uses: actions/checkout@v4 @@ -25,7 +28,8 @@ jobs: toolchain: stable profile: minimal override: true + + - uses: Swatinem/rust-cache@v2 - name: Run tests - # TODO: run on whole workspace with "cargo test --workspace --all-targets" - run: cargo test --manifest-path crates/shell/Cargo.toml --all-targets + run: cargo test --workspace --all-targets diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..71149ed --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,102 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "lldb", + "request": "launch", + "name": "Debug unit tests in library 'deno_task_shell'", + "cargo": { + "args": [ + "test", + "--no-run", + "--lib", + "--package=deno_task_shell" + ], + "filter": { + "name": "deno_task_shell", + "kind": "lib" + } + }, + "args": [], + "cwd": "${workspaceFolder}" + }, + { + "type": "lldb", + "request": "launch", + "name": "Debug unit tests in library 'shell'", + "cargo": { + "args": [ + "test", + "--no-run", + "--lib", + "--package=shell" + ], + "filter": { + "name": "shell", + "kind": "lib" + } + }, + "args": [], + "cwd": "${workspaceFolder}" + }, + { + "type": "lldb", + "request": "launch", + "name": "Debug executable 'shell'", + "cargo": { + "args": [ + "build", + "--bin=shell", + "--package=shell" + ], + "filter": { + "name": "shell", + "kind": "bin" + } + }, + "args": [], + "cwd": "${workspaceFolder}" + }, + { + "type": "lldb", + "request": "launch", + "name": "Debug unit tests in executable 'shell'", + "cargo": { + "args": [ + "test", + "--no-run", + "--bin=shell", + "--package=shell" + ], + "filter": { + "name": "shell", + "kind": "bin" + } + }, + "args": [], + "cwd": "${workspaceFolder}" + }, + { + "type": "lldb", + "request": "launch", + "name": "Debug unit tests in library 'tests'", + "cargo": { + "args": [ + "test", + "--no-run", + "--lib", + "--package=tests" + ], + "filter": { + "name": "tests", + "kind": "lib" + } + }, + "args": [], + "cwd": "${workspaceFolder}" + } + ] +} \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 5ee11c6..cdf93a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,18 +4,27 @@ version = 3 [[package]] name = "addr2line" -version = "0.22.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e4503c46a5c0c7844e948c9a4d6acd9f50cccb4de1c48eb9e291ea17470c678" +checksum = "f5fb1d8e4442bd405fdfd1dacb42792696b0cf9cb15882e5d097b742a676d375" dependencies = [ "gimli", ] [[package]] -name = "adler" -version = "1.0.2" +name = "adler2" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] [[package]] name = "android-tzdata" @@ -91,10 +100,16 @@ dependencies = [ ] [[package]] -name = "anyhow" -version = "1.0.86" +name = "arrayvec" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "ascii_tree" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca6c635b3aa665c649ad1415f1573c85957dfa47690ec27aebe7ec17efe3c643" [[package]] name = "autocfg" @@ -104,17 +119,26 @@ checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" [[package]] name = "backtrace" -version = "0.3.73" +version = "0.3.74" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cc23269a4f8976d0a4d2e7109211a419fe30e8d88d677cd60b6bc79c5732e0a" +checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" dependencies = [ "addr2line", - "cc", "cfg-if", "libc", "miniz_oxide", "object", "rustc-demangle", + "windows-targets 0.52.6", +] + +[[package]] +name = "backtrace-ext" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" +dependencies = [ + "backtrace", ] [[package]] @@ -146,9 +170,9 @@ checksum = "8318a53db07bb3f8dca91a600466bdb3f2eaadeedfdbcf02e1accbad9271ba50" [[package]] name = "cc" -version = "1.1.16" +version = "1.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9d013ecb737093c0e86b151a7b837993cf9ec6c502946cfb44bedc392421e0b" +checksum = "2d74707dde2ba56f86ae90effb3b43ddd369504387e718014de010cec7959800" dependencies = [ "shlex", ] @@ -165,6 +189,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.38" @@ -173,7 +203,9 @@ checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" dependencies = [ "android-tzdata", "iana-time-zone", + "js-sys", "num-traits", + "wasm-bindgen", "windows-targets 0.52.6", ] @@ -258,18 +290,30 @@ dependencies = [ "typenum", ] +[[package]] +name = "ctrlc" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90eeab0aa92f3f9b4e87f258c72b139c207d251f9cbc1080a0086b86a8870dd3" +dependencies = [ + "nix 0.29.0", + "windows-sys 0.59.0", +] + [[package]] name = "deno_task_shell" version = "0.17.0" dependencies = [ - "anyhow", "dirs", "futures", "glob", + "lazy_static", + "miette", "os_pipe", "parking_lot", "path-dedot", - "pest", + "pest 2.7.12 (git+https://github.com/pest-parser/pest.git?branch=master)", + "pest_ascii_tree", "pest_derive", "pretty_assertions", "serde", @@ -317,6 +361,18 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "dtparse" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23fb403c0926d35af2cc54d961bc2696a10d40725c08360ef69db04a4c201fd7" +dependencies = [ + "chrono", + "lazy_static", + "num-traits", + "rust_decimal", +] + [[package]] name = "dunce" version = "1.0.5" @@ -347,9 +403,15 @@ dependencies = [ [[package]] name = "error-code" -version = "3.2.0" +version = "3.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0474425d51df81997e2f90a21591180b38eccf27292d755f3e30750225c175b" +checksum = "a5d9305ccc6942a704f4335694ecd3de2ea531b114ac2d51f5f843750787a92f" + +[[package]] +name = "escape_string" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59e867569975c88fdf73833a30bd6e0978aa6ab6bd784b648fcece07450951ba" [[package]] name = "fastrand" @@ -368,6 +430,18 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "filetime" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35c0522e981e68cbfa8c3f978441a5f34b30b96e146b33cd3359176b50fe8586" +dependencies = [ + "cfg-if", + "libc", + "libredox", + "windows-sys 0.59.0", +] + [[package]] name = "futures" version = "0.3.30" @@ -480,9 +554,9 @@ dependencies = [ [[package]] name = "gimli" -version = "0.29.0" +version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ecd4077b5ae9fd2e9e169b102c6c330d0605168eb0e8bf79952b256dbefffd" +checksum = "32085ea23f3234fc7846555e85283ba4de91e21016dc0455a16286d87a292d64" [[package]] name = "glob" @@ -524,9 +598,9 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.60" +version = "0.1.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7ffbb5a1b541ea2561f8c41c087286cc091e21e556a4f09a8f6cbf17b69b141" +checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -545,6 +619,12 @@ dependencies = [ "cc", ] +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -575,6 +655,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.158" @@ -589,6 +675,7 @@ checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" dependencies = [ "bitflags", "libc", + "redox_syscall", ] [[package]] @@ -628,13 +715,50 @@ version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +[[package]] +name = "miette" +version = "7.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4edc8853320c2a0dab800fbda86253c8938f6ea88510dc92c5f1ed20e794afc1" +dependencies = [ + "backtrace", + "backtrace-ext", + "cfg-if", + "miette-derive", + "owo-colors", + "supports-color", + "supports-hyperlinks", + "supports-unicode", + "terminal_size", + "textwrap", + "thiserror", + "unicode-width", +] + +[[package]] +name = "miette-derive" +version = "7.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcf09caffaac8068c346b6df2a7fc27a177fd20b39421a39ce0a211bde679a6c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" -version = "0.7.4" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8a240ddb74feaf34a79a7add65a741f3167852fba007066dcac1ca548d89c08" +checksum = "e2d80299ef12ff69b16a84bb182e3b9df68b5a91574d3d4fa6e41b65deec4df1" dependencies = [ - "adler", + "adler2", ] [[package]] @@ -666,10 +790,32 @@ checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" dependencies = [ "bitflags", "cfg-if", - "cfg_aliases", + "cfg_aliases 0.1.1", + "libc", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases 0.2.1", "libc", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nu-ansi-term" version = "0.49.0" @@ -734,6 +880,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "owo-colors" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb37767f6569cd834a413442455e0f066d0d522de8630436e2a1761d9726ba56" + [[package]] name = "parking_lot" version = "0.12.3" @@ -757,6 +909,17 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "parse_datetime" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8720474e3dd4af20cea8716703498b9f3b690f318fa9d9d9e2e38eaf44b96d0" +dependencies = [ + "chrono", + "nom", + "regex", +] + [[package]] name = "path-dedot" version = "3.1.1" @@ -777,13 +940,37 @@ dependencies = [ "ucd-trie", ] +[[package]] +name = "pest" +version = "2.7.12" +source = "git+https://github.com/pest-parser/pest.git?branch=master#65e5b2b754a4d9bb9b231ab61ef0af437b556fbe" +dependencies = [ + "memchr", + "miette", + "serde", + "serde_json", + "thiserror", + "ucd-trie", +] + +[[package]] +name = "pest_ascii_tree" +version = "0.1.0" +source = "git+https://github.com/prsabahrami/pest_ascii_tree.git?branch=master#65ef51a888730598336b0628c3e42bc5ec2f358b" +dependencies = [ + "ascii_tree", + "escape_string", + "pest 2.7.12 (git+https://github.com/pest-parser/pest.git?branch=master)", + "pest_derive", +] + [[package]] name = "pest_derive" version = "2.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "664d22978e2815783adbdd2c588b455b1bd625299ce36b2a99881ac9627e6d8d" dependencies = [ - "pest", + "pest 2.7.12 (registry+https://github.com/rust-lang/crates.io-index)", "pest_generator", ] @@ -793,7 +980,7 @@ version = "2.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2d5487022d5d33f4c30d91c22afa240ce2a644e87fe08caad974d4eab6badbe" dependencies = [ - "pest", + "pest 2.7.12 (registry+https://github.com/rust-lang/crates.io-index)", "pest_meta", "proc-macro2", "quote", @@ -807,7 +994,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0091754bbd0ea592c4deb3a122ce8ecbb0753b738aa82bc055fcc2eccc8d8174" dependencies = [ "once_cell", - "pest", + "pest 2.7.12 (registry+https://github.com/rust-lang/crates.io-index)", "sha2", ] @@ -823,11 +1010,21 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "platform-info" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5ff316b9c4642feda973c18f0decd6c8b0919d4722566f6e4337cce0dd88217" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "pretty_assertions" -version = "1.4.0" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af7cee1a6c8a5b9208b3cb1061f10c0cb689087b3d8ce85fb9d2dd7a29b6ba66" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" dependencies = [ "diff", "yansi", @@ -863,9 +1060,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.3" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a908a6e00f1fdd0dfd9c0eb08ce85126f6d8bbda50017e74bc4a4b7d4a926a4" +checksum = "0884ad60e090bf1345b93da0a5de8923c93884cd03f40dfcfddd3b4bee661853" dependencies = [ "bitflags", ] @@ -881,6 +1078,45 @@ dependencies = [ "thiserror", ] +[[package]] +name = "regex" +version = "1.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4219d74c6b67a3654a9fbebc4b419e22126d13d2f3c4a07ee0cb61ff79a79619" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" + +[[package]] +name = "rust_decimal" +version = "1.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b082d80e3e3cc52b2ed634388d436fe1f4de6af5786cc2de9ba9737527bdf555" +dependencies = [ + "arrayvec", + "num-traits", +] + [[package]] name = "rustc-demangle" version = "0.1.24" @@ -889,9 +1125,9 @@ checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" [[package]] name = "rustix" -version = "0.38.36" +version = "0.38.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f55e80d50763938498dd5ebb18647174e0c76dc38c5505294bb224624f30f36" +checksum = "8acb788b847c24f28525660c4d7758620a7210875711f79e7f663cc152726811" dependencies = [ "bitflags", "errno", @@ -914,14 +1150,26 @@ dependencies = [ "libc", "log", "memchr", - "nix", + "nix 0.28.0", "radix_trie", + "rustyline-derive", "unicode-segmentation", "unicode-width", "utf8parse", "windows-sys 0.52.0", ] +[[package]] +name = "rustyline-derive" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5af959c8bf6af1aff6d2b463a57f71aae53d1332da58419e30ad8dc7011d951" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "ryu" version = "1.0.18" @@ -981,15 +1229,25 @@ dependencies = [ name = "shell" version = "0.1.0" dependencies = [ - "anyhow", + "chrono", "clap", + "ctrlc", "deno_task_shell", "dirs", + "dtparse", + "filetime", "futures", + "miette", + "parse_datetime", "rustyline", "serde_json", "tokio", + "uu_date", "uu_ls", + "uu_touch", + "uu_uname", + "which", + "windows-sys 0.59.0", ] [[package]] @@ -1022,12 +1280,49 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" +[[package]] +name = "smawk" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" + +[[package]] +name = "socket2" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "supports-color" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8775305acf21c96926c900ad056abeef436701108518cf890020387236ac5a77" +dependencies = [ + "is_ci", +] + +[[package]] +name = "supports-hyperlinks" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c0a1e5168041f5f3ff68ff7d95dcb9c8749df29f6e7e89ada40dd4c9de404ee" + +[[package]] +name = "supports-unicode" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" + [[package]] name = "syn" version = "2.0.77" @@ -1062,20 +1357,45 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "tests" +version = "0.1.0" +dependencies = [ + "deno_task_shell", + "dirs", + "futures", + "miette", + "pretty_assertions", + "shell", + "tempfile", + "tokio", +] + +[[package]] +name = "textwrap" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d434d3f8967a09480fb04132ebe0a3e088c173e6d0ee7897abbdf4eab0f8b9" +dependencies = [ + "smawk", + "unicode-linebreak", + "unicode-width", +] + [[package]] name = "thiserror" -version = "1.0.63" +version = "1.0.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724" +checksum = "d50af8abc119fb8bb6dbabcfa89656f46f84aa0ac7688088608076ad2b459a84" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.63" +version = "1.0.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261" +checksum = "08904e7672f5eb876eaaf87e0ce17857500934f4981c4a0ab2b4aa98baac7fc3" dependencies = [ "proc-macro2", "quote", @@ -1092,8 +1412,10 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", "signal-hook-registry", + "socket2", "tokio-macros", "windows-sys 0.52.0", ] @@ -1136,15 +1458,21 @@ checksum = "ed646292ffc8188ef8ea4d1e0e0150fb15a5c2e12ad9b8fc191ae7a8a7f3c4b9" [[package]] name = "unicode-ident" -version = "1.0.12" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" [[package]] name = "unicode-segmentation" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" [[package]] name = "unicode-width" @@ -1158,6 +1486,20 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uu_date" +version = "0.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09b64040b532d67a399c8670da5c5ba12bf38ee8b9d845330580f3ba7b1eabc7" +dependencies = [ + "chrono", + "clap", + "libc", + "parse_datetime", + "uucore", + "windows-sys 0.48.0", +] + [[package]] name = "uu_ls" version = "0.0.27" @@ -1177,6 +1519,31 @@ dependencies = [ "uutils_term_grid", ] +[[package]] +name = "uu_touch" +version = "0.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07698cd67da84b138369eeed24bee77c860b8b22021581a56edd31c4697dd61d" +dependencies = [ + "chrono", + "clap", + "filetime", + "parse_datetime", + "uucore", + "windows-sys 0.48.0", +] + +[[package]] +name = "uu_uname" +version = "0.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad1ca90f9b292bccaad0de70e6feccac5182c6713a5e1ca72d97bf3555b608b4" +dependencies = [ + "clap", + "platform-info", + "uucore", +] + [[package]] name = "uucore" version = "0.0.27" @@ -1188,7 +1555,7 @@ dependencies = [ "glob", "itertools", "libc", - "nix", + "nix 0.28.0", "number_prefix", "once_cell", "os_display", @@ -1292,6 +1659,18 @@ version = "0.2.93" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c62a0a307cb4a311d3a07867860911ca130c3494e8c2719593806c08bc5d0484" +[[package]] +name = "which" +version = "6.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ee928febd44d98f2f459a4a79bd4d928591333a494a10a868418ac1b39cf1f" +dependencies = [ + "either", + "home", + "rustix", + "winsafe", +] + [[package]] name = "wild" version = "2.2.1" @@ -1301,6 +1680,22 @@ dependencies = [ "glob", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[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.9" @@ -1310,6 +1705,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[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 = "windows" version = "0.52.0" @@ -1477,6 +1878,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + [[package]] name = "xattr" version = "1.3.1" @@ -1490,6 +1897,6 @@ dependencies = [ [[package]] name = "yansi" -version = "0.5.1" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" diff --git a/Cargo.toml b/Cargo.toml index 42367f8..e2047f6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,4 +8,4 @@ homepage = "https://github.com/prefix-dev/shell" repository = "https://github.com/prefix-dev/shell" license = "BSD-3-Clause" edition = "2021" -readme = "README.md" +readme = "README.md" \ No newline at end of file diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..0ab7dce --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,19 @@ +Copyright (c) 2024 prefix.dev GmbH + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE +OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index 689cd54..216f3fd 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,42 @@ -# shell - cross-platform bash compatible shell +![image](https://github.com/user-attachments/assets/74ad3cdd-9890-4b41-b42f-7eaed269f505) + +# 🦀 shell - fast, cross-platform Bash compatible shell 🚀 + +This shell looks and feels like bash, but works **natively on Windows** (and macOS / Linux)! No emulation needed. + +The idea of the `shell` project is to build a cross-platform shell that looks and feels similar to bash (while not claiming to be 100% bash compatible). The `shell` allows you to use platform specific native operations (e.g. `cd 'C:\Program Files (x86)'` on Windows), but it also allows you to use a platform-independent strict subset of bash which enables writing build scripts and instructions that work on all platforms. + +The project is written in Rust. + +The most common bash commands are implemented and we are linking with the `coreutils` crate to provide the most important Unix commands in a cross-platform, memory safe way (such as `mv`, `cp`, `ls`, `cat`, etc.). + +This new shell also already has _tab completion_ for files and directories, and _history_ support thanks to `rustyline`. + +The project is still very early alpha stage but can already be used as a daily +driver on all platforms. + +## Screenshots + +macOS: + +[](https://github.com/user-attachments/assets/7f5c72ed-2bce-4f64-8a53-792d153cf574) + +Windows: + +![Windows](https://github.com/user-attachments/assets/6982534c-066e-4b26-a1ec-b11cea7a3ffb) ## How to run this -To compile and run the project, you need to have Rust installed. +To compile and run the project, you need to have Rust & Cargo installed. ```bash -cargo r -- ./scripts/script_1.sh -cargo r -- ./scripts/script_2.sh -``` \ No newline at end of file +# To start an interactive shell +cargo r + +# To run a script +cargo r -- ./scripts/hello_world.sh +``` + +## License + +The project is licensed under the MIT License. It is an extension of the existing `deno_task_shell` project (also licensed under the MIT License, by the authors of `deno`). diff --git a/crates/deno_task_shell/Cargo.toml b/crates/deno_task_shell/Cargo.toml index 4d192a0..778b834 100644 --- a/crates/deno_task_shell/Cargo.toml +++ b/crates/deno_task_shell/Cargo.toml @@ -15,21 +15,23 @@ shell = ["futures", "glob", "os_pipe", "path-dedot", "tokio", "tokio-util"] serialization = ["serde"] [dependencies] -anyhow = "1.0.75" -futures = { version = "0.3.29", optional = true } +futures = { version = "0.3.30", optional = true } glob = { version = "0.3.1", optional = true } path-dedot = { version = "3.1.1", optional = true } tokio = { version = "1", features = ["fs", "io-std", "io-util", "macros", "process", "rt-multi-thread", "sync", "time"], optional = true } -tokio-util = { version = "0.7.10", optional = true } -os_pipe = { version = "1.1.4", optional = true } +tokio-util = { version = "0.7.12", optional = true } +os_pipe = { version = "1.2.1", optional = true } serde = { version = "1", features = ["derive"], optional = true } -thiserror = "1.0.58" -pest = "2.6.0" -pest_derive = "2.6.0" +thiserror = "1.0.63" +pest = { git = "https://github.com/pest-parser/pest.git", branch = "master", features = ["miette-error"] } +pest_derive = "2.7.12" dirs = "5.0.1" +pest_ascii_tree = { git = "https://github.com/prsabahrami/pest_ascii_tree.git", branch = "master" } +miette = { version = "7.2.0", features = ["fancy"] } +lazy_static = "1.4.0" [dev-dependencies] -parking_lot = "0.12.1" -pretty_assertions = "1" -serde_json = "1.0.111" -tempfile = "3.8.1" +tempfile = "3.12.0" +parking_lot = "0.12.3" +serde_json = "1.0.128" +pretty_assertions = "1.0.0" diff --git a/crates/deno_task_shell/src/grammar.pest b/crates/deno_task_shell/src/grammar.pest index 600cedd..abc6947 100644 --- a/crates/deno_task_shell/src/grammar.pest +++ b/crates/deno_task_shell/src/grammar.pest @@ -3,45 +3,89 @@ // Whitespace and comments WHITESPACE = _{ " " | "\t" | ("\\" ~ WHITESPACE* ~ NEWLINE) } COMMENT = _{ "#" ~ (!NEWLINE ~ ANY)* } +NUMBER = @{ INT ~ ("." ~ ASCII_DIGIT*)? ~ (^"e" ~ INT)? } +INT = { ("+" | "-")? ~ ASCII_DIGIT+ } // Basic tokens QUOTED_WORD = { DOUBLE_QUOTED | SINGLE_QUOTED } -UNQUOTED_PENDING_WORD = ${ - (!(WHITESPACE | OPERATOR | NEWLINE) ~ ( +UNQUOTED_PENDING_WORD = ${ + (TILDE_PREFIX ~ (!(OPERATOR | WHITESPACE | NEWLINE) ~ ( EXIT_STATUS | UNQUOTED_ESCAPE_CHAR | + "$" ~ ARITHMETIC_EXPRESSION | SUB_COMMAND | - ("$" ~ VARIABLE) | + ("$" ~ "{" ~ VARIABLE ~ "}" | "$" ~ VARIABLE) | UNQUOTED_CHAR | QUOTED_WORD - ) -)+ } + ))*) + | + (!(OPERATOR | WHITESPACE | NEWLINE) ~ ( + EXIT_STATUS | + UNQUOTED_ESCAPE_CHAR | + "$" ~ ARITHMETIC_EXPRESSION | + SUB_COMMAND | + ("$" ~ "{" ~ VARIABLE ~ "}" | "$" ~ VARIABLE) | + UNQUOTED_CHAR | + QUOTED_WORD + ))+ +} -FILE_NAME_PENDING_WORD = ${ (!(WHITESPACE | OPERATOR | NEWLINE) ~ (UNQUOTED_ESCAPE_CHAR | ("$" ~ VARIABLE) | UNQUOTED_CHAR | QUOTED_WORD))+ } +TILDE_PREFIX = ${ + "~" ~ (!(OPERATOR | WHITESPACE | NEWLINE | "/") ~ ( + (!("\"" | "'" | "$" | "\\" | "/") ~ ANY) + ))* +} + +ASSIGNMENT_TILDE_PREFIX = ${ + "~" ~ (!(OPERATOR | WHITESPACE | NEWLINE | "/" | ":") ~ + (!("\"" | "'" | "$" | "\\" | "/") ~ ANY) + )* +} + +FILE_NAME_PENDING_WORD = ${ + (TILDE_PREFIX ~ (!(WHITESPACE | OPERATOR | NEWLINE) ~ ( + UNQUOTED_ESCAPE_CHAR | + ("$" ~ VARIABLE) | + UNQUOTED_CHAR | + QUOTED_WORD + ))*) + | + (!(WHITESPACE | OPERATOR | NEWLINE) ~ ( + UNQUOTED_ESCAPE_CHAR | + ("$" ~ VARIABLE) | + UNQUOTED_CHAR | + QUOTED_WORD + ))+ +} QUOTED_PENDING_WORD = ${ ( EXIT_STATUS | QUOTED_ESCAPE_CHAR | SUB_COMMAND | - ("$" ~ VARIABLE) | + ("$" ~ "{" ~ VARIABLE ~ "}" | "$" ~ VARIABLE) | QUOTED_CHAR )* } -UNQUOTED_ESCAPE_CHAR = ${ ("\\" ~ "$" | "$" ~ !"(" ~ !VARIABLE) | "\\" ~ (" " | "`" | "\"" | "(" | ")")* } -QUOTED_ESCAPE_CHAR = ${ "\\" ~ "$" | "$" ~ !"(" ~ !VARIABLE | "\\" ~ ("`" | "\"" | "(" | ")" | "'")* } +UNQUOTED_ESCAPE_CHAR = ${ ("\\" ~ "$" | "$" ~ !"(" ~ !"{" ~ !VARIABLE) | "\\" ~ (" " | "`" | "\"" | "(" | ")")* } +QUOTED_ESCAPE_CHAR = ${ "\\" ~ "$" | "$" ~ !"(" ~ !"{" ~ !VARIABLE | "\\" ~ ("`" | "\"" | "(" | ")" | "'")* } -UNQUOTED_CHAR = ${ ("\\" ~ " ") | !("(" | ")" | "{" | "}" | "<" | ">" | "|" | "&" | ";" | "\"" | "'" | "$") ~ ANY } +UNQUOTED_CHAR = ${ ("\\" ~ " ") | !("]]" | "[[" | "(" | ")" | "<" | ">" | "|" | "&" | ";" | "\"" | "'" | "$") ~ ANY } QUOTED_CHAR = ${ !"\"" ~ ANY } -VARIABLE = ${ (ASCII_ALPHANUMERIC | "_")+ } -SUB_COMMAND = { "$(" ~ complete_command ~ ")" } +VARIABLE = ${ (ASCII_ALPHA | "_") ~ (ASCII_ALPHANUMERIC | "_")* } +SUB_COMMAND = { "$(" ~ complete_command ~ ")"} DOUBLE_QUOTED = @{ "\"" ~ QUOTED_PENDING_WORD ~ "\"" } SINGLE_QUOTED = @{ "'" ~ (!"'" ~ ANY)* ~ "'" } NAME = ${ (ASCII_ALPHA | "_") ~ (ASCII_ALPHANUMERIC | "_")* } -ASSIGNMENT_WORD = { NAME ~ "=" ~ UNQUOTED_PENDING_WORD? } +ASSIGNMENT_WORD = ${ NAME ~ "=" ~ ASSIGNMENT_VALUE? } +ASSIGNMENT_VALUE = ${ + ASSIGNMENT_TILDE_PREFIX ~ + ((":" ~ ASSIGNMENT_TILDE_PREFIX) | (!":" ~ UNQUOTED_PENDING_WORD))* | + UNQUOTED_PENDING_WORD +} IO_NUMBER = @{ ASCII_DIGIT+ } // Special tokens @@ -60,19 +104,18 @@ CLOBBER = { ">|" } AMPERSAND = { "&" } EXIT_STATUS = ${ "$?" } - // Operators OPERATOR = _{ AND_IF | OR_IF | DSEMI | DLESS | DGREAT | LESSAND | GREATAND | LESSGREAT | DLESSDASH | CLOBBER | - "(" | ")" | "{" | "}" | ";" | "&" | "|" | "<" | ">" + "," |"(" | ")" | "{" | "}" | ";" | "&" | "|" | "<" | ">" } // Reserved words -If = { "if" } -Then = { "then" } +If = _{ "if" } +Then = _{ "then" } Else = { "else" } Elif = { "elif" } -Fi = { "fi" } +Fi = _{ "fi" } Do = { "do" } Done = { "done" } Case = { "case" } @@ -102,13 +145,14 @@ pipeline = !{ Bang? ~ pipe_sequence } pipe_sequence = !{ command ~ ((StdoutStderr | Stdout) ~ linebreak ~ pipe_sequence)? } command = !{ - simple_command | compound_command ~ redirect_list? | + simple_command | function_definition } compound_command = { brace_group | + ARITHMETIC_EXPRESSION | subshell | for_clause | case_clause | @@ -117,14 +161,93 @@ compound_command = { until_clause } -subshell = !{ "(" ~ compound_list ~ ")" } +ARITHMETIC_EXPRESSION = !{ "((" ~ arithmetic_sequence ~ "))" } +arithmetic_sequence = !{ arithmetic_expr ~ ("," ~ arithmetic_expr)* } +arithmetic_expr = { parentheses_expr | variable_assignment | triple_conditional_expr | binary_arithmetic_expr | binary_conditional_expression | unary_arithmetic_expr | VARIABLE | NUMBER } +parentheses_expr = !{ "(" ~ arithmetic_sequence ~ ")" } + +variable_assignment = !{ + VARIABLE ~ assignment_operator ~ arithmetic_expr +} + +triple_conditional_expr = !{ + (parentheses_expr | variable_assignment | binary_arithmetic_expr | binary_conditional_expression | unary_arithmetic_expr | VARIABLE | NUMBER) ~ + "?" ~ (parentheses_expr | variable_assignment | binary_arithmetic_expr | binary_conditional_expression | unary_arithmetic_expr | VARIABLE | NUMBER) ~ + ":" ~ (parentheses_expr | variable_assignment | binary_arithmetic_expr | binary_conditional_expression | unary_arithmetic_expr | VARIABLE | NUMBER) +} + +binary_arithmetic_expr = _{ + (parentheses_expr | binary_conditional_expression | unary_arithmetic_expr | variable_assignment | VARIABLE | NUMBER) ~ + (binary_arithmetic_op ~ + (parentheses_expr | variable_assignment | binary_conditional_expression | unary_arithmetic_expr | VARIABLE | NUMBER) + )+ +} + +binary_arithmetic_op = _{ + add | subtract | power | multiply | divide | modulo | left_shift | right_shift | + bitwise_and | bitwise_xor | bitwise_or | logical_and | logical_or +} + +add = { "+" } +subtract = { "-" } +multiply = { "*" } +divide = { "/" } +modulo = { "%" } +power = { "**" } +left_shift = { "<<" } +right_shift = { ">>" } +bitwise_and = { "&" } +bitwise_xor = { "^" } +bitwise_or = { "|" } +logical_and = { "&&" } +logical_or = { "||" } + +unary_arithmetic_expr = !{ + (unary_arithmetic_op | post_arithmetic_op) ~ (parentheses_expr | VARIABLE | NUMBER) | + (parentheses_expr | VARIABLE | NUMBER) ~ post_arithmetic_op +} + +unary_arithmetic_op = _{ + unary_plus | unary_minus | logical_not | bitwise_not +} + +unary_plus = { "+" } +unary_minus = { "-" } +logical_not = { "!" } +bitwise_not = { "~" } + +post_arithmetic_op = !{ + increment | decrement +} + +increment = { "++" } +decrement = { "--" } + +assignment_operator = _{ + assign | multiply_assign | divide_assign | modulo_assign | add_assign | subtract_assign | + left_shift_assign | right_shift_assign | bitwise_and_assign | bitwise_xor_assign | bitwise_or_assign +} + +assign = { "=" } +multiply_assign = { "*=" } +divide_assign = { "/=" } +modulo_assign = { "%=" } +add_assign = { "+=" } +subtract_assign = { "-=" } +left_shift_assign = { "<<=" } +right_shift_assign = { ">>=" } +bitwise_and_assign = { "&=" } +bitwise_xor_assign = { "^=" } +bitwise_or_assign = { "|=" } + +subshell = !{ "(" ~ compound_list ~ ")" } compound_list = !{ (newline_list? ~ term ~ separator?)+ } term = !{ and_or ~ (separator ~ and_or)* } for_clause = { For ~ name ~ linebreak ~ - (linebreak ~ In ~ wordlist? ~ sequential_sep)? ~ - linebreak ~ do_group + (In ~ (brace_group | wordlist)? ~ sequential_sep)? ~ + do_group } case_clause = !{ @@ -155,15 +278,55 @@ pattern = !{ } if_clause = !{ - If ~ compound_list ~ - Then ~ compound_list ~ - else_part? ~ - Fi + If ~ conditional_expression ~ + linebreak ~ Then ~ linebreak ~ complete_command ~ linebreak ~ + else_part? ~ linebreak ~ Fi } else_part = !{ - Elif ~ compound_list ~ Then ~ else_part | - Else ~ compound_list + Elif ~ conditional_expression ~ linebreak ~ Then ~ complete_command ~ linebreak ~ else_part? | + Else ~ linebreak ~ complete_command +} + +conditional_expression = !{ + ("[[" ~ (unary_conditional_expression | binary_conditional_expression | UNQUOTED_PENDING_WORD) ~ "]]" ~ ";"?) | + ("[" ~ (unary_conditional_expression | binary_conditional_expression | UNQUOTED_PENDING_WORD) ~ "]" ~ ";"?) | + ("test" ~ (unary_conditional_expression | binary_conditional_expression | UNQUOTED_PENDING_WORD)) +} + +unary_conditional_expression = !{ + file_conditional_op ~ FILE_NAME_PENDING_WORD | + variable_conditional_op ~ VARIABLE | + string_conditional_op ~ UNQUOTED_PENDING_WORD +} + +file_conditional_op = !{ + "-a" | "-b" | "-c" | "-d" | "-e" | "-f" | "-g" | "-h" | "-k" | + "-p" | "-r" | "-s" | "-u" | "-w" | "-x" | "-G" | "-L" | + "-N" | "-O" | "-S" +} + +variable_conditional_op = !{ + "-v" | "-R" +} + +string_conditional_op = !{ + "-n" | "-z" +} + +binary_conditional_expression = !{ + UNQUOTED_PENDING_WORD ~ ( + binary_bash_conditional_op | + binary_posix_conditional_op + ) ~ UNQUOTED_PENDING_WORD +} + +binary_bash_conditional_op = !{ + "==" | "=" | "!=" | "<" | ">" +} + +binary_posix_conditional_op = !{ + "-eq" | "-ne" | "-lt" | "-le" | "-gt" | "-ge" } while_clause = !{ While ~ compound_list ~ do_group } @@ -185,7 +348,7 @@ simple_command = !{ cmd_prefix = !{ (io_redirect | ASSIGNMENT_WORD)+ } cmd_suffix = !{ (io_redirect | UNQUOTED_PENDING_WORD)+ } -cmd_name = @{ (RESERVED_WORD | UNQUOTED_PENDING_WORD) } +cmd_name = @{ !RESERVED_WORD ~ UNQUOTED_PENDING_WORD } cmd_word = @{ (ASSIGNMENT_WORD | UNQUOTED_PENDING_WORD) } redirect_list = !{ io_redirect+ } diff --git a/crates/deno_task_shell/src/parser.rs b/crates/deno_task_shell/src/parser.rs index 27919df..c438ece 100644 --- a/crates/deno_task_shell/src/parser.rs +++ b/crates/deno_task_shell/src/parser.rs @@ -1,23 +1,28 @@ // Copyright 2018-2024 the Deno authors. MIT license. -use anyhow::{anyhow, Result}; +use lazy_static::lazy_static; +use miette::{miette, Context, Result}; use pest::iterators::Pair; +use pest::pratt_parser::{Assoc, Op, PrattParser}; use pest::Parser; use pest_derive::Parser; +use thiserror::Error; // Shell grammar rules this is loosely based on: // https://pubs.opengroup.org/onlinepubs/009604499/utilities/xcu_chap02.html#tag_02_10_02 #[cfg_attr(feature = "serialization", derive(serde::Serialize))] #[cfg_attr(feature = "serialization", serde(rename_all = "camelCase"))] -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Error)] +#[error("Invalid sequential list")] pub struct SequentialList { pub items: Vec, } #[cfg_attr(feature = "serialization", derive(serde::Serialize))] #[cfg_attr(feature = "serialization", serde(rename_all = "camelCase"))] -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Error)] +#[error("Invalid sequential list item")] pub struct SequentialListItem { pub is_async: bool, pub sequence: Sequence, @@ -28,21 +33,21 @@ pub struct SequentialListItem { feature = "serialization", serde(rename_all = "camelCase", tag = "kind") )] -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum Sequence { - /// `MY_VAR=5` + #[error("Invalid shell variable")] ShellVar(EnvVar), - /// `cmd_name `, `cmd1 | cmd2` + #[error("Invalid pipeline")] Pipeline(Pipeline), - /// `cmd1 && cmd2 || cmd3` + #[error("Invalid boolean list")] BooleanList(Box), } #[cfg_attr(feature = "serialization", derive(serde::Serialize))] #[cfg_attr(feature = "serialization", serde(rename_all = "camelCase"))] -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Error)] +#[error("Invalid pipeline")] pub struct Pipeline { - /// `! pipeline` pub negated: bool, pub inner: PipelineInner, } @@ -58,11 +63,11 @@ impl From for Sequence { feature = "serialization", serde(rename_all = "camelCase", tag = "kind") )] -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum PipelineInner { - /// Ex. `cmd_name ` + #[error("Invalid command")] Command(Command), - /// `cmd1 | cmd2` + #[error("Invalid pipe sequence")] PipeSequence(Box), } @@ -74,11 +79,11 @@ impl From for PipelineInner { #[cfg_attr(feature = "serialization", derive(serde::Serialize))] #[cfg_attr(feature = "serialization", serde(rename_all = "camelCase"))] -#[derive(Copy, Clone, Debug, PartialEq, Eq)] +#[derive(Copy, Clone, Debug, PartialEq, Eq, Error)] pub enum BooleanListOperator { - // && + #[error("AND operator")] And, - // || + #[error("OR operator")] Or, } @@ -98,7 +103,8 @@ impl BooleanListOperator { #[cfg_attr(feature = "serialization", derive(serde::Serialize))] #[cfg_attr(feature = "serialization", serde(rename_all = "camelCase"))] -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Error)] +#[error("Invalid boolean list")] pub struct BooleanList { pub current: Sequence, pub op: BooleanListOperator, @@ -107,17 +113,8 @@ pub struct BooleanList { #[cfg_attr(feature = "serialization", derive(serde::Serialize))] #[cfg_attr(feature = "serialization", serde(rename_all = "camelCase"))] -#[derive(Copy, Clone, Debug, PartialEq, Eq)] -pub enum PipeSequenceOperator { - // | - Stdout, - // |& - StdoutStderr, -} - -#[cfg_attr(feature = "serialization", derive(serde::Serialize))] -#[cfg_attr(feature = "serialization", serde(rename_all = "camelCase"))] -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Error)] +#[error("Invalid pipe sequence")] pub struct PipeSequence { pub current: Command, pub op: PipeSequenceOperator, @@ -135,7 +132,18 @@ impl From for Sequence { #[cfg_attr(feature = "serialization", derive(serde::Serialize))] #[cfg_attr(feature = "serialization", serde(rename_all = "camelCase"))] -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Copy, Clone, Debug, PartialEq, Eq, Error)] +pub enum PipeSequenceOperator { + #[error("Stdout pipe operator")] + Stdout, + #[error("Stdout and stderr pipe operator")] + StdoutStderr, +} + +#[cfg_attr(feature = "serialization", derive(serde::Serialize))] +#[cfg_attr(feature = "serialization", serde(rename_all = "camelCase"))] +#[derive(Debug, Clone, PartialEq, Eq, Error)] +#[error("Invalid command")] pub struct Command { pub inner: CommandInner, pub redirect: Option, @@ -146,12 +154,16 @@ pub struct Command { feature = "serialization", serde(rename_all = "camelCase", tag = "kind") )] -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum CommandInner { - /// `cmd_name ` + #[error("Invalid simple command")] Simple(SimpleCommand), - /// `(list)` + #[error("Invalid subshell")] Subshell(Box), + #[error("Invalid if command")] + If(IfClause), + #[error("Invalid arithmetic expression")] + ArithmeticExpression(Arithmetic), } impl From for Sequence { @@ -166,7 +178,8 @@ impl From for Sequence { #[cfg_attr(feature = "serialization", derive(serde::Serialize))] #[cfg_attr(feature = "serialization", serde(rename_all = "camelCase"))] -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Error)] +#[error("Invalid simple command")] pub struct SimpleCommand { pub env_vars: Vec, pub args: Vec, @@ -202,7 +215,94 @@ impl From for Sequence { #[cfg_attr(feature = "serialization", derive(serde::Serialize))] #[cfg_attr(feature = "serialization", serde(rename_all = "camelCase"))] -#[derive(Debug, PartialEq, Eq, Clone)] +#[derive(Debug, PartialEq, Eq, Clone, Error)] +#[error("Invalid if clause")] +pub struct IfClause { + pub condition: Condition, + pub then_body: SequentialList, + pub else_part: Option, +} + +#[cfg_attr(feature = "serialization", derive(serde::Serialize))] +#[cfg_attr(feature = "serialization", serde(rename_all = "camelCase"))] +#[derive(Debug, PartialEq, Eq, Clone, Error)] +#[error("Invalid else part")] +pub enum ElsePart { + Elif(Box), + Else(SequentialList), +} + +#[cfg_attr(feature = "serialization", derive(serde::Serialize))] +#[cfg_attr(feature = "serialization", serde(rename_all = "camelCase"))] +#[derive(Debug, PartialEq, Eq, Clone, Error)] +#[error("Invalid condition")] +pub struct Condition { + pub condition_inner: ConditionInner, +} + +#[cfg_attr(feature = "serialization", derive(serde::Serialize))] +#[cfg_attr(feature = "serialization", serde(rename_all = "camelCase"))] +#[derive(Debug, PartialEq, Eq, Clone, Error)] +#[error("Invalid condition inner")] +pub enum ConditionInner { + Binary { + left: Word, + op: BinaryOp, + right: Word, + }, + Unary { + op: Option, + right: Word, + }, +} + +#[cfg_attr(feature = "serialization", derive(serde::Serialize))] +#[cfg_attr(feature = "serialization", serde(rename_all = "camelCase"))] +#[derive(Debug, PartialEq, Eq, Clone, Error)] +#[error("Invalid binary operator")] +pub enum BinaryOp { + Equal, + NotEqual, + LessThan, + LessThanOrEqual, + GreaterThan, + GreaterThanOrEqual, +} + +#[cfg_attr(feature = "serialization", derive(serde::Serialize))] +#[cfg_attr(feature = "serialization", serde(rename_all = "camelCase"))] +#[derive(Debug, PartialEq, Eq, Clone, Error)] +#[error("Invalid unary operator")] +pub enum UnaryOp { + FileExists, + BlockSpecial, + CharSpecial, + Directory, + RegularFile, + SetGroupId, + SymbolicLink, + StickyBit, + NamedPipe, + Readable, + SizeNonZero, + TerminalFd, + SetUserId, + Writable, + Executable, + OwnedByEffectiveGroupId, + ModifiedSinceLastRead, + OwnedByEffectiveUserId, + Socket, + NonEmptyString, + EmptyString, + VariableSet, + VariableNameReference, +} + +#[cfg_attr(feature = "serialization", derive(serde::Serialize))] +#[cfg_attr(feature = "serialization", serde(rename_all = "camelCase"))] +#[derive(Debug, PartialEq, Eq, Clone, Error)] +#[error("Invalid environment variable")] pub struct EnvVar { pub name: String, pub value: Word, @@ -215,7 +315,26 @@ impl EnvVar { } #[cfg_attr(feature = "serialization", derive(serde::Serialize))] -#[derive(Debug, PartialEq, Eq, Clone)] +#[cfg_attr(feature = "serialization", serde(rename_all = "camelCase"))] +#[derive(Debug, PartialEq, Eq, Clone, Error)] +#[error("Invalid tilde prefix")] +pub struct TildePrefix { + pub user: Option, +} + +impl TildePrefix { + pub fn only_tilde(self) -> bool { + self.user.is_none() + } + + pub fn new(user: Option) -> Self { + TildePrefix { user } + } +} + +#[cfg_attr(feature = "serialization", derive(serde::Serialize))] +#[derive(Debug, PartialEq, Eq, Clone, Error)] +#[error("Invalid word")] pub struct Word(Vec); impl Word { @@ -251,16 +370,130 @@ impl Word { feature = "serialization", serde(rename_all = "camelCase", tag = "kind", content = "value") )] -#[derive(Debug, PartialEq, Eq, Clone)] +#[derive(Debug, PartialEq, Eq, Clone, Error)] pub enum WordPart { - /// Text in the string (ex. `some text`) + #[error("Invalid text")] Text(String), - /// Variable substitution (ex. `$MY_VAR`) + #[error("Invalid variable")] Variable(String), - /// Command substitution (ex. `$(command)`) + #[error("Invalid command")] Command(SequentialList), - /// Quoted string (ex. `"hello"` or `'test'`) + #[error("Invalid quoted string")] Quoted(Vec), + #[error("Invalid tilde prefix")] + Tilde(TildePrefix), + #[error("Invalid arithmetic expression")] + Arithmetic(Arithmetic), + #[error("Invalid exit status")] + ExitStatus, +} + +#[cfg_attr(feature = "serialization", derive(serde::Serialize))] +#[cfg_attr(feature = "serialization", serde(rename_all = "camelCase"))] +#[derive(Debug, Clone, PartialEq, Eq, Error)] +#[error("Invalid arithmetic sequence")] +pub struct Arithmetic { + pub parts: Vec, +} +#[cfg_attr(feature = "serialization", derive(serde::Serialize))] +#[cfg_attr(feature = "serialization", serde(rename_all = "camelCase"))] +#[derive(Debug, Clone, PartialEq, Eq, Error)] +#[error("Invalid arithmetic part")] +pub enum ArithmeticPart { + #[error("Invalid parentheses expression")] + ParenthesesExpr(Box), + #[error("Invalid variable assignment")] + VariableAssignment { + name: String, + op: AssignmentOp, + value: Box, + }, + #[error("Invalid triple conditional expression")] + TripleConditionalExpr { + condition: Box, + true_expr: Box, + false_expr: Box, + }, + #[error("Invalid binary arithmetic expression")] + BinaryArithmeticExpr { + left: Box, + operator: BinaryArithmeticOp, + right: Box, + }, + #[error("Invalid binary conditional expression")] + BinaryConditionalExpr { + left: Box, + operator: BinaryOp, + right: Box, + }, + #[error("Invalid unary arithmetic expression")] + UnaryArithmeticExpr { + operator: UnaryArithmeticOp, + operand: Box, + }, + #[error("Invalid post arithmetic expression")] + PostArithmeticExpr { + operand: Box, + operator: PostArithmeticOp, + }, + #[error("Invalid variable")] + Variable(String), + #[error("Invalid number")] + Number(String), +} + +#[cfg_attr(feature = "serialization", derive(serde::Serialize))] +#[cfg_attr(feature = "serialization", serde(rename_all = "camelCase"))] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Hash, Copy, Ord)] +pub enum BinaryArithmeticOp { + Add, // + + Subtract, // - + Multiply, // * + Divide, // / + Modulo, // % + Power, // ** + LeftShift, // << + RightShift, // >> + BitwiseAnd, // & + BitwiseXor, // ^ + BitwiseOr, // | + LogicalAnd, // && + LogicalOr, // || +} + +#[cfg_attr(feature = "serialization", derive(serde::Serialize))] +#[cfg_attr(feature = "serialization", serde(rename_all = "camelCase"))] +#[derive(Debug, Clone, PartialEq, Eq, Copy)] +pub enum AssignmentOp { + Assign, // = + MultiplyAssign, // *= + DivideAssign, // /= + ModuloAssign, // %= + AddAssign, // += + SubtractAssign, // -= + LeftShiftAssign, // <<= + RightShiftAssign, // >>= + BitwiseAndAssign, // &= + BitwiseXorAssign, // ^= + BitwiseOrAssign, // |= +} + +#[cfg_attr(feature = "serialization", derive(serde::Serialize))] +#[cfg_attr(feature = "serialization", serde(rename_all = "camelCase"))] +#[derive(Debug, Clone, PartialEq, Eq, Copy)] +pub enum UnaryArithmeticOp { + Plus, // + + Minus, // - + LogicalNot, // ! + BitwiseNot, // ~ +} + +#[cfg_attr(feature = "serialization", derive(serde::Serialize))] +#[cfg_attr(feature = "serialization", serde(rename_all = "camelCase"))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PostArithmeticOp { + Increment, // ++ + Decrement, // -- } #[cfg_attr(feature = "serialization", derive(serde::Serialize))] @@ -268,15 +501,18 @@ pub enum WordPart { feature = "serialization", serde(rename_all = "camelCase", tag = "kind", content = "fd") )] -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum RedirectFd { + #[error("Invalid file descriptor")] Fd(u32), + #[error("Invalid stdout and stderr redirect")] StdoutStderr, } #[cfg_attr(feature = "serialization", derive(serde::Serialize))] #[cfg_attr(feature = "serialization", serde(rename_all = "camelCase"))] -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Error)] +#[error("Invalid redirect")] pub struct Redirect { pub maybe_fd: Option, pub op: RedirectOp, @@ -288,11 +524,11 @@ pub struct Redirect { feature = "serialization", serde(rename_all = "camelCase", tag = "kind", content = "value") )] -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum IoFile { - /// Filename to redirect to/from (ex. `file.txt`` in `cmd < file.txt`) + #[error("Invalid word")] Word(Word), - /// File descriptor to redirect to/from (ex. `2` in `cmd >&2`) + #[error("Invalid file descriptor")] Fd(u32), } @@ -301,38 +537,80 @@ pub enum IoFile { feature = "serialization", serde(rename_all = "camelCase", tag = "kind", content = "value") )] -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum RedirectOp { + #[error("Invalid input redirect")] Input(RedirectOpInput), + #[error("Invalid output redirect")] Output(RedirectOpOutput), } #[cfg_attr(feature = "serialization", derive(serde::Serialize))] #[cfg_attr(feature = "serialization", serde(rename_all = "camelCase"))] -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum RedirectOpInput { - /// < + #[error("Invalid input redirect")] Redirect, } #[cfg_attr(feature = "serialization", derive(serde::Serialize))] #[cfg_attr(feature = "serialization", serde(rename_all = "camelCase"))] -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum RedirectOpOutput { - /// > + #[error("Invalid overwrite redirect")] Overwrite, - /// >> + #[error("Invalid append redirect")] Append, } +lazy_static! { + static ref ARITHMETIC_PARSER: PrattParser = { + use Assoc::*; + use Rule::*; + + PrattParser::new() + .op( + Op::infix(assign, Right) + | Op::infix(multiply_assign, Right) + | Op::infix(divide_assign, Right) + | Op::infix(modulo_assign, Right) + | Op::infix(add_assign, Right) + | Op::infix(subtract_assign, Right) + | Op::infix(left_shift_assign, Right) + | Op::infix(right_shift_assign, Right) + | Op::infix(bitwise_and_assign, Right) + | Op::infix(bitwise_xor_assign, Right) + | Op::infix(bitwise_or_assign, Right), + ) + .op(Op::infix(logical_or, Left)) + .op(Op::infix(logical_and, Left)) + .op(Op::infix(bitwise_or, Left)) + .op(Op::infix(bitwise_xor, Left)) + .op(Op::infix(bitwise_and, Left)) + .op(Op::infix(left_shift, Left) | Op::infix(right_shift, Left)) + .op(Op::infix(add, Left) | Op::infix(subtract, Left)) + .op( + Op::infix(multiply, Left) + | Op::infix(divide, Left) + | Op::infix(modulo, Left), + ) + .op(Op::infix(power, Right)) + }; +} + #[derive(Parser)] #[grammar = "grammar.pest"] struct ShellParser; -pub fn parse(input: &str) -> Result { - let mut pairs = ShellParser::parse(Rule::FILE, input)?; +pub fn debug_parse(input: &str) { + let parsed = ShellParser::parse(Rule::FILE, input); + pest_ascii_tree::print_ascii_tree(parsed); +} - // println!("pairs: {:?}", pairs); +pub fn parse(input: &str) -> Result { + let mut pairs = ShellParser::parse(Rule::FILE, input).map_err(|e| { + miette::Error::new(e.into_miette()).context("Failed to parse input") + })?; parse_file(pairs.next().unwrap()) } @@ -353,7 +631,7 @@ fn parse_complete_command(pair: Pair) -> Result { break; } _ => { - return Err(anyhow::anyhow!( + return Err(miette!( "Unexpected rule in complete_command: {:?}", command.as_rule() )); @@ -382,10 +660,7 @@ fn parse_list( } } _ => { - return Err(anyhow::anyhow!( - "Unexpected rule in list: {:?}", - item.as_rule() - )); + return Err(miette!("Unexpected rule in list: {:?}", item.as_rule())); } } } @@ -410,7 +685,10 @@ fn parse_compound_list( } } _ => { - anyhow::bail!("Unexpected rule in compound_list: {:?}", item.as_rule()); + return Err(miette!( + "Unexpected rule in compound_list: {:?}", + item.as_rule() + )); } } } @@ -436,10 +714,7 @@ fn parse_term( } } _ => { - return Err(anyhow::anyhow!( - "Unexpected rule in term: {:?}", - item.as_rule() - )); + return Err(miette!("Unexpected rule in term: {:?}", item.as_rule())); } } } @@ -459,9 +734,9 @@ fn parse_and_or(pair: Pair) -> Result { match items.next() { Some(next_item) => { if next_item.as_rule() == Rule::ASSIGNMENT_WORD { - anyhow::bail!( + return Err(miette!( "Multiple assignment words before && or || is not supported yet" - ); + )); } else { let op = match next_item.as_str() { "&&" => BooleanListOperator::And, @@ -487,13 +762,13 @@ fn parse_shell_var(pair: Pair) -> Result { let mut inner = pair.into_inner(); let name = inner .next() - .ok_or_else(|| anyhow::anyhow!("Expected variable name"))? + .ok_or_else(|| miette!("Expected variable name"))? .as_str() .to_string(); let value = inner .next() - .ok_or_else(|| anyhow::anyhow!("Expected variable value"))?; - let value = parse_word(value)?; + .ok_or_else(|| miette!("Expected variable value"))?; + let value = parse_assignment_value(value)?; Ok(Sequence::ShellVar(EnvVar { name, value })) } @@ -504,21 +779,21 @@ fn parse_pipeline(pair: Pair) -> Result { // Check if the first element is Bang (negation) let first = inner .next() - .ok_or_else(|| anyhow::anyhow!("Expected pipeline content"))?; + .ok_or_else(|| miette!("Expected pipeline content"))?; let (negated, pipe_sequence) = if first.as_rule() == Rule::Bang { // If it's Bang, check for whitespace if pipeline_str.len() > 1 && !pipeline_str[1..2].chars().next().unwrap().is_whitespace() { - anyhow::bail!( + return Err(miette!( "Perhaps you meant to add a space after the exclamation point to negate the command?\n ! {}", pipeline_str - ); + )); } // Get the actual pipe sequence after whitespace - let pipe_sequence = inner.next().ok_or_else(|| { - anyhow::anyhow!("Expected pipe sequence after negation") - })?; + let pipe_sequence = inner + .next() + .ok_or_else(|| miette!("Expected pipe sequence after negation"))?; (true, pipe_sequence) } else { // If it's not Bang, this element itself is the pipe_sequence @@ -537,9 +812,9 @@ fn parse_pipe_sequence(pair: Pair) -> Result { let mut inner = pair.into_inner(); // Parse the first command - let first_command = inner.next().ok_or_else(|| { - anyhow::anyhow!("Expected at least one command in pipe sequence") - })?; + let first_command = inner + .next() + .ok_or_else(|| miette!("Expected at least one command in pipe sequence"))?; let current = parse_command(first_command)?; // Check if there's a pipe operator @@ -549,17 +824,17 @@ fn parse_pipe_sequence(pair: Pair) -> Result { Rule::Stdout => PipeSequenceOperator::Stdout, Rule::StdoutStderr => PipeSequenceOperator::StdoutStderr, _ => { - return Err(anyhow::anyhow!( + return Err(miette!( "Expected pipe operator, found {:?}", pipe_op.as_rule() - )) + )); } }; // Parse the rest of the pipe sequence - let next_sequence = inner.next().ok_or_else(|| { - anyhow::anyhow!("Expected command after pipe operator") - })?; + let next_sequence = inner + .next() + .ok_or_else(|| miette!("Expected command after pipe operator"))?; let next = parse_pipe_sequence(next_sequence)?; Ok(PipelineInner::PipeSequence(Box::new(PipeSequence { @@ -578,12 +853,9 @@ fn parse_command(pair: Pair) -> Result { Rule::simple_command => parse_simple_command(inner), Rule::compound_command => parse_compound_command(inner), Rule::function_definition => { - todo!("function definitions are not supported yet") + Err(miette!("Function definitions are not supported yet")) } - _ => Err(anyhow::anyhow!( - "Unexpected rule in command: {:?}", - inner.as_rule() - )), + _ => Err(miette!("Unexpected rule in command: {:?}", inner.as_rule())), } } @@ -598,12 +870,12 @@ fn parse_simple_command(pair: Pair) -> Result { for prefix in item.into_inner() { match prefix.as_rule() { Rule::ASSIGNMENT_WORD => env_vars.push(parse_env_var(prefix)?), - Rule::io_redirect => todo!("io_redirect as prefix"), + Rule::io_redirect => return Err(miette!("io_redirect as prefix")), _ => { - return Err(anyhow::anyhow!( + return Err(miette!( "Unexpected rule in cmd_prefix: {:?}", prefix.as_rule() - )) + )); } } } @@ -622,19 +894,19 @@ fn parse_simple_command(pair: Pair) -> Result { args.push(Word::new(vec![parse_quoted_word(suffix)?])) } _ => { - return Err(anyhow::anyhow!( + return Err(miette!( "Unexpected rule in cmd_suffix: {:?}", suffix.as_rule() - )) + )); } } } } _ => { - return Err(anyhow::anyhow!( + return Err(miette!( "Unexpected rule in simple_command: {:?}", item.as_rule() - )) + )); } } } @@ -648,14 +920,35 @@ fn parse_simple_command(pair: Pair) -> Result { fn parse_compound_command(pair: Pair) -> Result { let inner = pair.into_inner().next().unwrap(); match inner.as_rule() { - Rule::brace_group => todo!("brace_group"), + Rule::brace_group => { + Err(miette!("Unsupported compound command brace_group")) + } Rule::subshell => parse_subshell(inner), - Rule::for_clause => todo!("for_clause"), - Rule::case_clause => todo!("case_clause"), - Rule::if_clause => todo!("if_clause"), - Rule::while_clause => todo!("while_clause"), - Rule::until_clause => todo!("until_clause"), - _ => Err(anyhow::anyhow!( + Rule::for_clause => Err(miette!("Unsupported compound command for_clause")), + Rule::case_clause => { + Err(miette!("Unsupported compound command case_clause")) + } + Rule::if_clause => { + let if_clause = parse_if_clause(inner)?; + Ok(Command { + inner: CommandInner::If(if_clause), + redirect: None, + }) + } + Rule::while_clause => { + Err(miette!("Unsupported compound command while_clause")) + } + Rule::until_clause => { + Err(miette!("Unsupported compound command until_clause")) + } + Rule::ARITHMETIC_EXPRESSION => { + let arithmetic_expression = parse_arithmetic_expression(inner)?; + Ok(Command { + inner: CommandInner::ArithmeticExpression(arithmetic_expression), + redirect: None, + }) + } + _ => Err(miette!( "Unexpected rule in compound_command: {:?}", inner.as_rule() )), @@ -671,10 +964,227 @@ fn parse_subshell(pair: Pair) -> Result { redirect: None, }) } else { - Err(anyhow::anyhow!("Unexpected end of input in subshell")) + Err(miette!("Unexpected end of input in subshell")) } } +fn parse_if_clause(pair: Pair) -> Result { + let mut inner = pair.into_inner(); + let condition = inner + .next() + .ok_or_else(|| miette!("Expected condition after If"))?; + let condition = parse_conditional_expression(condition)?; + + let then_body_pair = inner + .next() + .ok_or_else(|| miette!("Expected then body after If"))?; + let then_body = parse_complete_command(then_body_pair)?; + + let else_part = match inner.next() { + Some(else_pair) => Some(parse_else_part(else_pair)?), + None => None, + }; + + Ok(IfClause { + condition, + then_body, + else_part, + }) +} + +fn parse_else_part(pair: Pair) -> Result { + let mut inner = pair.into_inner(); + + let keyword = inner + .next() + .ok_or_else(|| miette!("Expected ELSE or ELIF keyword"))?; + + match keyword.as_rule() { + Rule::Elif => { + let condition = inner + .next() + .ok_or_else(|| miette!("Expected condition after Elif"))?; + let condition = parse_conditional_expression(condition)?; + + let then_body_pair = inner + .next() + .ok_or_else(|| miette!("Expected then body after Elif"))?; + let then_body = parse_complete_command(then_body_pair)?; + + let else_part = match inner.next() { + Some(else_pair) => Some(parse_else_part(else_pair)?), + None => None, + }; + + Ok(ElsePart::Elif(Box::new(IfClause { + condition, + then_body, + else_part, + }))) + } + Rule::Else => { + let body_pair = inner + .next() + .ok_or_else(|| miette!("Expected body after Else"))?; + let body = parse_complete_command(body_pair)?; + Ok(ElsePart::Else(body)) + } + _ => Err(miette!( + "Unexpected rule in else_part: {:?}", + keyword.as_rule() + )), + } +} + +fn parse_conditional_expression(pair: Pair) -> Result { + let inner = pair + .into_inner() + .next() + .ok_or_else(|| miette!("Expected conditional expression content"))?; + + match inner.as_rule() { + Rule::unary_conditional_expression => { + parse_unary_conditional_expression(inner) + } + Rule::binary_conditional_expression => { + parse_binary_conditional_expression(inner) + } + _ => Err(miette!( + "Unexpected rule in conditional expression: {:?}", + inner.as_rule() + )), + } +} + +fn parse_unary_conditional_expression(pair: Pair) -> Result { + let mut inner = pair.into_inner(); + let operator = inner.next().ok_or_else(|| miette!("Expected operator"))?; + let operand = inner.next().ok_or_else(|| miette!("Expected operand"))?; + + let op = match operator.as_rule() { + Rule::string_conditional_op => match operator.as_str() { + "-n" => UnaryOp::NonEmptyString, + "-z" => UnaryOp::EmptyString, + _ => { + return Err(miette!( + "Unexpected string conditional operator: {}", + operator.as_str() + )) + } + }, + Rule::file_conditional_op => match operator.as_str() { + "-a" => UnaryOp::FileExists, + "-b" => UnaryOp::BlockSpecial, + "-c" => UnaryOp::CharSpecial, + "-d" => UnaryOp::Directory, + "-f" => UnaryOp::RegularFile, + "-g" => UnaryOp::SetGroupId, + "-h" => UnaryOp::SymbolicLink, + "-k" => UnaryOp::StickyBit, + "-p" => UnaryOp::NamedPipe, + "-r" => UnaryOp::Readable, + "-s" => UnaryOp::SizeNonZero, + "-u" => UnaryOp::SetUserId, + "-w" => UnaryOp::Writable, + "-x" => UnaryOp::Executable, + "-G" => UnaryOp::OwnedByEffectiveGroupId, + "-L" => UnaryOp::SymbolicLink, + "-N" => UnaryOp::ModifiedSinceLastRead, + "-O" => UnaryOp::OwnedByEffectiveUserId, + "-S" => UnaryOp::Socket, + _ => { + return Err(miette!( + "Unexpected file conditional operator: {}", + operator.as_str() + )) + } + }, + Rule::variable_conditional_op => match operator.as_str() { + "-v" => UnaryOp::VariableSet, + "-R" => UnaryOp::VariableNameReference, + _ => { + return Err(miette!( + "Unexpected variable conditional operator: {}", + operator.as_str() + )) + } + }, + _ => { + return Err(miette!( + "Unexpected unary conditional operator rule: {:?}", + operator.as_rule() + )) + } + }; + + let right = parse_word(operand)?; + + Ok(Condition { + condition_inner: ConditionInner::Unary { + op: Some(op), + right, + }, + }) +} + +fn parse_binary_conditional_expression(pair: Pair) -> Result { + let mut inner = pair.into_inner(); + let left = inner + .next() + .ok_or_else(|| miette!("Expected left operand"))?; + let operator = inner.next().ok_or_else(|| miette!("Expected operator"))?; + let right = inner + .next() + .ok_or_else(|| miette!("Expected right operand"))?; + + let left_word = parse_word(left)?; + let right_word = parse_word(right)?; + + let op = match operator.as_rule() { + Rule::binary_bash_conditional_op => match operator.as_str() { + "==" => BinaryOp::Equal, + "=" => BinaryOp::Equal, + "!=" => BinaryOp::NotEqual, + "<" => BinaryOp::LessThan, + ">" => BinaryOp::GreaterThan, + _ => { + return Err(miette!( + "Unexpected string conditional operator: {}", + operator.as_str() + )) + } + }, + Rule::binary_posix_conditional_op => match operator.as_str() { + "-eq" => BinaryOp::Equal, + "-ne" => BinaryOp::NotEqual, + "-lt" => BinaryOp::LessThan, + "-le" => BinaryOp::LessThanOrEqual, + "-gt" => BinaryOp::GreaterThan, + "-ge" => BinaryOp::GreaterThanOrEqual, + _ => { + return Err(miette!( + "Unexpected arithmetic conditional operator: {}", + operator.as_str() + )) + } + }, + _ => { + return Err(miette!( + "Unexpected operator rule: {:?}", + operator.as_rule() + )) + } + }; + + Ok(Condition { + condition_inner: ConditionInner::Binary { + left: left_word, + op, + right: right_word, + }, + }) +} + fn parse_word(pair: Pair) -> Result { let mut parts = Vec::new(); @@ -682,7 +1192,7 @@ fn parse_word(pair: Pair) -> Result { Rule::UNQUOTED_PENDING_WORD => { for part in pair.into_inner() { match part.as_rule() { - Rule::EXIT_STATUS => parts.push(WordPart::Variable("?".to_string())), + Rule::EXIT_STATUS => parts.push(WordPart::ExitStatus), Rule::UNQUOTED_CHAR => { if let Some(WordPart::Text(ref mut text)) = parts.last_mut() { text.push(part.as_str().chars().next().unwrap()); @@ -727,11 +1237,19 @@ fn parse_word(pair: Pair) -> Result { let quoted = parse_quoted_word(part)?; parts.push(quoted); } + Rule::TILDE_PREFIX => { + let tilde_prefix = parse_tilde_prefix(part)?; + parts.push(tilde_prefix); + } + Rule::ARITHMETIC_EXPRESSION => { + let arithmetic_expression = parse_arithmetic_expression(part)?; + parts.push(WordPart::Arithmetic(arithmetic_expression)); + } _ => { - return Err(anyhow::anyhow!( + return Err(miette!( "Unexpected rule in UNQUOTED_PENDING_WORD: {:?}", part.as_rule() - )) + )); } } } @@ -768,20 +1286,25 @@ fn parse_word(pair: Pair) -> Result { let quoted = parse_quoted_word(part)?; parts.push(quoted); } + Rule::TILDE_PREFIX => { + let tilde_prefix = parse_tilde_prefix(part)?; + parts.push(tilde_prefix); + } + Rule::ARITHMETIC_EXPRESSION => { + let arithmetic_expression = parse_arithmetic_expression(part)?; + parts.push(WordPart::Arithmetic(arithmetic_expression)); + } _ => { - return Err(anyhow::anyhow!( + return Err(miette!( "Unexpected rule in FILE_NAME_PENDING_WORD: {:?}", part.as_rule() - )) + )); } } } } _ => { - return Err(anyhow::anyhow!( - "Unexpected rule in word: {:?}", - pair.as_rule() - )) + return Err(miette!("Unexpected rule in word: {:?}", pair.as_rule())); } } @@ -792,6 +1315,175 @@ fn parse_word(pair: Pair) -> Result { } } +fn parse_arithmetic_expression(pair: Pair) -> Result { + assert!(pair.as_rule() == Rule::ARITHMETIC_EXPRESSION); + let inner = pair.into_inner().next().unwrap(); + let parts = parse_arithmetic_sequence(inner)?; + Ok(Arithmetic { parts }) +} + +fn parse_arithmetic_sequence(pair: Pair) -> Result> { + assert!(pair.as_rule() == Rule::arithmetic_sequence); + let mut parts = Vec::new(); + for expr in pair.into_inner() { + parts.push(parse_arithmetic_expr(expr)?); + } + Ok(parts) +} + +fn parse_arithmetic_expr(pair: Pair) -> Result { + ARITHMETIC_PARSER + .map_primary(|primary| match primary.as_rule() { + Rule::parentheses_expr => { + let inner = primary.into_inner().next().unwrap(); + let parts = parse_arithmetic_sequence(inner)?; + Ok(ArithmeticPart::ParenthesesExpr(Box::new(Arithmetic { + parts, + }))) + } + Rule::variable_assignment => { + let mut inner = primary.into_inner(); + let name = inner.next().unwrap().as_str().to_string(); + let op = inner.next().unwrap(); + + let value = parse_arithmetic_expr(inner.next().unwrap())?; + Ok(ArithmeticPart::VariableAssignment { + name, + op: match op.as_rule() { + Rule::assign => AssignmentOp::Assign, + Rule::multiply_assign => AssignmentOp::MultiplyAssign, + Rule::divide_assign => AssignmentOp::DivideAssign, + Rule::modulo_assign => AssignmentOp::ModuloAssign, + Rule::add_assign => AssignmentOp::AddAssign, + Rule::subtract_assign => AssignmentOp::SubtractAssign, + Rule::left_shift_assign => AssignmentOp::LeftShiftAssign, + Rule::right_shift_assign => AssignmentOp::RightShiftAssign, + _ => { + return Err(miette!( + "Unexpected assignment operator: {:?}", + op.as_rule() + )); + } + }, + value: Box::new(value), + }) + } + Rule::triple_conditional_expr => { + let mut inner = primary.into_inner(); + let condition = parse_arithmetic_expr(inner.next().unwrap())?; + let true_expr = parse_arithmetic_expr(inner.next().unwrap())?; + let false_expr = parse_arithmetic_expr(inner.next().unwrap())?; + Ok(ArithmeticPart::TripleConditionalExpr { + condition: Box::new(condition), + true_expr: Box::new(true_expr), + false_expr: Box::new(false_expr), + }) + } + Rule::unary_arithmetic_expr => parse_unary_arithmetic_expr(primary), + Rule::VARIABLE => { + Ok(ArithmeticPart::Variable(primary.as_str().to_string())) + } + Rule::NUMBER => Ok(ArithmeticPart::Number(primary.as_str().to_string())), + _ => Err(miette!( + "Unexpected rule in arithmetic expression: {:?}", + primary.as_rule() + )), + }) + .map_infix(|lhs, op, rhs| { + let operator = match op.as_rule() { + Rule::add => BinaryArithmeticOp::Add, + Rule::subtract => BinaryArithmeticOp::Subtract, + Rule::multiply => BinaryArithmeticOp::Multiply, + Rule::divide => BinaryArithmeticOp::Divide, + Rule::modulo => BinaryArithmeticOp::Modulo, + Rule::power => BinaryArithmeticOp::Power, + Rule::left_shift => BinaryArithmeticOp::LeftShift, + Rule::right_shift => BinaryArithmeticOp::RightShift, + Rule::bitwise_and => BinaryArithmeticOp::BitwiseAnd, + Rule::bitwise_xor => BinaryArithmeticOp::BitwiseXor, + Rule::bitwise_or => BinaryArithmeticOp::BitwiseOr, + Rule::logical_and => BinaryArithmeticOp::LogicalAnd, + Rule::logical_or => BinaryArithmeticOp::LogicalOr, + _ => { + return Err(miette!("Unexpected infix operator: {:?}", op.as_rule())) + } + }; + Ok(ArithmeticPart::BinaryArithmeticExpr { + left: Box::new(lhs?), + operator, + right: Box::new(rhs?), + }) + }) + .parse(pair.into_inner()) +} + +fn parse_unary_arithmetic_expr(pair: Pair) -> Result { + let mut inner = pair.into_inner(); + let first = inner.next().unwrap(); + + match first.as_rule() { + Rule::unary_arithmetic_op => { + let op = parse_unary_arithmetic_op(first)?; + let operand = parse_arithmetic_expr(inner.next().unwrap())?; + Ok(ArithmeticPart::UnaryArithmeticExpr { + operator: op, + operand: Box::new(operand), + }) + } + Rule::post_arithmetic_op => { + let operand = parse_arithmetic_expr(inner.next().unwrap())?; + let op = parse_post_arithmetic_op(first)?; + Ok(ArithmeticPart::PostArithmeticExpr { + operand: Box::new(operand), + operator: op, + }) + } + _ => { + let operand = parse_arithmetic_expr(first)?; + let op = parse_post_arithmetic_op(inner.next().unwrap())?; + Ok(ArithmeticPart::PostArithmeticExpr { + operand: Box::new(operand), + operator: op, + }) + } + } +} + +fn parse_unary_arithmetic_op(pair: Pair) -> Result { + match pair.as_str() { + "+" => Ok(UnaryArithmeticOp::Plus), + "-" => Ok(UnaryArithmeticOp::Minus), + "!" => Ok(UnaryArithmeticOp::LogicalNot), + "~" => Ok(UnaryArithmeticOp::BitwiseNot), + _ => Err(miette!( + "Invalid unary arithmetic operator: {}", + pair.as_str() + )), + } +} + +fn parse_post_arithmetic_op(pair: Pair) -> Result { + match pair.as_str() { + "++" => Ok(PostArithmeticOp::Increment), + "--" => Ok(PostArithmeticOp::Decrement), + _ => Err(miette!( + "Invalid post arithmetic operator: {}", + pair.as_str() + )), + } +} + +fn parse_tilde_prefix(pair: Pair) -> Result { + let tilde_prefix_str = pair.as_str(); + let user = if tilde_prefix_str.len() > 1 { + Some(tilde_prefix_str[1..].to_string()) + } else { + None + }; + let tilde_prefix = TildePrefix::new(user); + Ok(WordPart::Tilde(tilde_prefix)) +} + fn parse_quoted_word(pair: Pair) -> Result { let mut parts = Vec::new(); let inner = pair.into_inner().next().unwrap(); @@ -825,10 +1517,10 @@ fn parse_quoted_word(pair: Pair) -> Result { } } _ => { - return Err(anyhow::anyhow!( + return Err(miette!( "Unexpected rule in DOUBLE_QUOTED: {:?}", part.as_rule() - )) + )); } } } @@ -841,7 +1533,7 @@ fn parse_quoted_word(pair: Pair) -> Result { trimmed_str.to_string(), )])) } - _ => Err(anyhow::anyhow!( + _ => Err(miette!( "Unexpected rule in QUOTED_WORD: {:?}", inner.as_rule() )), @@ -854,13 +1546,13 @@ fn parse_env_var(pair: Pair) -> Result { // Get the name of the environment variable let name = parts .next() - .ok_or_else(|| anyhow!("Expected variable name"))? + .ok_or_else(|| miette!("Expected variable name"))? .as_str() .to_string(); // Get the value of the environment variable let word_value = if let Some(value) = parts.next() { - parse_word(value)? + parse_assignment_value(value).context("Failed to parse assignment value")? } else { Word::new_empty() }; @@ -871,6 +1563,32 @@ fn parse_env_var(pair: Pair) -> Result { }) } +fn parse_assignment_value(pair: Pair) -> Result { + let mut parts = Vec::new(); + + for part in pair.into_inner() { + match part.as_rule() { + Rule::ASSIGNMENT_TILDE_PREFIX => { + let tilde_prefix = + parse_tilde_prefix(part).context("Failed to parse tilde prefix")?; + parts.push(tilde_prefix); + } + Rule::UNQUOTED_PENDING_WORD => { + let word_parts = parse_word(part)?; + parts.extend(word_parts.into_parts()); + } + _ => { + return Err(miette!( + "Unexpected rule in assignment value: {:?}", + part.as_rule() + )); + } + } + } + + Ok(Word::new(parts)) +} + fn parse_io_redirect(pair: Pair) -> Result { let mut inner = pair.into_inner(); @@ -879,17 +1597,17 @@ fn parse_io_redirect(pair: Pair) -> Result { Some(p) if p.as_rule() == Rule::IO_NUMBER => ( Some(RedirectFd::Fd(p.as_str().parse::().unwrap())), inner.next().ok_or_else(|| { - anyhow!("Expected redirection operator after IO number") + miette!("Expected redirection operator after IO number") })?, ), Some(p) if p.as_rule() == Rule::AMPERSAND => ( Some(RedirectFd::StdoutStderr), inner .next() - .ok_or_else(|| anyhow!("Expected redirection operator after &"))?, + .ok_or_else(|| miette!("Expected redirection operator after &"))?, ), Some(p) => (None, p), - None => return Err(anyhow!("Unexpected end of input in io_redirect")), + None => return Err(miette!("Unexpected end of input in io_redirect")), }; let (op, io_file) = parse_io_file(op_and_file)?; @@ -905,10 +1623,10 @@ fn parse_io_file(pair: Pair) -> Result<(RedirectOp, IoFile)> { let mut inner = pair.into_inner(); let op = inner .next() - .ok_or_else(|| anyhow!("Expected redirection operator"))?; + .ok_or_else(|| miette!("Expected redirection operator"))?; let filename = inner .next() - .ok_or_else(|| anyhow!("Expected filename after redirection operator"))?; + .ok_or_else(|| miette!("Expected filename after redirection operator"))?; let redirect_op = match op.as_rule() { Rule::LESS => RedirectOp::Input(RedirectOpInput::Redirect), @@ -927,7 +1645,7 @@ fn parse_io_file(pair: Pair) -> Result<(RedirectOp, IoFile)> { IoFile::Fd(fd), )); } else { - return Err(anyhow!( + return Err(miette!( "Expected a number after {} operator", if op.as_rule() == Rule::LESSAND { "<&" @@ -938,7 +1656,7 @@ fn parse_io_file(pair: Pair) -> Result<(RedirectOp, IoFile)> { } } _ => { - return Err(anyhow!( + return Err(miette!( "Unexpected redirection operator: {:?}", op.as_rule() )) @@ -948,7 +1666,7 @@ fn parse_io_file(pair: Pair) -> Result<(RedirectOp, IoFile)> { let io_file = if filename.as_rule() == Rule::FILE_NAME_PENDING_WORD { IoFile::Word(parse_word(filename)?) } else { - return Err(anyhow!( + return Err(miette!( "Unexpected filename type: {:?}", filename.as_rule() )); @@ -960,7 +1678,6 @@ fn parse_io_file(pair: Pair) -> Result<(RedirectOp, IoFile)> { #[cfg(test)] mod test { use super::*; - use pretty_assertions::assert_eq; #[test] fn test_main() { @@ -982,15 +1699,13 @@ mod test { assert!(parse("echo \"foo\" > out.txt").is_ok()); } - #[test] fn test_sequential_list() { let parse_and_create = |input: &str| -> Result { let pairs = ShellParser::parse(Rule::complete_command, input) - .map_err(|e| anyhow::Error::msg(e.to_string()))? + .map_err(|e| miette!(e.to_string()))? .next() .unwrap(); - // println!("pairs: {:?}", pairs); parse_complete_command(pairs) }; @@ -1276,9 +1991,9 @@ mod test { #[test] fn test_env_var() { - let parse_and_create = |input: &str| -> Result { + let parse_and_create = |input: &str| -> Result { let pairs = ShellParser::parse(Rule::ASSIGNMENT_WORD, input) - .map_err(|e| anyhow::anyhow!(e.to_string()))? + .map_err(|e| miette!(e.to_string()))? .next() .unwrap(); parse_env_var(pairs) diff --git a/crates/deno_task_shell/src/shell/command.rs b/crates/deno_task_shell/src/shell/command.rs index 9ca8a5b..b2a319c 100644 --- a/crates/deno_task_shell/src/shell/command.rs +++ b/crates/deno_task_shell/src/shell/command.rs @@ -13,8 +13,8 @@ use crate::ExecuteResult; use crate::FutureExecuteResult; use crate::ShellCommand; use crate::ShellCommandContext; -use anyhow::Result; use futures::FutureExt; +use miette::{miette, Result}; use thiserror::Error; #[derive(Debug, Clone)] @@ -87,15 +87,21 @@ enum ResolveCommandError { enum FailedShebangError { #[error(transparent)] CommandPath(#[from] ResolveCommandPathError), - #[error(transparent)] - Any(#[from] anyhow::Error), + #[error("{0}")] + MietteError(String), +} + +impl From for FailedShebangError { + fn from(err: miette::Error) -> Self { + FailedShebangError::MietteError(err.to_string()) + } } impl FailedShebangError { pub fn exit_code(&self) -> i32 { match self { FailedShebangError::CommandPath(err) => err.exit_code(), - FailedShebangError::Any(_) => 1, + FailedShebangError::MietteError(_) => 1, } } } @@ -119,12 +125,14 @@ async fn resolve_command<'a>( // won't have a script with a shebang in it on Windows if command_name.name.contains('/') { if let Some(shebang) = resolve_shebang(&command_path).map_err(|err| { - ResolveCommandError::FailedShebang(FailedShebangError::Any(err.into())) + ResolveCommandError::FailedShebang(FailedShebangError::MietteError( + err.to_string(), + )) })? { let (shebang_command_name, mut args) = if shebang.string_split { let mut args = parse_shebang_args(&shebang.command, context) .await - .map_err(FailedShebangError::Any)?; + .map_err(|e| FailedShebangError::MietteError(e.to_string()))?; args.push(command_path.to_string_lossy().to_string()); (args.remove(0), args) } else { @@ -155,7 +163,7 @@ async fn parse_shebang_args( context: &ShellCommandContext, ) -> Result> { fn err_unsupported(text: &str) -> Result> { - anyhow::bail!("unsupported shebang. Please report this as a bug (https://github.com/denoland/deno).\n\nShebang: {}", text) + miette::bail!("unsupported shebang. Please report this as a bug (https://github.com/denoland/deno).\n\nShebang: {}", text) } let mut args = crate::parser::parse(text)?; @@ -185,20 +193,24 @@ async fn parse_shebang_args( let cmd = match cmd.inner { crate::parser::CommandInner::Simple(cmd) => cmd, crate::parser::CommandInner::Subshell(_) => return err_unsupported(text), + crate::parser::CommandInner::If(_) => return err_unsupported(text), + crate::parser::CommandInner::ArithmeticExpression(_) => { + return err_unsupported(text) + } }; if !cmd.env_vars.is_empty() { return err_unsupported(text); } - Ok( - super::execute::evaluate_args( - cmd.args, - &context.state, - context.stdin.clone(), - context.stderr.clone(), - ) - .await?, + let result = super::execute::evaluate_args( + cmd.args, + &context.state, + context.stdin.clone(), + context.stderr.clone(), ) + .await + .map_err(|e| miette!(e.to_string()))?; + Ok(result.value) } /// Errors for executable commands. @@ -226,7 +238,7 @@ pub fn resolve_command_path( state: &ShellState, ) -> Result { resolve_command_path_inner(command_name, base_dir, state, || { - Ok(std::env::current_exe()?) + std::env::current_exe().map_err(|e| miette!(e.to_string())) }) } diff --git a/crates/deno_task_shell/src/shell/commands/args.rs b/crates/deno_task_shell/src/shell/commands/args.rs index d308500..fff34c5 100644 --- a/crates/deno_task_shell/src/shell/commands/args.rs +++ b/crates/deno_task_shell/src/shell/commands/args.rs @@ -1,7 +1,7 @@ // Copyright 2018-2024 the Deno authors. MIT license. -use anyhow::bail; -use anyhow::Result; +use miette::bail; +use miette::Result; #[derive(Debug, PartialEq, Eq)] pub enum ArgKind<'a> { diff --git a/crates/deno_task_shell/src/shell/commands/cat.rs b/crates/deno_task_shell/src/shell/commands/cat.rs index 674fe33..d66d344 100644 --- a/crates/deno_task_shell/src/shell/commands/cat.rs +++ b/crates/deno_task_shell/src/shell/commands/cat.rs @@ -1,11 +1,14 @@ // Copyright 2018-2024 the Deno authors. MIT license. -use anyhow::Result; use futures::future::LocalBoxFuture; +use miette::IntoDiagnostic; +use miette::Result; use std::fs::File; +use std::io::IsTerminal; use std::io::Read; use crate::shell::types::ExecuteResult; +use crate::ShellPipeWriter; use super::args::parse_arg_kinds; use super::args::ArgKind; @@ -50,12 +53,20 @@ fn execute_cat(mut context: ShellCommandContext) -> Result { return Ok(ExecuteResult::for_cancellation()); } - let size = file.read(&mut buf)?; + let size = file.read(&mut buf).into_diagnostic()?; if size == 0 { break; } else { context.stdout.write_all(&buf[..size])?; } + + if let ShellPipeWriter::Stdout = context.stdout { + // check if it's interactive + if buf[size - 1] != b'\n' && std::io::stdout().is_terminal() { + // make sure that we end up on a new line + context.stdout.write_all(b"%\n")?; + } + } }, Err(err) => { context.stderr.write_line(&format!("cat: {path}: {err}"))?; diff --git a/crates/deno_task_shell/src/shell/commands/cd.rs b/crates/deno_task_shell/src/shell/commands/cd.rs index b4e1c3a..c2b6595 100644 --- a/crates/deno_task_shell/src/shell/commands/cd.rs +++ b/crates/deno_task_shell/src/shell/commands/cd.rs @@ -3,9 +3,9 @@ use std::path::Path; use std::path::PathBuf; -use anyhow::bail; -use anyhow::Result; use futures::future::LocalBoxFuture; +use miette::bail; +use miette::Result; use path_dedot::ParseDot; use crate::shell::fs_util; @@ -47,7 +47,7 @@ fn execute_cd(cwd: &Path, args: Vec) -> Result { let path = parse_args(args.clone())?; let new_dir = if path == "~" { dirs::home_dir() - .ok_or_else(|| anyhow::anyhow!("Home directory not found"))? + .ok_or_else(|| miette::miette!("Home directory not found"))? } else { cwd.join(&path) }; diff --git a/crates/deno_task_shell/src/shell/commands/cp_mv.rs b/crates/deno_task_shell/src/shell/commands/cp_mv.rs index 5647cfb..83fff6a 100644 --- a/crates/deno_task_shell/src/shell/commands/cp_mv.rs +++ b/crates/deno_task_shell/src/shell/commands/cp_mv.rs @@ -3,12 +3,13 @@ use std::path::Path; use std::path::PathBuf; -use anyhow::bail; -use anyhow::Context; -use anyhow::Result; use futures::future::BoxFuture; use futures::future::LocalBoxFuture; use futures::FutureExt; +use miette::bail; +use miette::Context; +use miette::IntoDiagnostic; +use miette::Result; use crate::shell::types::ExecuteResult; use crate::shell::types::ShellPipeWriter; @@ -87,7 +88,9 @@ async fn do_copy_operation( bail!("source was a directory; maybe specify -r") } } else { - tokio::fs::copy(&from.path, &to.path).await?; + tokio::fs::copy(&from.path, &to.path) + .await + .into_diagnostic()?; } Ok(()) } @@ -100,13 +103,15 @@ fn copy_dir_recursively( async move { tokio::fs::create_dir_all(&to) .await - .with_context(|| format!("Creating {}", to.display()))?; + .into_diagnostic() + .context(miette::miette!("Creating {}", to.display()))?; let mut read_dir = tokio::fs::read_dir(&from) .await - .with_context(|| format!("Reading {}", from.display()))?; + .into_diagnostic() + .context(miette::miette!("Reading {}", from.display()))?; - while let Some(entry) = read_dir.next_entry().await? { - let file_type = entry.file_type().await?; + while let Some(entry) = read_dir.next_entry().await.into_diagnostic()? { + let file_type = entry.file_type().await.into_diagnostic()?; let new_from = from.join(entry.file_name()); let new_to = to.join(entry.file_name()); @@ -117,9 +122,12 @@ fn copy_dir_recursively( format!("Dir {} to {}", new_from.display(), new_to.display()) })?; } else if file_type.is_file() { - tokio::fs::copy(&new_from, &new_to).await.with_context(|| { - format!("Copying {} to {}", new_from.display(), new_to.display()) - })?; + tokio::fs::copy(&new_from, &new_to) + .await + .into_diagnostic() + .with_context(|| { + format!("Copying {} to {}", new_from.display(), new_to.display()) + })?; } } diff --git a/crates/deno_task_shell/src/shell/commands/exit.rs b/crates/deno_task_shell/src/shell/commands/exit.rs index 5222c61..44ab7fb 100644 --- a/crates/deno_task_shell/src/shell/commands/exit.rs +++ b/crates/deno_task_shell/src/shell/commands/exit.rs @@ -1,8 +1,8 @@ // Copyright 2018-2024 the Deno authors. MIT license. -use anyhow::bail; -use anyhow::Result; use futures::future::LocalBoxFuture; +use miette::bail; +use miette::Result; use crate::shell::types::ExecuteResult; diff --git a/crates/deno_task_shell/src/shell/commands/head.rs b/crates/deno_task_shell/src/shell/commands/head.rs index bef3a46..c0a9344 100644 --- a/crates/deno_task_shell/src/shell/commands/head.rs +++ b/crates/deno_task_shell/src/shell/commands/head.rs @@ -3,9 +3,10 @@ use std::fs::File; use std::io::Read; -use anyhow::bail; -use anyhow::Result; use futures::future::LocalBoxFuture; +use miette::bail; +use miette::IntoDiagnostic; +use miette::Result; use tokio_util::sync::CancellationToken; use crate::ExecuteResult; @@ -96,7 +97,7 @@ fn execute_head(mut context: ShellCommandContext) -> Result { &mut context.stdout, flags.lines, context.state.token(), - |buf| file.read(buf).map_err(Into::into), + |buf| file.read(buf).into_diagnostic(), 512, ), Err(err) => { @@ -131,7 +132,7 @@ fn parse_args(args: Vec) -> Result { } ArgKind::ShortFlag('n') => match iterator.next() { Some(ArgKind::Arg(arg)) => { - lines = Some(arg.parse::()?); + lines = Some(arg.parse::().into_diagnostic()?); } _ => bail!("expected a value following -n"), }, @@ -139,7 +140,7 @@ fn parse_args(args: Vec) -> Result { if flag == "lines" || flag == "lines=" { bail!("expected a value for --lines"); } else if let Some(arg) = flag.strip_prefix("lines=") { - lines = Some(arg.parse::()?); + lines = Some(arg.parse::().into_diagnostic()?); } else { arg.bail_unsupported()? } diff --git a/crates/deno_task_shell/src/shell/commands/mkdir.rs b/crates/deno_task_shell/src/shell/commands/mkdir.rs index 25636ca..5d8d90f 100644 --- a/crates/deno_task_shell/src/shell/commands/mkdir.rs +++ b/crates/deno_task_shell/src/shell/commands/mkdir.rs @@ -1,9 +1,9 @@ // Copyright 2018-2024 the Deno authors. MIT license. -use anyhow::bail; -use anyhow::Result; use futures::future::LocalBoxFuture; use futures::FutureExt; +use miette::bail; +use miette::Result; use std::path::Path; use crate::shell::types::ExecuteResult; diff --git a/crates/deno_task_shell/src/shell/commands/pwd.rs b/crates/deno_task_shell/src/shell/commands/pwd.rs index 9da211c..bb6e933 100644 --- a/crates/deno_task_shell/src/shell/commands/pwd.rs +++ b/crates/deno_task_shell/src/shell/commands/pwd.rs @@ -1,8 +1,8 @@ // Copyright 2018-2024 the Deno authors. MIT license. -use anyhow::Context; -use anyhow::Result; use futures::future::LocalBoxFuture; +use miette::Context; +use miette::Result; use std::path::Path; use crate::shell::fs_util; diff --git a/crates/deno_task_shell/src/shell/commands/rm.rs b/crates/deno_task_shell/src/shell/commands/rm.rs index 72c1410..77cf9c3 100644 --- a/crates/deno_task_shell/src/shell/commands/rm.rs +++ b/crates/deno_task_shell/src/shell/commands/rm.rs @@ -1,9 +1,9 @@ // Copyright 2018-2024 the Deno authors. MIT license. -use anyhow::bail; -use anyhow::Result; use futures::future::LocalBoxFuture; use futures::FutureExt; +use miette::bail; +use miette::Result; use std::io::ErrorKind; use std::path::Path; diff --git a/crates/deno_task_shell/src/shell/commands/sleep.rs b/crates/deno_task_shell/src/shell/commands/sleep.rs index f1e68e6..b86d92d 100644 --- a/crates/deno_task_shell/src/shell/commands/sleep.rs +++ b/crates/deno_task_shell/src/shell/commands/sleep.rs @@ -2,10 +2,11 @@ use std::time::Duration; -use anyhow::bail; -use anyhow::Result; use futures::future::LocalBoxFuture; use futures::FutureExt; +use miette::bail; +use miette::IntoDiagnostic; +use miette::Result; use crate::shell::types::ExecuteResult; use crate::shell::types::ShellPipeWriter; @@ -54,19 +55,19 @@ async fn execute_sleep(args: Vec) -> Result<()> { fn parse_arg(arg: &str) -> Result { if let Some(t) = arg.strip_suffix('s') { - return Ok(t.parse()?); + return t.parse().into_diagnostic(); } if let Some(t) = arg.strip_suffix('m') { - return Ok(t.parse::()? * 60.); + return Ok(t.parse::().into_diagnostic()? * 60.); } if let Some(t) = arg.strip_suffix('h') { - return Ok(t.parse::()? * 60. * 60.); + return Ok(t.parse::().into_diagnostic()? * 60. * 60.); } if let Some(t) = arg.strip_suffix('d') { - return Ok(t.parse::()? * 60. * 60. * 24.); + return Ok(t.parse::().into_diagnostic()? * 60. * 60. * 24.); } - Ok(arg.parse()?) + arg.parse().into_diagnostic() } fn parse_args(args: Vec) -> Result { diff --git a/crates/deno_task_shell/src/shell/commands/unset.rs b/crates/deno_task_shell/src/shell/commands/unset.rs index 9a60c3b..bb9ec01 100644 --- a/crates/deno_task_shell/src/shell/commands/unset.rs +++ b/crates/deno_task_shell/src/shell/commands/unset.rs @@ -1,8 +1,8 @@ // Copyright 2018-2024 the Deno authors. MIT license. -use anyhow::bail; -use anyhow::Result; use futures::future::LocalBoxFuture; +use miette::bail; +use miette::Result; use crate::shell::types::ExecuteResult; use crate::EnvChange; diff --git a/crates/deno_task_shell/src/shell/commands/xargs.rs b/crates/deno_task_shell/src/shell/commands/xargs.rs index 7ff01e1..11e3609 100644 --- a/crates/deno_task_shell/src/shell/commands/xargs.rs +++ b/crates/deno_task_shell/src/shell/commands/xargs.rs @@ -1,9 +1,10 @@ // Copyright 2018-2024 the Deno authors. MIT license. -use anyhow::bail; -use anyhow::Result; use futures::future::LocalBoxFuture; use futures::FutureExt; +use miette::bail; +use miette::IntoDiagnostic; +use miette::Result; use crate::shell::types::ExecuteResult; use crate::shell::types::ShellPipeReader; @@ -51,7 +52,7 @@ fn xargs_collect_args( let flags = parse_args(cli_args)?; let mut buf = Vec::new(); stdin.pipe_to(&mut buf)?; - let text = String::from_utf8(buf)?; + let text = String::from_utf8(buf).into_diagnostic()?; let mut args = flags.initial_args; if args.is_empty() { diff --git a/crates/deno_task_shell/src/shell/execute.rs b/crates/deno_task_shell/src/shell/execute.rs index cd988ef..c17c39e 100644 --- a/crates/deno_task_shell/src/shell/execute.rs +++ b/crates/deno_task_shell/src/shell/execute.rs @@ -7,16 +7,25 @@ use std::rc::Rc; use futures::future; use futures::future::LocalBoxFuture; use futures::FutureExt; +use miette::Error; use thiserror::Error; use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; +use crate::parser::AssignmentOp; +use crate::parser::BinaryOp; +use crate::parser::Condition; +use crate::parser::ConditionInner; +use crate::parser::ElsePart; use crate::parser::IoFile; use crate::parser::RedirectOpInput; use crate::parser::RedirectOpOutput; +use crate::parser::UnaryOp; use crate::shell::commands::ShellCommand; use crate::shell::commands::ShellCommandContext; use crate::shell::types::pipe; +use crate::shell::types::ArithmeticResult; +use crate::shell::types::ArithmeticValue; use crate::shell::types::EnvChange; use crate::shell::types::ExecuteResult; use crate::shell::types::FutureExecuteResult; @@ -24,8 +33,12 @@ use crate::shell::types::ShellPipeReader; use crate::shell::types::ShellPipeWriter; use crate::shell::types::ShellState; +use crate::parser::Arithmetic; +use crate::parser::ArithmeticPart; +use crate::parser::BinaryArithmeticOp; use crate::parser::Command; use crate::parser::CommandInner; +use crate::parser::IfClause; use crate::parser::PipeSequence; use crate::parser::PipeSequenceOperator; use crate::parser::Pipeline; @@ -36,8 +49,10 @@ use crate::parser::RedirectOp; use crate::parser::Sequence; use crate::parser::SequentialList; use crate::parser::SimpleCommand; +use crate::parser::UnaryArithmeticOp; use crate::parser::Word; use crate::parser::WordPart; +use crate::shell::types::WordEvalResult; use super::command::execute_unresolved_command_name; use super::command::UnresolvedCommandName; @@ -419,21 +434,21 @@ async fn resolve_redirect_word_pipe( } }; // edge case that's not supported - if words.is_empty() { + if words.value.is_empty() { let _ = stderr.write_line("redirect path must be 1 argument, but found 0"); return Err(ExecuteResult::from_exit_code(1)); - } else if words.len() > 1 { + } else if words.value.len() > 1 { let _ = stderr.write_line(&format!( concat!( "redirect path must be 1 argument, but found {0} ({1}). ", "Did you mean to quote it (ex. \"{1}\")?" ), - words.len(), + words.value.len(), words.join(" ") )); return Err(ExecuteResult::from_exit_code(1)); } - let output_path = &words[0]; + let output_path = &words.value[0]; match &redirect_op { RedirectOp::Input(RedirectOpInput::Redirect) => { @@ -471,7 +486,7 @@ async fn execute_command( stdout: ShellPipeWriter, mut stderr: ShellPipeWriter, ) -> ExecuteResult { - let (stdin, stdout, stderr) = if let Some(redirect) = &command.redirect { + let (stdin, stdout, mut stderr) = if let Some(redirect) = &command.redirect { let pipe = match resolve_redirect_pipe( redirect, &state, @@ -516,6 +531,215 @@ async fn execute_command( CommandInner::Subshell(list) => { execute_subshell(list, state, stdin, stdout, stderr).await } + CommandInner::If(if_clause) => { + execute_if_clause(if_clause, state, stdin, stdout, stderr).await + } + CommandInner::ArithmeticExpression(arithmetic) => { + match execute_arithmetic_expression(arithmetic, state).await { + Ok(result) => ExecuteResult::Continue(0, result.changes, Vec::new()), + Err(e) => { + let _ = stderr.write_line(&e.to_string()); + ExecuteResult::Continue(2, Vec::new(), Vec::new()) + } + } + } + } +} + +async fn execute_arithmetic_expression( + arithmetic: Arithmetic, + mut state: ShellState, +) -> Result { + evaluate_arithmetic(&arithmetic, &mut state).await +} + +async fn evaluate_arithmetic( + arithmetic: &Arithmetic, + state: &mut ShellState, +) -> Result { + let mut result = ArithmeticResult::new(ArithmeticValue::Integer(0)); + for part in &arithmetic.parts { + result = Box::pin(evaluate_arithmetic_part(part, state)).await?; + } + Ok(result) +} + +async fn evaluate_arithmetic_part( + part: &ArithmeticPart, + state: &mut ShellState, +) -> Result { + match part { + ArithmeticPart::ParenthesesExpr(expr) => { + Box::pin(evaluate_arithmetic(expr, state)).await + } + ArithmeticPart::VariableAssignment { name, op, value } => { + let val = Box::pin(evaluate_arithmetic_part(value, state)).await?; + let applied_value = match op { + AssignmentOp::Assign => val.clone(), + _ => { + let var = state + .get_var(name) + .ok_or_else(|| miette::miette!("Undefined variable: {}", name))?; + let parsed_var = var.parse::().map_err(|e| { + miette::miette!("Failed to parse variable '{}': {}", name, e) + })?; + match op { + AssignmentOp::MultiplyAssign => val.checked_mul(&parsed_var), + AssignmentOp::DivideAssign => val.checked_div(&parsed_var), + AssignmentOp::ModuloAssign => val.checked_rem(&parsed_var), + AssignmentOp::AddAssign => val.checked_add(&parsed_var), + AssignmentOp::SubtractAssign => val.checked_sub(&parsed_var), + AssignmentOp::LeftShiftAssign => val.checked_shl(&parsed_var), + AssignmentOp::RightShiftAssign => val.checked_shr(&parsed_var), + AssignmentOp::BitwiseAndAssign => val.checked_and(&parsed_var), + AssignmentOp::BitwiseXorAssign => val.checked_xor(&parsed_var), + AssignmentOp::BitwiseOrAssign => val.checked_or(&parsed_var), + _ => unreachable!(), + }? + } + }; + state.apply_env_var(name, &applied_value.to_string()); + Ok( + applied_value + .clone() + .with_changes(vec![EnvChange::SetShellVar( + name.clone(), + applied_value.to_string(), + )]), + ) + } + ArithmeticPart::TripleConditionalExpr { + condition, + true_expr, + false_expr, + } => { + let cond = Box::pin(evaluate_arithmetic_part(condition, state)).await?; + if cond.is_zero() { + Box::pin(evaluate_arithmetic_part(true_expr, state)).await + } else { + Box::pin(evaluate_arithmetic_part(false_expr, state)).await + } + } + ArithmeticPart::BinaryArithmeticExpr { + left, + operator, + right, + } => { + let lhs = Box::pin(evaluate_arithmetic_part(left, state)).await?; + let rhs = Box::pin(evaluate_arithmetic_part(right, state)).await?; + apply_binary_op(lhs, *operator, rhs) + } + ArithmeticPart::BinaryConditionalExpr { + left, + operator, + right, + } => { + let lhs = Box::pin(evaluate_arithmetic_part(left, state)).await?; + let rhs = Box::pin(evaluate_arithmetic_part(right, state)).await?; + apply_conditional_binary_op(lhs, operator, rhs) + } + ArithmeticPart::UnaryArithmeticExpr { operator, operand } => { + let val = Box::pin(evaluate_arithmetic_part(operand, state)).await?; + apply_unary_op(*operator, val) + } + ArithmeticPart::PostArithmeticExpr { operand, .. } => { + let val = Box::pin(evaluate_arithmetic_part(operand, state)).await?; + Ok(val) + } + ArithmeticPart::Variable(name) => state + .get_var(name) + .and_then(|s| s.parse::().ok()) + .ok_or_else(|| { + miette::miette!("Undefined or non-integer variable: {}", name) + }), + ArithmeticPart::Number(num_str) => num_str + .parse::() + .map_err(|e| miette::miette!(e.to_string())), + } +} + +fn apply_binary_op( + lhs: ArithmeticResult, + op: BinaryArithmeticOp, + rhs: ArithmeticResult, +) -> Result { + match op { + BinaryArithmeticOp::Add => lhs.checked_add(&rhs), + BinaryArithmeticOp::Subtract => lhs.checked_sub(&rhs), + BinaryArithmeticOp::Multiply => lhs.checked_mul(&rhs), + BinaryArithmeticOp::Divide => lhs.checked_div(&rhs), + BinaryArithmeticOp::Modulo => lhs.checked_rem(&rhs), + BinaryArithmeticOp::Power => lhs.checked_pow(&rhs), + BinaryArithmeticOp::LeftShift => lhs.checked_shl(&rhs), + BinaryArithmeticOp::RightShift => lhs.checked_shr(&rhs), + BinaryArithmeticOp::BitwiseAnd => lhs.checked_and(&rhs), + BinaryArithmeticOp::BitwiseXor => lhs.checked_xor(&rhs), + BinaryArithmeticOp::BitwiseOr => lhs.checked_or(&rhs), + BinaryArithmeticOp::LogicalAnd => Ok(if lhs.is_zero() && rhs.is_zero() { + ArithmeticResult::new(ArithmeticValue::Integer(0)) + } else { + ArithmeticResult::new(ArithmeticValue::Integer(1)) + }), + BinaryArithmeticOp::LogicalOr => Ok(if !lhs.is_zero() || !rhs.is_zero() { + ArithmeticResult::new(ArithmeticValue::Integer(1)) + } else { + ArithmeticResult::new(ArithmeticValue::Integer(0)) + }), + } +} + +fn apply_conditional_binary_op( + lhs: ArithmeticResult, + op: &BinaryOp, + rhs: ArithmeticResult, +) -> Result { + match op { + BinaryOp::Equal => Ok(if lhs == rhs { + ArithmeticResult::new(ArithmeticValue::Integer(1)) + } else { + ArithmeticResult::new(ArithmeticValue::Integer(0)) + }), + BinaryOp::NotEqual => Ok(if lhs != rhs { + ArithmeticResult::new(ArithmeticValue::Integer(1)) + } else { + ArithmeticResult::new(ArithmeticValue::Integer(0)) + }), + BinaryOp::LessThan => Ok(if lhs < rhs { + ArithmeticResult::new(ArithmeticValue::Integer(1)) + } else { + ArithmeticResult::new(ArithmeticValue::Integer(0)) + }), + BinaryOp::LessThanOrEqual => Ok(if lhs <= rhs { + ArithmeticResult::new(ArithmeticValue::Integer(1)) + } else { + ArithmeticResult::new(ArithmeticValue::Integer(0)) + }), + BinaryOp::GreaterThan => Ok(if lhs > rhs { + ArithmeticResult::new(ArithmeticValue::Integer(1)) + } else { + ArithmeticResult::new(ArithmeticValue::Integer(0)) + }), + BinaryOp::GreaterThanOrEqual => Ok(if lhs >= rhs { + ArithmeticResult::new(ArithmeticValue::Integer(1)) + } else { + ArithmeticResult::new(ArithmeticValue::Integer(0)) + }), + } +} + +fn apply_unary_op( + op: UnaryArithmeticOp, + val: ArithmeticResult, +) -> Result { + match op { + UnaryArithmeticOp::Plus => Ok(val), + UnaryArithmeticOp::Minus => val.checked_neg(), + UnaryArithmeticOp::LogicalNot => Ok(if val.is_zero() { + ArithmeticResult::new(ArithmeticValue::Integer(1)) + } else { + ArithmeticResult::new(ArithmeticValue::Integer(0)) + }), + UnaryArithmeticOp::BitwiseNot => val.checked_not(), } } @@ -602,6 +826,134 @@ async fn execute_subshell( } } +async fn execute_if_clause( + if_clause: IfClause, + state: ShellState, + stdin: ShellPipeReader, + stdout: ShellPipeWriter, + mut stderr: ShellPipeWriter, +) -> ExecuteResult { + let mut current_condition = if_clause.condition; + let mut current_body = if_clause.then_body; + let mut current_else = if_clause.else_part; + + loop { + let condition_result = evaluate_condition( + current_condition, + &state, + stdin.clone(), + stderr.clone(), + ) + .await; + match condition_result { + Ok(true) => { + return execute_sequential_list( + current_body, + state, + stdin, + stdout, + stderr, + AsyncCommandBehavior::Yield, + ) + .await; + } + Ok(false) => match current_else { + Some(ElsePart::Elif(elif_clause)) => { + current_condition = elif_clause.condition; + current_body = elif_clause.then_body; + current_else = elif_clause.else_part; + } + Some(ElsePart::Else(else_body)) => { + return execute_sequential_list( + else_body, + state, + stdin, + stdout, + stderr, + AsyncCommandBehavior::Yield, + ) + .await; + } + None => { + return ExecuteResult::Continue(0, Vec::new(), Vec::new()); + } + }, + Err(err) => { + return err.into_exit_code(&mut stderr); + } + } + } +} + +async fn evaluate_condition( + condition: Condition, + state: &ShellState, + stdin: ShellPipeReader, + stderr: ShellPipeWriter, +) -> Result { + match condition.condition_inner { + ConditionInner::Binary { left, op, right } => { + let left = + evaluate_word(left, state, stdin.clone(), stderr.clone()).await?; + let right = + evaluate_word(right, state, stdin.clone(), stderr.clone()).await?; + + // transform the string comparison to a numeric comparison if possible + if let Ok(left) = left.parse::() { + if let Ok(right) = right.parse::() { + return Ok(match op { + BinaryOp::Equal => left == right, + BinaryOp::NotEqual => left != right, + BinaryOp::LessThan => left < right, + BinaryOp::LessThanOrEqual => left <= right, + BinaryOp::GreaterThan => left > right, + BinaryOp::GreaterThanOrEqual => left >= right, + }); + } + } + + match op { + BinaryOp::Equal => Ok(left == right), + BinaryOp::NotEqual => Ok(left != right), + BinaryOp::LessThan => Ok(left < right), + BinaryOp::LessThanOrEqual => Ok(left <= right), + BinaryOp::GreaterThan => Ok(left > right), + BinaryOp::GreaterThanOrEqual => Ok(left >= right), + } + } + ConditionInner::Unary { op, right } => { + let _right = + evaluate_word(right, state, stdin.clone(), stderr.clone()).await?; + match op { + Some(UnaryOp::FileExists) => todo!(), + Some(UnaryOp::BlockSpecial) => todo!(), + Some(UnaryOp::CharSpecial) => todo!(), + Some(UnaryOp::Directory) => todo!(), + Some(UnaryOp::RegularFile) => todo!(), + Some(UnaryOp::SetGroupId) => todo!(), + Some(UnaryOp::SymbolicLink) => todo!(), + Some(UnaryOp::StickyBit) => todo!(), + Some(UnaryOp::NamedPipe) => todo!(), + Some(UnaryOp::Readable) => todo!(), + Some(UnaryOp::SizeNonZero) => todo!(), + Some(UnaryOp::TerminalFd) => todo!(), + Some(UnaryOp::SetUserId) => todo!(), + Some(UnaryOp::Writable) => todo!(), + Some(UnaryOp::Executable) => todo!(), + Some(UnaryOp::OwnedByEffectiveGroupId) => todo!(), + Some(UnaryOp::ModifiedSinceLastRead) => todo!(), + Some(UnaryOp::OwnedByEffectiveUserId) => todo!(), + Some(UnaryOp::Socket) => todo!(), + Some(UnaryOp::NonEmptyString) => todo!(), + Some(UnaryOp::EmptyString) => todo!(), + Some(UnaryOp::VariableSet) => todo!(), + Some(UnaryOp::VariableNameReference) => todo!(), + None => todo!(), + } + } + } +} + async fn execute_simple_command( command: SimpleCommand, state: ShellState, @@ -611,8 +963,8 @@ async fn execute_simple_command( ) -> ExecuteResult { let args = evaluate_args(command.args, &state, stdin.clone(), stderr.clone()).await; - let args = match args { - Ok(args) => args, + let (args, changes) = match args { + Ok(args) => (args.value, args.changes), Err(err) => { return err.into_exit_code(&mut stderr); } @@ -629,7 +981,15 @@ async fn execute_simple_command( }; state.apply_env_var(&env_var.name, &value); } - execute_command_args(args, state, stdin, stdout, stderr).await + let result = execute_command_args(args, state, stdin, stdout, stderr).await; + match result { + ExecuteResult::Exit(code, handles) => ExecuteResult::Exit(code, handles), + ExecuteResult::Continue(code, env_changes, handles) => { + let mut combined_changes = env_changes.clone(); + combined_changes.extend(changes); + ExecuteResult::Continue(code, combined_changes, handles) + } + } } fn execute_command_args( @@ -642,8 +1002,19 @@ fn execute_command_args( let command_name = if args.is_empty() { String::new() } else { + // check if the command name is in the alias hashmap + if let Some(value) = state.alias_map().get(&args[0]) { + args.remove(0); + args = value + .iter() + .chain(args.iter()) + .cloned() + .collect::>(); + } + args.remove(0) }; + if state.token().is_cancelled() { Box::pin(future::ready(ExecuteResult::for_cancellation())) } else if let Some(stripped_name) = command_name.strip_prefix('!') { @@ -692,8 +1063,8 @@ pub async fn evaluate_args( state: &ShellState, stdin: ShellPipeReader, stderr: ShellPipeWriter, -) -> Result, EvaluateWordTextError> { - let mut result = Vec::new(); +) -> Result { + let mut result = WordEvalResult::new(Vec::new(), Vec::new()); for arg in args { let parts = evaluate_word_parts( arg.into_parts(), @@ -729,6 +1100,8 @@ pub enum EvaluateWordTextError { }, #[error("glob: no matches found '{}'", pattern)] NoFilesMatched { pattern: String }, + #[error("Failed to get home directory")] + FailedToGetHomeDirectory(miette::Error), } impl EvaluateWordTextError { @@ -738,12 +1111,18 @@ impl EvaluateWordTextError { } } +impl From for EvaluateWordTextError { + fn from(err: miette::Error) -> Self { + Self::FailedToGetHomeDirectory(err) + } +} + fn evaluate_word_parts( parts: Vec, state: &ShellState, stdin: ShellPipeReader, stderr: ShellPipeWriter, -) -> LocalBoxFuture, EvaluateWordTextError>> { +) -> LocalBoxFuture> { #[derive(Debug)] enum TextPart { Quoted(String), @@ -772,7 +1151,7 @@ fn evaluate_word_parts( state: &ShellState, text_parts: Vec, is_quoted: bool, - ) -> Result, EvaluateWordTextError> { + ) -> Result { if !is_quoted && text_parts .iter() @@ -842,13 +1221,16 @@ fn evaluate_word_parts( }) .collect::>() }; - Ok(paths) + Ok(WordEvalResult::new(paths, Vec::new())) } } Err(err) => Err(EvaluateWordTextError::InvalidPattern { pattern, err }), } } else { - Ok(vec![text_parts_to_string(text_parts)]) + Ok(WordEvalResult { + value: vec![text_parts_to_string(text_parts)], + changes: Vec::new(), + }) } } @@ -858,10 +1240,12 @@ fn evaluate_word_parts( state: &ShellState, stdin: ShellPipeReader, stderr: ShellPipeWriter, - ) -> LocalBoxFuture, EvaluateWordTextError>> { + ) -> LocalBoxFuture> { // recursive async, so requires boxing + let mut changes: Vec = Vec::new(); + async move { - let mut result = Vec::new(); + let mut result = WordEvalResult::new(Vec::new(), Vec::new()); let mut current_text = Vec::new(); for part in parts { let evaluation_result_text = match part { @@ -896,6 +1280,30 @@ fn evaluate_word_parts( current_text.push(TextPart::Quoted(text)); continue; } + WordPart::Tilde(tilde_prefix) => { + if tilde_prefix.only_tilde() { + let home_str = dirs::home_dir() + .ok_or_else(|| miette::miette!("Failed to get home directory"))? + .display() + .to_string(); + current_text.push(TextPart::Text(home_str)); + } else { + todo!("tilde expansion with user name is not supported"); + } + continue; + } + WordPart::Arithmetic(arithmetic) => { + let arithmetic_result = + execute_arithmetic_expression(arithmetic, state.clone()).await?; + current_text.push(TextPart::Text(arithmetic_result.to_string())); + changes.extend(arithmetic_result.changes); + continue; + } + WordPart::ExitStatus => { + let exit_code = state.last_command_exit_code(); + current_text.push(TextPart::Text(exit_code.to_string())); + continue; + } }; // This text needs to be turned into a vector of strings. diff --git a/crates/deno_task_shell/src/shell/fs_util.rs b/crates/deno_task_shell/src/shell/fs_util.rs index 043b81f..a13aa77 100644 --- a/crates/deno_task_shell/src/shell/fs_util.rs +++ b/crates/deno_task_shell/src/shell/fs_util.rs @@ -3,11 +3,12 @@ use std::path::Path; use std::path::PathBuf; -use anyhow::Result; +use miette::IntoDiagnostic; +use miette::Result; /// Similar to `std::fs::canonicalize()` but strips UNC prefixes on Windows. pub fn canonicalize_path(path: &Path) -> Result { - let path = path.canonicalize()?; + let path = path.canonicalize().into_diagnostic()?; #[cfg(windows)] return Ok(strip_unc_prefix(path)); #[cfg(not(windows))] diff --git a/crates/deno_task_shell/src/shell/mod.rs b/crates/deno_task_shell/src/shell/mod.rs index dac1dac..df180b3 100644 --- a/crates/deno_task_shell/src/shell/mod.rs +++ b/crates/deno_task_shell/src/shell/mod.rs @@ -17,13 +17,9 @@ pub use types::ShellPipeReader; pub use types::ShellPipeWriter; pub use types::ShellState; +pub mod fs_util; + mod command; mod commands; mod execute; -mod fs_util; mod types; - -#[cfg(test)] -mod test; -#[cfg(test)] -mod test_builder; diff --git a/crates/deno_task_shell/src/shell/test.rs b/crates/deno_task_shell/src/shell/test.rs deleted file mode 100644 index bdb9f73..0000000 --- a/crates/deno_task_shell/src/shell/test.rs +++ /dev/null @@ -1,1483 +0,0 @@ -// Copyright 2018-2024 the Deno authors. MIT license. - -use futures::FutureExt; - -use super::test_builder::TestBuilder; -use super::types::ExecuteResult; - -const FOLDER_SEPARATOR: char = if cfg!(windows) { '\\' } else { '/' }; - -#[tokio::test] -async fn commands() { - TestBuilder::new() - .command("echo 1") - .assert_stdout("1\n") - .run() - .await; - - TestBuilder::new() - .command("echo 1 2 3") - .assert_stdout("1 2 3\n") - .run() - .await; - - TestBuilder::new() - .command(r#"echo "1 2 3""#) - .assert_stdout("1 2 3\n") - .run() - .await; - - TestBuilder::new() - .command(r"echo 1 2\ \ \ 3") - .assert_stdout("1 2 3\n") - .run() - .await; - - TestBuilder::new() - .command(r#"echo "1 2\ \ \ 3""#) - .assert_stdout("1 2\\ \\ \\ 3\n") - .run() - .await; - - TestBuilder::new() - .command(r#"echo test$(echo "1 2")"#) - .assert_stdout("test1 2\n") - .run() - .await; - - TestBuilder::new() - .command(r#"TEST="1 2" ; echo $TEST"#) - .assert_stdout("1 2\n") - .run() - .await; - - TestBuilder::new() - .command( - r#"VAR=1 deno eval 'console.log(Deno.env.get("VAR"))' && echo $VAR"#, - ) - .assert_stdout("1\n\n") - .run() - .await; - - TestBuilder::new() - .command(r#"VAR=1 VAR2=2 deno eval 'console.log(Deno.env.get("VAR") + Deno.env.get("VAR2"))'"#) - .assert_stdout("12\n") - .run() - .await; - - TestBuilder::new() - .command( - r#"EMPTY= deno eval 'console.log(`EMPTY: ${Deno.env.get("EMPTY")}`)'"#, - ) - .assert_stdout("EMPTY: \n") - .run() - .await; - - TestBuilder::new() - .command(r#""echo" "1""#) - .assert_stdout("1\n") - .run() - .await; - - TestBuilder::new() - .command(r#""echo" "*""#) - .assert_stdout("*\n") - .run() - .await; - - TestBuilder::new() - .command("echo test-dashes") - .assert_stdout("test-dashes\n") - .run() - .await; - - TestBuilder::new() - .command("echo 'a/b'/c") - .assert_stdout("a/b/c\n") - .run() - .await; - - TestBuilder::new() - .command("echo 'a/b'ctest\"te st\"'asdf'") - .assert_stdout("a/bctestte stasdf\n") - .run() - .await; - - TestBuilder::new() - .command("echo --test=\"2\" --test='2' test\"TEST\" TEST'test'TEST 'test''test' test'test'\"test\" \"test\"\"test\"'test'") - .assert_stdout("--test=2 --test=2 testTEST TESTtestTEST testtest testtesttest testtesttest\n") - .run() - .await; - - TestBuilder::new() - .command("deno eval 'console.log(1)'") - .env_var("PATH", "") - .assert_stderr("deno: command not found\n") - .assert_exit_code(127) - .run() - .await; - - TestBuilder::new().command("unset").run().await; -} - -#[tokio::test] -async fn boolean_logic() { - TestBuilder::new() - .command("echo 1 && echo 2 || echo 3") - .assert_stdout("1\n2\n") - .run() - .await; - - TestBuilder::new() - .command("echo 1 || echo 2 && echo 3") - .assert_stdout("1\n3\n") - .run() - .await; - - TestBuilder::new() - .command("echo 1 || (echo 2 && echo 3)") - .assert_stdout("1\n") - .run() - .await; - - TestBuilder::new() - .command("false || false || (echo 2 && false) || echo 3") - .assert_stdout("2\n3\n") - .run() - .await; -} - -#[tokio::test] -async fn exit() { - TestBuilder::new() - .command("exit 1") - .assert_exit_code(1) - .run() - .await; - - TestBuilder::new() - .command("exit 5") - .assert_exit_code(5) - .run() - .await; - - TestBuilder::new() - .command("exit 258 && echo 1") - .assert_exit_code(2) - .run() - .await; - - TestBuilder::new() - .command("(exit 0) && echo 1") - .assert_stdout("1\n") - .run() - .await; - - TestBuilder::new() - .command("(exit 1) && echo 1") - .assert_exit_code(1) - .run() - .await; - - TestBuilder::new() - .command("echo 1 && (exit 1)") - .assert_stdout("1\n") - .assert_exit_code(1) - .run() - .await; - - TestBuilder::new() - .command("exit ; echo 2") - .assert_exit_code(1) - .run() - .await; - - TestBuilder::new() - .command("exit bad args") - .assert_stderr("exit: too many arguments\n") - .assert_exit_code(2) - .run() - .await; -} - -#[tokio::test] -async fn async_commands() { - TestBuilder::new() - .command("sleep 0.1 && echo 2 & echo 1") - .assert_stdout("1\n2\n") - .run() - .await; - - TestBuilder::new() - .command("(sleep 0.1 && echo 2 &) ; echo 1") - .assert_stdout("1\n2\n") - .run() - .await; - - TestBuilder::new() - .command("(sleep 0.1 && echo 2) & echo 1") - .assert_stdout("1\n2\n") - .run() - .await; - - TestBuilder::new() - .command( - "$(sleep 0.1 && echo 1 & $(sleep 0.2 && echo 2 & echo echo) & echo echo)", - ) - .assert_stdout("1 2\n") - .run() - .await; - - TestBuilder::new() - .command("exit 1 & exit 0") - .assert_exit_code(1) - .run() - .await; - - // should not output because the `exit 1` will cancel the sleep - TestBuilder::new() - .command("sleep 5 && echo 1 & exit 1") - .assert_exit_code(1) - .run() - .await; - - // should fail when async command exits - TestBuilder::new() - .command("exit 1 & exit 0") - .assert_exit_code(1) - .run() - .await; - - // should fail when async command fails and cancel any running command - TestBuilder::new() - .command("deno eval 'Deno.exit(1)' & sleep 5 && echo 2 & echo 1") - .assert_stdout("1\n") - .assert_exit_code(1) - .run() - .await; - - // should cancel running command - TestBuilder::new() - .command("sleep 10 & sleep 0.5 && deno eval 'Deno.exit(2)' & deno eval 'console.log(1); setTimeout(() => { console.log(3) }, 10_000);'") - .assert_stdout("1\n") - .assert_exit_code(2) - .run() - .await; - - // should be able to opt out by doing an `|| exit 0` - TestBuilder::new() - .command("deno eval 'Deno.exit(1)' || exit 0 & echo 1") - .assert_stdout("1\n") - .assert_exit_code(0) - .run() - .await; -} - -#[tokio::test] -async fn command_substitution() { - TestBuilder::new() - .command("echo $(echo 1)") - .assert_stdout("1\n") - .run() - .await; - - TestBuilder::new() - .command("echo $(echo 1 && echo 2)") - .assert_stdout("1 2\n") - .run() - .await; - - // async inside subshell should wait - TestBuilder::new() - .command("$(sleep 0.1 && echo 1 & echo echo) 2") - .assert_stdout("1 2\n") - .run() - .await; - TestBuilder::new() - .command("$(sleep 0.1 && echo 1 && exit 5 &) ; echo 2") - .assert_stdout("2\n") - .assert_stderr("1: command not found\n") - .run() - .await; -} - -#[tokio::test] -async fn shell_variables() { - TestBuilder::new() - .command(r#"echo $VAR && VAR=1 && echo $VAR && deno eval 'console.log(Deno.env.get("VAR"))'"#) - .assert_stdout("\n1\nundefined\n") - .run() - .await; - - TestBuilder::new() - .command(r#"VAR=1 && echo $VAR$VAR"#) - .assert_stdout("11\n") - .run() - .await; - - TestBuilder::new() - .command(r#"VAR=1 && echo Test$VAR && echo $(echo "Test: $VAR") ; echo CommandSub$($VAR); echo $ ; echo \$VAR"#) - .assert_stdout("Test1\nTest: 1\nCommandSub\n$\n$VAR\n") - .assert_stderr("1: command not found\n") - .run() - .await; -} - -#[tokio::test] -async fn env_variables() { - TestBuilder::new() - .command(r#"echo $VAR && export VAR=1 && echo $VAR && deno eval 'console.log(Deno.env.get("VAR"))'"#) - .assert_stdout("\n1\n1\n") - .run() - .await; - - TestBuilder::new() - .command(r#"export VAR=1 VAR2=testing VAR3="test this out" && echo $VAR $VAR2 $VAR3"#) - .assert_stdout("1 testing test this out\n") - .run() - .await; -} - -#[tokio::test] -async fn exit_code_var() { - TestBuilder::new() - .command(r#"echo $? ; echo $? ; false ; echo $?"#) - .assert_stdout("\n0\n1\n") - .run() - .await; - TestBuilder::new() - .command(r#"(false || echo $?) && echo $?"#) - .assert_stdout("1\n0\n") - .run() - .await; - TestBuilder::new() - .command(r#"! false && echo $?"#) - .assert_stdout("0\n") - .run() - .await; - TestBuilder::new() - .command(r#"(deno eval 'Deno.exit(25)') || echo $?"#) - .assert_stdout("25\n") - .run() - .await; -} - -#[tokio::test] -async fn sequential_lists() { - TestBuilder::new() - .command(r#"echo 1 ; sleep 0.1 && echo 4 & echo 2 ; echo 3;"#) - .assert_stdout("1\n2\n3\n4\n") - .run() - .await; -} - -#[tokio::test] -async fn pipeline() { - TestBuilder::new() - .command(r#"echo 1 | deno eval 'await Deno.stdin.readable.pipeTo(Deno.stdout.writable)'"#) - .assert_stdout("1\n") - .run() - .await; - - TestBuilder::new() - .command(r#"echo 1 | echo 2 && echo 3"#) - .assert_stdout("2\n3\n") - .run() - .await; - - TestBuilder::new() - .command(r#"echo $(sleep 0.1 && echo 2 & echo 1) | deno eval 'await Deno.stdin.readable.pipeTo(Deno.stdout.writable)'"#) - .assert_stdout("1 2\n") - .run() - .await; - - TestBuilder::new() - .command(r#"echo 2 | echo 1 | deno eval 'await Deno.stdin.readable.pipeTo(Deno.stdout.writable)'"#) - .assert_stdout("1\n") - .run() - .await; - - TestBuilder::new() - .command(r#"deno eval 'console.log(1); console.error(2);' | deno eval 'await Deno.stdin.readable.pipeTo(Deno.stdout.writable)'"#) - .assert_stdout("1\n") - .assert_stderr("2\n") - .run() - .await; - - // stdout and stderr pipeline - - TestBuilder::new() - .command(r#"deno eval 'console.log(1); console.error(2);' |& deno eval 'await Deno.stdin.readable.pipeTo(Deno.stdout.writable)'"#) - .assert_stdout("1\n2\n") - .run() - .await; - - TestBuilder::new() - // add bit of a delay while outputting stdout so that it doesn't race with stderr - .command(r#"deno eval 'console.log(1); console.error(2);' | deno eval 'setTimeout(async () => { await Deno.stdin.readable.pipeTo(Deno.stderr.writable) }, 10)' |& deno eval 'await Deno.stdin.readable.pipeTo(Deno.stderr.writable)'"#) - // still outputs 2 because the first command didn't pipe stderr - .assert_stderr("2\n1\n") - .run() - .await; - - // |& pipeline should still pipe stdout - TestBuilder::new() - .command(r#"echo 1 |& deno eval 'await Deno.stdin.readable.pipeTo(Deno.stdout.writable)'"#) - .assert_stdout("1\n") - .run() - .await; - - // pipeline with redirect - TestBuilder::new() - .command(r#"echo 1 | deno eval 'await Deno.stdin.readable.pipeTo(Deno.stdout.writable)' > output.txt"#) - .assert_file_equals("output.txt", "1\n") - .run() - .await; - - // pipeline with stderr redirect - TestBuilder::new() - .command(r#"echo 1 | deno eval 'await Deno.stdin.readable.pipeTo(Deno.stderr.writable)' 2> output.txt"#) - .assert_file_equals("output.txt", "1\n") - .run() - .await; -} - -#[tokio::test] -async fn negated() { - TestBuilder::new() - .command(r#"! echo 1 && echo 2"#) - .assert_stdout("1\n") - .assert_exit_code(1) - .run() - .await; - TestBuilder::new() - .command(r#"! echo 1 || echo 2"#) - .assert_stdout("1\n2\n") - .run() - .await; - TestBuilder::new() - .command(r#"! (echo 1 | echo 2 && echo 3) || echo 4"#) - .assert_stdout("2\n3\n4\n") - .run() - .await; - TestBuilder::new() - .command(r#"! echo 1 | echo 2 && echo 3"#) - .assert_stdout("2\n") - .assert_exit_code(1) - .run() - .await; - TestBuilder::new() - .command(r#"! (exit 5) && echo 1"#) - .assert_stdout("1\n") - .run() - .await; - TestBuilder::new() - .command(r#"! exit 5 && echo 1"#) - .assert_exit_code(5) - .run() - .await; - TestBuilder::new() - .command(r#"! echo 1 && echo 2 &"#) - .assert_stdout("1\n") - // differing behaviour to shells, where this async command will actually fail - .assert_exit_code(1) - .run() - .await; - - // test no spaces - TestBuilder::new() - .command(r#"!echo 1 && echo 2"#) - .assert_stderr("History expansion is not supported:\n !echo\n ~\n\nPerhaps you meant to add a space after the exclamation point to negate the command?\n ! echo\n") - .assert_exit_code(1) - .run() - .await; -} - -#[tokio::test] -async fn redirects_output() { - TestBuilder::new() - .command(r#"echo 5 6 7 > test.txt"#) - .assert_file_equals("test.txt", "5 6 7\n") - .run() - .await; - - TestBuilder::new() - .command(r#"echo 1 2 3 && echo 1 > test.txt"#) - .assert_stdout("1 2 3\n") - .assert_file_equals("test.txt", "1\n") - .run() - .await; - - // subdir - TestBuilder::new() - .command(r#"mkdir subdir && cd subdir && echo 1 2 3 > test.txt"#) - .assert_file_equals("subdir/test.txt", "1 2 3\n") - .run() - .await; - - // absolute path - TestBuilder::new() - .command(r#"echo 1 2 3 > "$PWD/test.txt""#) - .assert_file_equals("test.txt", "1 2 3\n") - .run() - .await; - - // stdout - TestBuilder::new() - .command(r#"deno eval 'console.log(1); console.error(5)' 1> test.txt"#) - .assert_stderr("5\n") - .assert_file_equals("test.txt", "1\n") - .run() - .await; - - // stderr - TestBuilder::new() - .command(r#"deno eval 'console.log(1); console.error(5)' 2> test.txt"#) - .assert_stdout("1\n") - .assert_file_equals("test.txt", "5\n") - .run() - .await; - - // invalid fd - TestBuilder::new() - .command(r#"echo 2 3> test.txt"#) - .ensure_temp_dir() - .assert_stderr( - "only redirecting to stdout (1) and stderr (2) is supported\n", - ) - .assert_exit_code(1) - .run() - .await; - - // /dev/null - TestBuilder::new() - .command(r#"deno eval 'console.log(1); console.error(5)' 2> /dev/null"#) - .assert_stdout("1\n") - .run() - .await; - - // appending - TestBuilder::new() - .command(r#"echo 1 > test.txt && echo 2 >> test.txt"#) - .assert_file_equals("test.txt", "1\n2\n") - .run() - .await; - - // &> and &>> redirect - TestBuilder::new() - .command( - concat!( - "deno eval 'console.log(1); setTimeout(() => console.error(23), 10)' &> file.txt &&", - "deno eval 'console.log(456); setTimeout(() => console.error(789), 10)' &>> file.txt" - ) - ) - .assert_file_equals("file.txt", "1\n23\n456\n789\n") - .run() - .await; - - // multiple arguments after re-direct - TestBuilder::new() - .command(r"export TwoArgs=testing\ this && echo 1 > $TwoArgs") - .assert_stderr(concat!( - "redirect path must be 1 argument, but found 2 ", - "(testing this). Did you mean to quote it (ex. \"testing this\")?\n" - )) - .assert_exit_code(1) - .run() - .await; - - // zero arguments after re-direct - TestBuilder::new() - .command(r#"echo 1 > $EMPTY"#) - .assert_stderr("redirect path must be 1 argument, but found 0\n") - .assert_exit_code(1) - .run() - .await; - - TestBuilder::new() - .command(r#"echo 1 >&3"#) - .assert_stderr( - "deno_task_shell: output redirecting file descriptors beyond stdout and stderr is not implemented\n", - ) - .assert_exit_code(1) - .run() - .await; - - TestBuilder::new() - .command(r#"echo 1 >&1"#) - .assert_stdout("1\n") - .assert_exit_code(0) - .run() - .await; - - TestBuilder::new() - .command(r#"echo 1 >&2"#) - .assert_stderr("1\n") - .assert_exit_code(0) - .run() - .await; - - TestBuilder::new() - .command(r#"deno eval 'console.error(2)' 2>&1"#) - .assert_stdout("2\n") - .assert_exit_code(0) - .run() - .await; -} - -#[tokio::test] -async fn redirects_input() { - TestBuilder::new() - .file("test.txt", "Hi!") - .command(r#"cat - < test.txt"#) - .assert_stdout("Hi!") - .run() - .await; - - TestBuilder::new() - .file("test.txt", "Hi!\n") - .command(r#"cat - < test.txt && echo There"#) - .assert_stdout("Hi!\nThere\n") - .run() - .await; - - TestBuilder::new() - .command(r#"cat - <&0"#) - .assert_stderr( - "deno_task_shell: input redirecting file descriptors is not implemented\n", - ) - .assert_exit_code(1) - .run() - .await; -} - -#[tokio::test] -async fn pwd() { - TestBuilder::new() - .directory("sub_dir") - .file("file.txt", "test") - .command("pwd && cd sub_dir && pwd && cd ../ && pwd") - // the actual temp directory will get replaced here - .assert_stdout(&format!( - "$TEMP_DIR\n$TEMP_DIR{FOLDER_SEPARATOR}sub_dir\n$TEMP_DIR\n" - )) - .run() - .await; - - TestBuilder::new() - .command("pwd -M") - .assert_stderr("pwd: unsupported flag: -M\n") - .assert_exit_code(1) - .run() - .await; -} - -#[tokio::test] -async fn subshells() { - TestBuilder::new() - .command("(export TEST=1) && echo $TEST") - .assert_stdout("\n") - .assert_exit_code(0) - .run() - .await; - TestBuilder::new() - .directory("sub_dir") - .command("echo $PWD && (cd sub_dir && echo $PWD) && echo $PWD") - .assert_stdout(&format!( - "$TEMP_DIR\n$TEMP_DIR{FOLDER_SEPARATOR}sub_dir\n$TEMP_DIR\n" - )) - .assert_exit_code(0) - .run() - .await; - TestBuilder::new() - .command( - "export TEST=1 && (echo $TEST && unset TEST && echo $TEST) && echo $TEST", - ) - .assert_stdout("1\n\n1\n") - .assert_exit_code(0) - .run() - .await; - TestBuilder::new() - .command("(exit 1) && echo 1") - .assert_exit_code(1) - .run() - .await; - TestBuilder::new() - .command("(exit 1) || echo 1") - .assert_stdout("1\n") - .assert_exit_code(0) - .run() - .await; -} - -#[tokio::test] -#[cfg(unix)] -async fn pwd_logical() { - TestBuilder::new() - .directory("main") - .command("ln -s main symlinked_main && cd symlinked_main && pwd && pwd -L") - .assert_stdout("$TEMP_DIR/symlinked_main\n$TEMP_DIR/main\n") - .run() - .await; -} - -#[tokio::test] -async fn cat() { - // no args - TestBuilder::new() - .command("cat") - .stdin("hello") - .assert_stdout("hello") - .run() - .await; - - // dash - TestBuilder::new() - .command("cat -") - .stdin("hello") - .assert_stdout("hello") - .run() - .await; - - // file - TestBuilder::new() - .command("cat file") - .file("file", "test") - .assert_stdout("test") - .run() - .await; - - // multiple files - TestBuilder::new() - .command("cat file1 file2") - .file("file1", "test") - .file("file2", "other") - .assert_stdout("testother") - .run() - .await; - - // multiple files and stdin - TestBuilder::new() - .command("cat file1 file2 -") - .file("file1", "test\n") - .file("file2", "other\n") - .stdin("hello") - .assert_stdout("test\nother\nhello") - .run() - .await; - - // multiple files and stdin different order - TestBuilder::new() - .command("cat file1 - file2") - .file("file1", "test\n") - .file("file2", "other\n") - .stdin("hello\n") - .assert_stdout("test\nhello\nother\n") - .run() - .await; - - // file containing a command to evaluate - TestBuilder::new() - .command("$(cat file)") - .file("file", "echo hello") - .assert_stdout("hello\n") - .run() - .await; -} - -#[tokio::test] -async fn head() { - // no args - TestBuilder::new() - .command("head") - .stdin( - "foo\nbar\nbaz\nqux\nquuux\ncorge\ngrault\ngarply\nwaldo\nfred\nplugh\n", - ) - .assert_stdout( - "foo\nbar\nbaz\nqux\nquuux\ncorge\ngrault\ngarply\nwaldo\nfred\n", - ) - .run() - .await; - - // dash - TestBuilder::new() - .command("head -") - .stdin( - "foo\nbar\nbaz\nqux\nquuux\ncorge\ngrault\ngarply\nwaldo\nfred\nplugh\n", - ) - .assert_stdout( - "foo\nbar\nbaz\nqux\nquuux\ncorge\ngrault\ngarply\nwaldo\nfred\n", - ) - .run() - .await; - - // file - TestBuilder::new() - .command("head file") - .file( - "file", - "foo\nbar\nbaz\nqux\nquuux\ncorge\ngrault\ngarply\nwaldo\nfred\nplugh\n", - ) - .assert_stdout( - "foo\nbar\nbaz\nqux\nquuux\ncorge\ngrault\ngarply\nwaldo\nfred\n", - ) - .run() - .await; - - // dash + longer than internal buffer (512) - TestBuilder::new() - .command("head -") - .stdin( - "foo\nbar\nbaz\nqux\nquuux\ncorge\ngrault\ngarply\nwaldo\nfred\nplugh\n" - .repeat(10) - .as_str(), - ) - .assert_stdout( - "foo\nbar\nbaz\nqux\nquuux\ncorge\ngrault\ngarply\nwaldo\nfred\n", - ) - .run() - .await; - - // file + longer than internal buffer (512) - TestBuilder::new() - .command("head file") - .file( - "file", - "foo\nbar\nbaz\nqux\nquuux\ncorge\ngrault\ngarply\nwaldo\nfred\nplugh\n" - .repeat(1024) - .as_str(), - ) - .assert_stdout( - "foo\nbar\nbaz\nqux\nquuux\ncorge\ngrault\ngarply\nwaldo\nfred\n", - ) - .run() - .await; - - // shorter than 10 lines - TestBuilder::new() - .command("head") - .stdin("foo\nbar") - .assert_stdout("foo\nbar") - .run() - .await; - - // -n - TestBuilder::new() - .command("head -n 2") - .stdin("foo\nbar\nbaz\nqux\nquuux") - .assert_stdout("foo\nbar\n") - .run() - .await; - - // --lines - TestBuilder::new() - .command("head --lines=3") - .stdin("foo\nbar\nbaz\nqux\nquuux") - .assert_stdout("foo\nbar\nbaz\n") - .run() - .await; -} - -// Basic integration tests as there are unit tests in the commands -#[tokio::test] -async fn mv() { - // single file - TestBuilder::new() - .command("mv file1.txt file2.txt") - .file("file1.txt", "test") - .assert_not_exists("file1.txt") - .assert_exists("file2.txt") - .run() - .await; - - // multiple files to folder - TestBuilder::new() - .command("mkdir sub_dir && mv file1.txt file2.txt sub_dir") - .file("file1.txt", "test1") - .file("file2.txt", "test2") - .assert_not_exists("file1.txt") - .assert_not_exists("file2.txt") - .assert_exists("sub_dir/file1.txt") - .assert_exists("sub_dir/file2.txt") - .run() - .await; - - // error message - TestBuilder::new() - .command("mv file1.txt file2.txt") - .assert_exit_code(1) - .assert_stderr(&format!( - "mv: could not move file1.txt to file2.txt: {}\n", - no_such_file_error_text() - )) - .run() - .await; -} - -// Basic integration tests as there are unit tests in the commands -#[tokio::test] -async fn cp() { - // single file - TestBuilder::new() - .command("cp file1.txt file2.txt") - .file("file1.txt", "test") - .assert_exists("file1.txt") - .assert_exists("file2.txt") - .run() - .await; - - // multiple files to folder - TestBuilder::new() - .command("mkdir sub_dir && cp file1.txt file2.txt sub_dir") - .file("file1.txt", "test1") - .file("file2.txt", "test2") - .assert_exists("file1.txt") - .assert_exists("file2.txt") - .assert_exists("sub_dir/file1.txt") - .assert_exists("sub_dir/file2.txt") - .run() - .await; - - // error message - TestBuilder::new() - .command("cp file1.txt file2.txt") - .assert_exit_code(1) - .assert_stderr(&format!( - "cp: could not copy file1.txt to file2.txt: {}\n", - no_such_file_error_text() - )) - .run() - .await; -} - -// Basic integration tests as there are unit tests in the commands -#[tokio::test] -async fn mkdir() { - TestBuilder::new() - .command("mkdir sub_dir") - .assert_exists("sub_dir") - .run() - .await; - - // error message - TestBuilder::new() - .command("mkdir file.txt") - .file("file.txt", "test") - .assert_stderr("mkdir: cannot create directory 'file.txt': File exists\n") - .assert_exit_code(1) - .run() - .await; -} - -// Basic integration tests as there are unit tests in the commands -#[tokio::test] -async fn rm() { - TestBuilder::new() - .command("mkdir sub_dir && rm -d sub_dir && rm file.txt") - .file("file.txt", "") - .assert_not_exists("sub_dir") - .assert_not_exists("file.txt") - .run() - .await; - - // error message - TestBuilder::new() - .command("rm file.txt") - .assert_stderr(&format!( - "rm: cannot remove 'file.txt': {}\n", - no_such_file_error_text() - )) - .assert_exit_code(1) - .run() - .await; -} - -// Basic integration tests as there are unit tests in the commands -#[tokio::test] -async fn unset() { - // Unset 1 shell variable - TestBuilder::new() - .command( - r#"VAR1=1 && VAR2=2 && VAR3=3 && unset VAR1 && echo $VAR1 $VAR2 $VAR3"#, - ) - .assert_stdout("2 3\n") - .run() - .await; - - // Unset 1 env variable - TestBuilder::new() - .command(r#"export VAR1=1 VAR2=2 VAR3=3 && unset VAR1 && deno eval 'for (let i = 1; i <= 3; i++) { const val = Deno.env.get(`VAR${i}`); console.log(val); }'"#) - .assert_stdout("undefined\n2\n3\n") - .run() - .await; - - // Unset 2 shell variables - TestBuilder::new() - .command( - r#"VAR1=1 && VAR2=2 && VAR3=3 && unset VAR1 VAR2 && echo $VAR1 $VAR2 $VAR3"#, - ) - .assert_stdout("3\n") - .run() - .await; - - // Unset 2 env variables - TestBuilder::new() - .command(r#"export VAR1=1 VAR2=2 VAR3=3 && unset VAR1 VAR2 && deno eval 'for (let i = 1; i <= 3; i++) { const val = Deno.env.get(`VAR${i}`); console.log(val); }'"#) - .assert_stdout("undefined\nundefined\n3\n") - .run() - .await; - - // Unset 2 shell variables with -v enabled - TestBuilder::new() - .command( - r#"VAR1=1 && VAR2=2 && VAR3=3 && unset -v VAR1 VAR2 && echo $VAR1 $VAR2 $VAR3"#, - ) - .assert_stdout("3\n") - .run() - .await; - - // Unset 1 env variable with -v enabled - TestBuilder::new() - .command(r#"export VAR1=1 VAR2=2 VAR3=3 && unset -v VAR1 && deno eval 'for (let i = 1; i <= 3; i++) { const val = Deno.env.get(`VAR${i}`); console.log(val); }'"#) - .assert_stdout("undefined\n2\n3\n") - .run() - .await; - - // Unset 2 env variables with -v enabled - TestBuilder::new() - .command(r#"export VAR1=1 VAR2=2 VAR3=3 && unset -v VAR1 VAR2 && deno eval 'for (let i = 1; i <= 3; i++) { const val = Deno.env.get(`VAR${i}`); console.log(val); }'"#) - .assert_stdout("undefined\nundefined\n3\n") - .run() - .await; - - // Unset 1 shell variable and 1 env variable at the same time - TestBuilder::new() - .command( - r#"VAR=1 && export ENV_VAR=2 && unset VAR ENV_VAR && echo $VAR $ENV_VAR"#, - ) - .assert_stdout("\n") - .run() - .await; - - // -f is not supported - TestBuilder::new() - .command(r#"export VAR=42 && unset -f VAR"#) - .assert_stderr("unset: unsupported flag: -f\n") - .assert_exit_code(1) - .run() - .await; -} - -#[tokio::test] -async fn xargs() { - TestBuilder::new() - .command("echo '1 2 3 ' | xargs") - .assert_stdout("1 2 3\n") - .run() - .await; - - TestBuilder::new() - .command("echo '1 2 \t\t\t3 ' | xargs echo test") - .assert_stdout("test 1 2 3\n") - .run() - .await; - - TestBuilder::new() - .command(r#"deno eval "console.log('testing\nthis')" | xargs"#) - .assert_stdout("testing this\n") - .run() - .await; - - // \n delimiter - TestBuilder::new() - .command(r#"deno eval "console.log('testing this out\n\ntest\n')" | xargs -d \n deno eval "console.log(Deno.args)""#) - .assert_stdout("[ \"testing this out\", \"\", \"test\", \"\" ]\n") - .run() - .await; - - // \0 delimiter - TestBuilder::new() - .command(r#"deno eval "console.log('testing this out\ntest\0other')" | xargs -0 deno eval "console.log(Deno.args)""#) - .assert_stdout("[ \"testing this out\\ntest\", \"other\\n\" ]\n") - .run() - .await; - - // unmatched single quote - TestBuilder::new() - .command(r#"deno eval "console.log(\"'test\")" | xargs"#) - .assert_stderr("xargs: unmatched quote; by default quotes are special to xargs unless you use the -0 option\n") - .assert_exit_code(1) - .run() - .await; - - // unmatched double quote - TestBuilder::new() - .command(r#"deno eval "console.log('\"test')" | xargs"#) - .assert_stderr("xargs: unmatched quote; by default quotes are special to xargs unless you use the -0 option\n") - .assert_exit_code(1) - .run() - .await; - - // test reading env file - TestBuilder::new() - .file( - ".env", - r#"VAR1="testing" -VAR2="other" -"#, - ) - // most likely people would want to do `export $(grep -v '^#' .env | xargs)` though - // in order to remove comments... - .command("export $(cat .env | xargs) && echo $VAR1 $VAR2") - .assert_stdout("testing other\n") - .run() - .await; -} - -#[tokio::test] -async fn stdin() { - TestBuilder::new() - .command(r#"deno eval "const b = new Uint8Array(1);Deno.stdin.readSync(b);console.log(b)" && deno eval "const b = new Uint8Array(1);Deno.stdin.readSync(b);console.log(b)""#) - .stdin("12345") - .assert_stdout("Uint8Array(1) [ 49 ]\nUint8Array(1) [ 50 ]\n") - .run() - .await; - - TestBuilder::new() - .command(r#"echo "12345" | (deno eval "const b = new Uint8Array(1);Deno.stdin.readSync(b);console.log(b)" && deno eval "const b = new Uint8Array(1);Deno.stdin.readSync(b);console.log(b)")"#) - .stdin("55555") // should not use this because stdin is piped from the echo - .assert_stdout("Uint8Array(1) [ 49 ]\nUint8Array(1) [ 50 ]\n") - .run() - .await; -} - -#[cfg(windows)] -#[tokio::test] -async fn windows_resolve_command() { - // not cross platform, but still allow this - TestBuilder::new() - .command("deno.exe eval 'console.log(1)'") - .assert_stdout("1\n") - .run() - .await; - - TestBuilder::new() - .command("deno eval 'console.log(1)'") - // handle trailing semi-colon - .env_var("PATHEXT", ".EXE;") - .assert_stdout("1\n") - .run() - .await; -} - -#[tokio::test] -async fn custom_command() { - // not cross platform, but still allow this - TestBuilder::new() - .command("add 1 2") - .custom_command( - "add", - Box::new(|mut context| { - async move { - let mut sum = 0; - for val in context.args { - sum += val.parse::().unwrap(); - } - let _ = context.stderr.write_line(&sum.to_string()); - ExecuteResult::from_exit_code(0) - } - .boxed_local() - }), - ) - .assert_stderr("3\n") - .run() - .await; -} - -#[tokio::test] -async fn custom_command_resolve_command_path() { - TestBuilder::new() - .command("$(custom_which deno) eval 'console.log(1)'") - .custom_command( - "custom_which", - Box::new(|mut context| { - async move { - let path = context - .state - .resolve_command_path(&context.args[0]) - .unwrap(); - let _ = context.stdout.write_line(&path.to_string_lossy()); - ExecuteResult::from_exit_code(0) - } - .boxed_local() - }), - ) - .assert_stdout("1\n") - .run() - .await; -} - -#[tokio::test] -async fn glob_basic() { - TestBuilder::new() - .file("test.txt", "test\n") - .file("test2.txt", "test2\n") - .command("cat *.txt") - .assert_stdout("test\ntest2\n") - .run() - .await; - - TestBuilder::new() - .file("test.txt", "test\n") - .file("test2.txt", "test2\n") - .command("cat test?.txt") - .assert_stdout("test2\n") - .run() - .await; - - TestBuilder::new() - .file("test.txt", "test\n") - .file("testa.txt", "testa\n") - .file("test2.txt", "test2\n") - .command("cat test[0-9].txt") - .assert_stdout("test2\n") - .run() - .await; - - TestBuilder::new() - .file("test.txt", "test\n") - .file("testa.txt", "testa\n") - .file("test2.txt", "test2\n") - .command("cat test[!a-z].txt") - .assert_stdout("test2\n") - .run() - .await; - - TestBuilder::new() - .file("test.txt", "test\n") - .file("testa.txt", "testa\n") - .file("test2.txt", "test2\n") - .command("cat test[a-z].txt") - .assert_stdout("testa\n") - .run() - .await; - - TestBuilder::new() - .directory("sub_dir/sub") - .file("sub_dir/sub/1.txt", "1\n") - .file("sub_dir/2.txt", "2\n") - .file("sub_dir/other.ts", "other\n") - .file("3.txt", "3\n") - .command("cat */*.txt") - .assert_stdout("2\n") - .run() - .await; - - TestBuilder::new() - .directory("sub_dir/sub") - .file("sub_dir/sub/1.txt", "1\n") - .file("sub_dir/2.txt", "2\n") - .file("sub_dir/other.ts", "other\n") - .file("3.txt", "3\n") - .command("cat **/*.txt") - .assert_stdout("3\n2\n1\n") - .run() - .await; - - TestBuilder::new() - .directory("sub_dir/sub") - .file("sub_dir/sub/1.txt", "1\n") - .file("sub_dir/2.txt", "2\n") - .file("sub_dir/other.ts", "other\n") - .file("3.txt", "3\n") - .command("cat $PWD/**/*.txt") - .assert_stdout("3\n2\n1\n") - .run() - .await; - - TestBuilder::new() - .directory("dir") - .file("dir/1.txt", "1\n") - .file("dir_1.txt", "2\n") - .command("cat dir*1.txt") - .assert_stdout("2\n") - .run() - .await; - - TestBuilder::new() - .file("test.txt", "test\n") - .file("test2.txt", "test2\n") - .command("cat *.ts") - .assert_stderr("glob: no matches found '$TEMP_DIR/*.ts'\n") - .assert_exit_code(1) - .run() - .await; - - let mut builder = TestBuilder::new(); - let temp_dir_path = builder.temp_dir_path(); - let error_pos = temp_dir_path.to_string_lossy().len() + 1; - builder.file("test.txt", "test\n") - .file("test2.txt", "test2\n") - .command("cat [].ts") - .assert_stderr(&format!("glob: no matches found '$TEMP_DIR/[].ts'. Pattern syntax error near position {}: invalid range pattern\n", error_pos)) - .assert_exit_code(1) - .run() - .await; - - TestBuilder::new() - .file("test.txt", "test\n") - .file("test2.txt", "test2\n") - .command("cat *.ts || echo 2") - .assert_stderr("glob: no matches found '$TEMP_DIR/*.ts'\n") - .assert_stdout("2\n") - .assert_exit_code(0) - .run() - .await; - - TestBuilder::new() - .file("test.txt", "test\n") - .file("test2.txt", "test2\n") - .command("cat *.ts 2> /dev/null || echo 2") - .assert_stderr("") - .assert_stdout("2\n") - .assert_exit_code(0) - .run() - .await; - - TestBuilder::new() - .command("echo --inspect='[::0]:3366'") - .assert_stderr("") - .assert_stdout("--inspect=[::0]:3366\n") - .assert_exit_code(0) - .run() - .await; -} - -#[tokio::test] -async fn glob_case_insensitive() { - TestBuilder::new() - .file("TEST.txt", "test\n") - .file("testa.txt", "testa\n") - .file("test2.txt", "test2\n") - .command("cat tes*.txt") - .assert_stdout("test\ntest2\ntesta\n") - .run() - .await; -} - -#[tokio::test] -async fn glob_escapes() { - // no escape - TestBuilder::new() - .file("[test].txt", "test\n") - .file("t.txt", "t\n") - .command("cat [test].txt") - .assert_stdout("t\n") - .run() - .await; - - // escape - TestBuilder::new() - .file("[test].txt", "test\n") - .file("t.txt", "t\n") - .command("cat [[]test[]].txt") - .assert_stdout("test\n") - .run() - .await; - - // single quotes - TestBuilder::new() - .file("[test].txt", "test\n") - .file("t.txt", "t\n") - .command("cat '[test].txt'") - .assert_stdout("test\n") - .run() - .await; - - // double quotes - TestBuilder::new() - .file("[test].txt", "test\n") - .file("t.txt", "t\n") - .command("cat \"[test].txt\"") - .assert_stdout("test\n") - .run() - .await; - - // mix - TestBuilder::new() - .file("[test].txt", "test\n") - .file("t.txt", "t\n") - .command("cat \"[\"test\"]\".txt") - .assert_stdout("test\n") - .run() - .await; -} - -#[tokio::test] -async fn paren_escapes() { - TestBuilder::new() - .command(r"echo \( foo bar \)") - .assert_stdout("( foo bar )\n") - .run() - .await; -} - -#[tokio::test] -async fn cross_platform_shebang() { - // with -S - TestBuilder::new() - .file("file.ts", "#!/usr/bin/env -S deno run\nconsole.log(5)") - .command("./file.ts") - .assert_stdout("5\n") - .run() - .await; - - // without -S and invalid - TestBuilder::new() - .file("file.ts", "#!/usr/bin/env deno run\nconsole.log(5)") - .command("./file.ts") - .assert_stderr("deno run: command not found\n") - .assert_exit_code(127) - .run() - .await; - - // without -S, but valid - TestBuilder::new() - .file("file.ts", "#!/usr/bin/env ./echo_stdin.ts\nconsole.log('Hello')") - .file("echo_stdin.ts", "#!/usr/bin/env -S deno run --allow-run\nawait new Deno.Command('deno', { args: ['run', ...Deno.args] }).spawn();") - .command("./file.ts") - .assert_stdout("Hello\n") - .run() - .await; - - // sub dir - TestBuilder::new() - .directory("sub") - .file("sub/file.ts", "#!/usr/bin/env ../echo_stdin.ts\nconsole.log('Hello')") - .file("echo_stdin.ts", "#!/usr/bin/env -S deno run --allow-run\nawait new Deno.Command('deno', { args: ['run', ...Deno.args] }).spawn();") - .command("./sub/file.ts") - .assert_stdout("Hello\n") - .run() - .await; - - // arguments - TestBuilder::new() - .file( - "file.ts", - "#!/usr/bin/env -S deno run --allow-read\nconsole.log(Deno.args)\nconst text = Deno.readTextFileSync(import.meta.filename);\nconsole.log(text.length)\n", - ) - .command("./file.ts 1 2 3") - .assert_stdout(r#"[ "1", "2", "3" ] -146 -"#) - .run() - .await; -} - -fn no_such_file_error_text() -> &'static str { - if cfg!(windows) { - "The system cannot find the file specified. (os error 2)" - } else { - "No such file or directory (os error 2)" - } -} diff --git a/crates/deno_task_shell/src/shell/test_builder.rs b/crates/deno_task_shell/src/shell/test_builder.rs deleted file mode 100644 index 0aacc65..0000000 --- a/crates/deno_task_shell/src/shell/test_builder.rs +++ /dev/null @@ -1,285 +0,0 @@ -// Copyright 2018-2024 the Deno authors. MIT license. - -use anyhow::Context; -use futures::future::LocalBoxFuture; -use pretty_assertions::assert_eq; -use std::collections::HashMap; -use std::fs; -use std::path::PathBuf; -use std::rc::Rc; -use tokio::task::JoinHandle; - -use crate::execute_with_pipes; -use crate::parser::parse; -use crate::shell::fs_util; -use crate::shell::types::pipe; -use crate::shell::types::ShellPipeWriter; -use crate::shell::types::ShellState; -use crate::ShellCommand; -use crate::ShellCommandContext; - -use super::types::ExecuteResult; - -type FnShellCommandExecute = - Box LocalBoxFuture<'static, ExecuteResult>>; - -struct FnShellCommand(FnShellCommandExecute); - -impl ShellCommand for FnShellCommand { - fn execute( - &self, - context: ShellCommandContext, - ) -> LocalBoxFuture<'static, ExecuteResult> { - (self.0)(context) - } -} - -// Clippy is complaining about them all having `File` prefixes, -// but there might be non-file variants in the future. -#[allow(clippy::enum_variant_names)] -enum TestAssertion { - FileExists(String), - FileNotExists(String), - FileTextEquals(String, String), -} - -struct TempDir { - // hold to keep it alive until drop - _inner: tempfile::TempDir, - cwd: PathBuf, -} - -impl TempDir { - pub fn new() -> Self { - let temp_dir = tempfile::tempdir().unwrap(); - let cwd = fs_util::canonicalize_path(temp_dir.path()).unwrap(); - Self { - _inner: temp_dir, - cwd, - } - } -} - -pub struct TestBuilder { - // it is much much faster to lazily create this - temp_dir: Option, - env_vars: HashMap, - custom_commands: HashMap>, - command: String, - stdin: Vec, - expected_exit_code: i32, - expected_stderr: String, - expected_stdout: String, - assertions: Vec, -} - -impl TestBuilder { - pub fn new() -> Self { - let env_vars = std::env::vars() - .map(|(key, value)| { - // For some very strange reason, key will sometimes be cased as "Path" - // or other times "PATH" on Windows. Since keys are case-insensitive on - // Windows, normalize the keys to be upper case. - if cfg!(windows) { - // need to normalize on windows - (key.to_uppercase(), value) - } else { - (key, value) - } - }) - .collect(); - - Self { - temp_dir: None, - env_vars, - custom_commands: Default::default(), - command: Default::default(), - stdin: Default::default(), - expected_exit_code: 0, - expected_stderr: Default::default(), - expected_stdout: Default::default(), - assertions: Default::default(), - } - } - - pub fn ensure_temp_dir(&mut self) -> &mut Self { - self.get_temp_dir(); - self - } - - fn get_temp_dir(&mut self) -> &mut TempDir { - if self.temp_dir.is_none() { - self.temp_dir = Some(TempDir::new()); - } - self.temp_dir.as_mut().unwrap() - } - - pub fn temp_dir_path(&mut self) -> PathBuf { - self.get_temp_dir().cwd.clone() - } - - pub fn command(&mut self, command: &str) -> &mut Self { - self.command = command.to_string(); - self - } - - pub fn stdin(&mut self, stdin: &str) -> &mut Self { - self.stdin = stdin.as_bytes().to_vec(); - self - } - - pub fn directory(&mut self, path: &str) -> &mut Self { - let temp_dir = self.get_temp_dir(); - fs::create_dir_all(temp_dir.cwd.join(path)).unwrap(); - self - } - - pub fn env_var(&mut self, name: &str, value: &str) -> &mut Self { - self.env_vars.insert(name.to_string(), value.to_string()); - self - } - - pub fn custom_command( - &mut self, - name: &str, - execute: FnShellCommandExecute, - ) -> &mut Self { - self - .custom_commands - .insert(name.to_string(), Rc::new(FnShellCommand(execute))); - self - } - - pub fn file(&mut self, path: &str, text: &str) -> &mut Self { - let temp_dir = self.get_temp_dir(); - fs::write(temp_dir.cwd.join(path), text).unwrap(); - self - } - - pub fn assert_exit_code(&mut self, code: i32) -> &mut Self { - self.expected_exit_code = code; - self - } - - pub fn assert_stderr(&mut self, output: &str) -> &mut Self { - self.expected_stderr.push_str(output); - self - } - - pub fn assert_stdout(&mut self, output: &str) -> &mut Self { - self.expected_stdout.push_str(output); - self - } - - pub fn assert_exists(&mut self, path: &str) -> &mut Self { - self.ensure_temp_dir(); - self - .assertions - .push(TestAssertion::FileExists(path.to_string())); - self - } - - pub fn assert_not_exists(&mut self, path: &str) -> &mut Self { - self.ensure_temp_dir(); - self - .assertions - .push(TestAssertion::FileNotExists(path.to_string())); - self - } - - pub fn assert_file_equals( - &mut self, - path: &str, - file_text: &str, - ) -> &mut Self { - self.ensure_temp_dir(); - self.assertions.push(TestAssertion::FileTextEquals( - path.to_string(), - file_text.to_string(), - )); - self - } - - pub async fn run(&mut self) { - let list = parse(&self.command).unwrap(); - let cwd = if let Some(temp_dir) = &self.temp_dir { - temp_dir.cwd.clone() - } else { - std::env::temp_dir() - }; - let (stdin, mut stdin_writer) = pipe(); - stdin_writer.write_all(&self.stdin).unwrap(); - drop(stdin_writer); // prevent a deadlock by dropping the writer - let (stdout, stdout_handle) = get_output_writer_and_handle(); - let (stderr, stderr_handle) = get_output_writer_and_handle(); - - let local_set = tokio::task::LocalSet::new(); - let state = ShellState::new( - self.env_vars.clone(), - &cwd, - self.custom_commands.drain().collect(), - ); - let exit_code = local_set - .run_until(execute_with_pipes(list, state, stdin, stdout, stderr)) - .await; - let temp_dir = if let Some(temp_dir) = &self.temp_dir { - temp_dir.cwd.display().to_string() - } else { - "NO_TEMP_DIR".to_string() - }; - assert_eq!( - stderr_handle.await.unwrap(), - self.expected_stderr.replace("$TEMP_DIR", &temp_dir), - "\n\nFailed for: {}", - self.command - ); - assert_eq!( - stdout_handle.await.unwrap(), - self.expected_stdout.replace("$TEMP_DIR", &temp_dir), - "\n\nFailed for: {}", - self.command - ); - assert_eq!( - exit_code, self.expected_exit_code, - "\n\nFailed for: {}", - self.command - ); - - for assertion in &self.assertions { - match assertion { - TestAssertion::FileExists(path) => { - assert!( - cwd.join(path).exists(), - "\n\nFailed for: {}\nExpected '{}' to exist.", - self.command, - path, - ) - } - TestAssertion::FileNotExists(path) => { - assert!( - !cwd.join(path).exists(), - "\n\nFailed for: {}\nExpected '{}' to not exist.", - self.command, - path, - ) - } - TestAssertion::FileTextEquals(path, text) => { - let actual_text = std::fs::read_to_string(cwd.join(path)) - .with_context(|| format!("Error reading {path}")) - .unwrap(); - assert_eq!( - &actual_text, text, - "\n\nFailed for: {}\nPath: {}", - self.command, path, - ) - } - } - } - } -} - -fn get_output_writer_and_handle() -> (ShellPipeWriter, JoinHandle) { - let (reader, writer) = pipe(); - let handle = reader.pipe_to_string_handle(); - (writer, handle) -} diff --git a/crates/deno_task_shell/src/shell/types.rs b/crates/deno_task_shell/src/shell/types.rs index a66e442..3c87caa 100644 --- a/crates/deno_task_shell/src/shell/types.rs +++ b/crates/deno_task_shell/src/shell/types.rs @@ -2,14 +2,20 @@ use std::borrow::Cow; use std::collections::HashMap; +use std::fmt; +use std::fmt::Display; +use std::fs; use std::io::Read; use std::io::Write; use std::path::Path; use std::path::PathBuf; use std::rc::Rc; +use std::str::FromStr; -use anyhow::Result; use futures::future::LocalBoxFuture; +use miette::Error; +use miette::IntoDiagnostic; +use miette::Result; use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; @@ -26,10 +32,20 @@ pub struct ShellState { /// Variables that should be evaluated within the shell and /// not passed down to any sub commands. shell_vars: HashMap, + /// The current working directory of the shell cwd: PathBuf, + /// The commands that are available in the shell commands: Rc>>, + /// A map of aliases for commands (e.g. `ll=ls -al`) + alias: HashMap>, /// Token to cancel execution. token: CancellationToken, + /// Git repository handling. + git_repository: bool, // Is `cwd` inside a git repository? + git_root: PathBuf, // Path to the root (`$git_root/.git/HEAD` exists) + git_branch: String, // Contents of `$git_root/.git/HEAD` + last_command_cd: bool, // Was last command a `cd` (thus git_branch is current)? + last_command_exit_code: i32, // Exit code of the last command } impl ShellState { @@ -44,9 +60,15 @@ impl ShellState { let mut result = Self { env_vars: Default::default(), shell_vars: Default::default(), + alias: Default::default(), cwd: PathBuf::new(), commands: Rc::new(commands), token: CancellationToken::default(), + git_repository: false, + git_root: PathBuf::new(), + git_branch: String::new(), + last_command_cd: false, + last_command_exit_code: 0, }; // ensure the data is normalized for (name, value) in env_vars { @@ -60,6 +82,34 @@ impl ShellState { &self.cwd } + pub fn alias_map(&self) -> &HashMap> { + &self.alias + } + + pub fn git_repository(&self) -> bool { + self.git_repository + } + + pub fn git_root(&self) -> &PathBuf { + &self.git_root + } + + pub fn git_branch(&self) -> &String { + &self.git_branch + } + + pub fn last_command_cd(&self) -> bool { + self.last_command_cd + } + + pub fn set_last_command_exit_code(&mut self, exit_code: i32) { + self.last_command_exit_code = exit_code; + } + + pub fn last_command_exit_code(&self) -> i32 { + self.last_command_exit_code + } + pub fn env_vars(&self) -> &HashMap { &self.env_vars } @@ -76,15 +126,75 @@ impl ShellState { .or_else(|| self.shell_vars.get(name.as_ref())) } + // Update self.git_branch using self.git_root + pub fn update_git_branch(&mut self) { + if self.git_repository { + match fs::read_to_string(self.git_root().join(".git/HEAD")) { + Ok(contents) => { + // The git root can still be read, update the git branch + self.git_branch = contents.trim().to_string(); + } + Err(_) => { + // The git root can no longer be read + // (the `.git/HEAD` was removed in the meantime) + self.git_repository = false; + self.git_branch = "".to_string(); + self.git_root = "".to_string().into(); + } + }; + } + } + + /// Set the current working directory of this shell pub fn set_cwd(&mut self, cwd: &Path) { self.cwd = cwd.to_path_buf(); // $PWD holds the current working directory, so we keep cwd and $PWD in sync self .env_vars .insert("PWD".to_string(), self.cwd.display().to_string()); + // Handle a git repository + // First read the current directory's `.git/HEAD` + match fs::read_to_string(cwd.join(".git/HEAD")) { + Ok(contents) => { + // We are in a git repository in the git root dir + self.git_repository = true; + self.git_branch = contents.trim().to_string(); + self.git_root = cwd.to_path_buf(); + } + Err(_) => { + if self.git_repository + && cwd + .display() + .to_string() + .starts_with(&self.git_root.display().to_string()) + { + // We moved inside the same git repository, but we are not + // in the git root dir + self.update_git_branch(); + } else { + // We didn't move within the same git repository, + // and there is no `.git` present. + // Consequently, we: + // * Either moved into a subdirectory of a git repository from + // outside + // * Or moved into a directory that is not inside git repository + // In the first case we need to recursively search to find the + // root. This might be slow, so we want to be smart and use the + // old directory to eliminate search in case we are moving up or + // down from the same root. For now we will set no git + // repository, which is incorrect for the first case, but will + // be fast for the most common use of not being inside a git + // repository. + self.git_repository = false; + self.git_branch = "".to_string(); + self.git_root = "".to_string().into(); + } + } + }; } pub fn apply_changes(&mut self, changes: &[EnvChange]) { + self.last_command_cd = false; for change in changes { self.apply_change(change); } @@ -106,6 +216,16 @@ impl ShellState { } EnvChange::Cd(new_dir) => { self.set_cwd(new_dir); + self.last_command_cd = true; + } + EnvChange::AliasCommand(alias, cmd) => { + self.alias.insert( + alias.clone(), + cmd.split_whitespace().map(ToString::to_string).collect(), + ); + } + EnvChange::UnAliasCommand(alias) => { + self.alias.remove(alias); } } } @@ -165,14 +285,19 @@ impl ShellState { } } -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, PartialEq, Eq, Clone, PartialOrd)] pub enum EnvChange { - // `export ENV_VAR=VALUE` + /// `export ENV_VAR=VALUE` SetEnvVar(String, String), - // `ENV_VAR=VALUE` + /// `ENV_VAR=VALUE` SetShellVar(String, String), - // `unset ENV_VAR` + /// Create an alias for a command (e.g. ll=ls -al) + AliasCommand(String, String), + /// Remove an alias + UnAliasCommand(String), + /// `unset ENV_VAR` UnsetVar(String), + /// Set the current working directory to the new Path Cd(PathBuf), } @@ -264,15 +389,19 @@ impl ShellPipeReader { loop { let mut buffer = [0; 512]; // todo: what is an appropriate buffer size? let size = match &mut self { - ShellPipeReader::OsPipe(pipe) => pipe.read(&mut buffer)?, - ShellPipeReader::StdFile(file) => file.read(&mut buffer)?, + ShellPipeReader::OsPipe(pipe) => { + pipe.read(&mut buffer).into_diagnostic()? + } + ShellPipeReader::StdFile(file) => { + file.read(&mut buffer).into_diagnostic()? + } }; if size == 0 { break; } - writer.write_all(&buffer[0..size])?; + writer.write_all(&buffer[0..size]).into_diagnostic()?; if flush { - writer.flush()?; + writer.flush().into_diagnostic()?; } } Ok(()) @@ -309,8 +438,8 @@ impl ShellPipeReader { pub fn read(&mut self, buf: &mut [u8]) -> Result { match self { - ShellPipeReader::OsPipe(pipe) => pipe.read(buf).map_err(|e| e.into()), - ShellPipeReader::StdFile(file) => file.read(buf).map_err(|e| e.into()), + ShellPipeReader::OsPipe(pipe) => pipe.read(buf).into_diagnostic(), + ShellPipeReader::StdFile(file) => file.read(buf).into_diagnostic(), } } } @@ -374,19 +503,19 @@ impl ShellPipeWriter { pub fn write_all(&mut self, bytes: &[u8]) -> Result<()> { match self { - Self::OsPipe(pipe) => pipe.write_all(bytes)?, - Self::StdFile(file) => file.write_all(bytes)?, + Self::OsPipe(pipe) => pipe.write_all(bytes).into_diagnostic()?, + Self::StdFile(file) => file.write_all(bytes).into_diagnostic()?, // For both stdout & stderr, we want to flush after each // write in order to bypass Rust's internal buffer. Self::Stdout => { let mut stdout = std::io::stdout().lock(); - stdout.write_all(bytes)?; - stdout.flush()?; + stdout.write_all(bytes).into_diagnostic()?; + stdout.flush().into_diagnostic()?; } Self::Stderr => { let mut stderr = std::io::stderr().lock(); - stderr.write_all(bytes)?; - stderr.flush()?; + stderr.write_all(bytes).into_diagnostic()?; + stderr.flush().into_diagnostic()?; } Self::Null => {} } @@ -407,3 +536,585 @@ pub fn pipe() -> (ShellPipeReader, ShellPipeWriter) { ShellPipeWriter::OsPipe(writer), ) } + +#[derive(Debug, Clone, PartialEq, PartialOrd, thiserror::Error)] +pub struct ArithmeticResult { + pub value: ArithmeticValue, + pub changes: Vec, +} + +#[derive(Debug, Clone, PartialEq, PartialOrd, thiserror::Error)] +pub enum ArithmeticValue { + Float(f64), + Integer(i64), +} + +impl Display for ArithmeticResult { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.value) + } +} + +impl Display for ArithmeticValue { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ArithmeticValue::Float(val) => write!(f, "{}", val), + ArithmeticValue::Integer(val) => write!(f, "{}", val), + } + } +} + +impl ArithmeticResult { + pub fn new(value: ArithmeticValue) -> Self { + ArithmeticResult { + value, + changes: Vec::new(), + } + } + + pub fn is_zero(&self) -> bool { + match &self.value { + ArithmeticValue::Integer(val) => *val == 0, + ArithmeticValue::Float(val) => *val == 0.0, + } + } + + pub fn checked_add( + &self, + other: &ArithmeticResult, + ) -> Result { + let result = match (&self.value, &other.value) { + (ArithmeticValue::Integer(lhs), ArithmeticValue::Integer(rhs)) => lhs + .checked_add(*rhs) + .map(ArithmeticValue::Integer) + .ok_or_else(|| { + miette::miette!("Integer overflow: {} + {}", lhs, rhs) + })?, + (ArithmeticValue::Float(lhs), ArithmeticValue::Float(rhs)) => { + let sum = lhs + rhs; + if sum.is_finite() { + ArithmeticValue::Float(sum) + } else { + return Err(miette::miette!("Float overflow: {} + {}", lhs, rhs)); + } + } + (ArithmeticValue::Integer(lhs), ArithmeticValue::Float(rhs)) + | (ArithmeticValue::Float(rhs), ArithmeticValue::Integer(lhs)) => { + let sum = *lhs as f64 + rhs; + if sum.is_finite() { + ArithmeticValue::Float(sum) + } else { + return Err(miette::miette!("Float overflow: {} + {}", lhs, rhs)); + } + } + }; + + let mut changes = self.changes.clone(); + changes.extend(other.changes.clone()); + + Ok(ArithmeticResult { + value: result, + changes, + }) + } + + pub fn checked_sub( + &self, + other: &ArithmeticResult, + ) -> Result { + let result = match (&self.value, &other.value) { + (ArithmeticValue::Integer(lhs), ArithmeticValue::Integer(rhs)) => lhs + .checked_sub(*rhs) + .map(ArithmeticValue::Integer) + .ok_or_else(|| { + miette::miette!("Integer overflow: {} - {}", lhs, rhs) + })?, + (ArithmeticValue::Float(lhs), ArithmeticValue::Float(rhs)) => { + let diff = lhs - rhs; + if diff.is_finite() { + ArithmeticValue::Float(diff) + } else { + return Err(miette::miette!("Float overflow: {} - {}", lhs, rhs)); + } + } + (ArithmeticValue::Integer(lhs), ArithmeticValue::Float(rhs)) => { + let diff = *lhs as f64 - rhs; + if diff.is_finite() { + ArithmeticValue::Float(diff) + } else { + return Err(miette::miette!("Float overflow: {} - {}", lhs, rhs)); + } + } + (ArithmeticValue::Float(lhs), ArithmeticValue::Integer(rhs)) => { + let diff = lhs - *rhs as f64; + if diff.is_finite() { + ArithmeticValue::Float(diff) + } else { + return Err(miette::miette!("Float overflow: {} - {}", lhs, rhs)); + } + } + }; + + let mut changes = self.changes.clone(); + changes.extend(other.changes.clone()); + + Ok(ArithmeticResult { + value: result, + changes, + }) + } + + pub fn checked_mul( + &self, + other: &ArithmeticResult, + ) -> Result { + let result = match (&self.value, &other.value) { + (ArithmeticValue::Integer(lhs), ArithmeticValue::Integer(rhs)) => lhs + .checked_mul(*rhs) + .map(ArithmeticValue::Integer) + .ok_or_else(|| { + miette::miette!("Integer overflow: {} * {}", lhs, rhs) + })?, + (ArithmeticValue::Float(lhs), ArithmeticValue::Float(rhs)) => { + let product = lhs * rhs; + if product.is_finite() { + ArithmeticValue::Float(product) + } else { + return Err(miette::miette!("Float overflow: {} * {}", lhs, rhs)); + } + } + (ArithmeticValue::Integer(lhs), ArithmeticValue::Float(rhs)) + | (ArithmeticValue::Float(rhs), ArithmeticValue::Integer(lhs)) => { + let product = *lhs as f64 * rhs; + if product.is_finite() { + ArithmeticValue::Float(product) + } else { + return Err(miette::miette!("Float overflow: {} * {}", lhs, rhs)); + } + } + }; + + let mut changes = self.changes.clone(); + changes.extend(other.changes.clone()); + + Ok(ArithmeticResult { + value: result, + changes, + }) + } + + pub fn checked_div( + &self, + other: &ArithmeticResult, + ) -> Result { + let result = match (&self.value, &other.value) { + (ArithmeticValue::Integer(lhs), ArithmeticValue::Integer(rhs)) => { + if *rhs == 0 { + return Err(miette::miette!("Division by zero: {} / {}", lhs, rhs)); + } + lhs + .checked_div(*rhs) + .map(ArithmeticValue::Integer) + .ok_or_else(|| { + miette::miette!("Integer overflow: {} / {}", lhs, rhs) + })? + } + (ArithmeticValue::Float(lhs), ArithmeticValue::Float(rhs)) => { + if *rhs == 0.0 { + return Err(miette::miette!("Division by zero: {} / {}", lhs, rhs)); + } + let quotient = lhs / rhs; + if quotient.is_finite() { + ArithmeticValue::Float(quotient) + } else { + return Err(miette::miette!("Float overflow: {} / {}", lhs, rhs)); + } + } + (ArithmeticValue::Integer(lhs), ArithmeticValue::Float(rhs)) => { + if *rhs == 0.0 { + return Err(miette::miette!("Division by zero: {} / {}", lhs, rhs)); + } + let quotient = *lhs as f64 / rhs; + if quotient.is_finite() { + ArithmeticValue::Float(quotient) + } else { + return Err(miette::miette!("Float overflow: {} / {}", lhs, rhs)); + } + } + (ArithmeticValue::Float(lhs), ArithmeticValue::Integer(rhs)) => { + if *rhs == 0 { + return Err(miette::miette!("Division by zero: {} / {}", lhs, rhs)); + } + let quotient = lhs / *rhs as f64; + if quotient.is_finite() { + ArithmeticValue::Float(quotient) + } else { + return Err(miette::miette!("Float overflow: {} / {}", lhs, rhs)); + } + } + }; + + let mut changes = self.changes.clone(); + changes.extend(other.changes.clone()); + + Ok(ArithmeticResult { + value: result, + changes, + }) + } + + pub fn checked_rem( + &self, + other: &ArithmeticResult, + ) -> Result { + let result = match (&self.value, &other.value) { + (ArithmeticValue::Integer(lhs), ArithmeticValue::Integer(rhs)) => { + if *rhs == 0 { + return Err(miette::miette!("Modulo by zero: {} % {}", lhs, rhs)); + } + lhs + .checked_rem(*rhs) + .map(ArithmeticValue::Integer) + .ok_or_else(|| { + miette::miette!("Integer overflow: {} % {}", lhs, rhs) + })? + } + (ArithmeticValue::Float(lhs), ArithmeticValue::Float(rhs)) => { + if *rhs == 0.0 { + return Err(miette::miette!("Modulo by zero: {} % {}", lhs, rhs)); + } + let remainder = lhs % rhs; + if remainder.is_finite() { + ArithmeticValue::Float(remainder) + } else { + return Err(miette::miette!("Float overflow: {} % {}", lhs, rhs)); + } + } + (ArithmeticValue::Integer(lhs), ArithmeticValue::Float(rhs)) => { + if *rhs == 0.0 { + return Err(miette::miette!("Modulo by zero: {} % {}", lhs, rhs)); + } + let remainder = *lhs as f64 % rhs; + if remainder.is_finite() { + ArithmeticValue::Float(remainder) + } else { + return Err(miette::miette!("Float overflow: {} % {}", lhs, rhs)); + } + } + (ArithmeticValue::Float(lhs), ArithmeticValue::Integer(rhs)) => { + if *rhs == 0 { + return Err(miette::miette!("Modulo by zero: {} % {}", lhs, rhs)); + } + let remainder = lhs % *rhs as f64; + if remainder.is_finite() { + ArithmeticValue::Float(remainder) + } else { + return Err(miette::miette!("Float overflow: {} % {}", lhs, rhs)); + } + } + }; + + let mut changes = self.changes.clone(); + changes.extend(other.changes.clone()); + + Ok(ArithmeticResult { + value: result, + changes, + }) + } + + pub fn checked_pow( + &self, + other: &ArithmeticResult, + ) -> Result { + let result = match (&self.value, &other.value) { + (ArithmeticValue::Integer(lhs), ArithmeticValue::Integer(rhs)) => { + if *rhs < 0 { + let result = (*lhs as f64).powf(*rhs as f64); + if result.is_finite() { + ArithmeticValue::Float(result) + } else { + return Err(miette::miette!("Float overflow: {} ** {}", lhs, rhs)); + } + } else { + lhs + .checked_pow(*rhs as u32) + .map(ArithmeticValue::Integer) + .ok_or_else(|| { + miette::miette!("Integer overflow: {} ** {}", lhs, rhs) + })? + } + } + (ArithmeticValue::Float(lhs), ArithmeticValue::Float(rhs)) => { + let result = lhs.powf(*rhs); + if result.is_finite() { + ArithmeticValue::Float(result) + } else { + return Err(miette::miette!("Float overflow: {} ** {}", lhs, rhs)); + } + } + (ArithmeticValue::Integer(lhs), ArithmeticValue::Float(rhs)) => { + let result = (*lhs as f64).powf(*rhs); + if result.is_finite() { + ArithmeticValue::Float(result) + } else { + return Err(miette::miette!("Float overflow: {} ** {}", lhs, rhs)); + } + } + (ArithmeticValue::Float(lhs), ArithmeticValue::Integer(rhs)) => { + let result = lhs.powf(*rhs as f64); + if result.is_finite() { + ArithmeticValue::Float(result) + } else { + return Err(miette::miette!("Float overflow: {} ** {}", lhs, rhs)); + } + } + }; + + let mut changes = self.changes.clone(); + changes.extend(other.changes.clone()); + + Ok(ArithmeticResult { + value: result, + changes, + }) + } + + pub fn checked_neg(&self) -> Result { + let result = match &self.value { + ArithmeticValue::Integer(val) => val + .checked_neg() + .map(ArithmeticValue::Integer) + .ok_or_else(|| miette::miette!("Integer overflow: -{}", val))?, + ArithmeticValue::Float(val) => { + let result = -val; + if result.is_finite() { + ArithmeticValue::Float(result) + } else { + return Err(miette::miette!("Float overflow: -{}", val)); + } + } + }; + + Ok(ArithmeticResult { + value: result, + changes: self.changes.clone(), + }) + } + + pub fn checked_not(&self) -> Result { + let result = match &self.value { + ArithmeticValue::Integer(val) => ArithmeticValue::Integer(!val), + ArithmeticValue::Float(_) => { + return Err(miette::miette!( + "Invalid arithmetic result type for bitwise NOT: {}", + self + )) + } + }; + + Ok(ArithmeticResult { + value: result, + changes: self.changes.clone(), + }) + } + + pub fn checked_shl( + &self, + other: &ArithmeticResult, + ) -> Result { + let result = match (&self.value, &other.value) { + (ArithmeticValue::Integer(lhs), ArithmeticValue::Integer(rhs)) => { + if *rhs < 0 { + return Err(miette::miette!( + "Negative shift amount: {} << {}", + lhs, + rhs + )); + } + lhs + .checked_shl(*rhs as u32) + .map(ArithmeticValue::Integer) + .ok_or_else(|| { + miette::miette!("Integer overflow: {} << {}", lhs, rhs) + })? + } + _ => { + return Err(miette::miette!( + "Invalid arithmetic result types for left shift: {} << {}", + self, + other + )) + } + }; + + let mut changes = self.changes.clone(); + changes.extend(other.changes.clone()); + + Ok(ArithmeticResult { + value: result, + changes, + }) + } + + pub fn checked_shr( + &self, + other: &ArithmeticResult, + ) -> Result { + let result = match (&self.value, &other.value) { + (ArithmeticValue::Integer(lhs), ArithmeticValue::Integer(rhs)) => { + if *rhs < 0 { + return Err(miette::miette!( + "Negative shift amount: {} >> {}", + lhs, + rhs + )); + } + lhs + .checked_shr(*rhs as u32) + .map(ArithmeticValue::Integer) + .ok_or_else(|| { + miette::miette!("Integer underflow: {} >> {}", lhs, rhs) + })? + } + _ => { + return Err(miette::miette!( + "Invalid arithmetic result types for right shift: {} >> {}", + self, + other + )) + } + }; + + let mut changes = self.changes.clone(); + changes.extend(other.changes.clone()); + + Ok(ArithmeticResult { + value: result, + changes, + }) + } + + pub fn checked_and( + &self, + other: &ArithmeticResult, + ) -> Result { + let result = match (&self.value, &other.value) { + (ArithmeticValue::Integer(lhs), ArithmeticValue::Integer(rhs)) => { + ArithmeticValue::Integer(lhs & rhs) + } + _ => { + return Err(miette::miette!( + "Invalid arithmetic result types for bitwise AND: {} & {}", + self, + other + )) + } + }; + + let mut changes = self.changes.clone(); + changes.extend(other.changes.clone()); + + Ok(ArithmeticResult { + value: result, + changes, + }) + } + + pub fn checked_or( + &self, + other: &ArithmeticResult, + ) -> Result { + let result = match (&self.value, &other.value) { + (ArithmeticValue::Integer(lhs), ArithmeticValue::Integer(rhs)) => { + ArithmeticValue::Integer(lhs | rhs) + } + _ => { + return Err(miette::miette!( + "Invalid arithmetic result types for bitwise OR: {} | {}", + self, + other + )) + } + }; + + let mut changes = self.changes.clone(); + changes.extend(other.changes.clone()); + + Ok(ArithmeticResult { + value: result, + changes, + }) + } + + pub fn checked_xor( + &self, + other: &ArithmeticResult, + ) -> Result { + let result = match (&self.value, &other.value) { + (ArithmeticValue::Integer(lhs), ArithmeticValue::Integer(rhs)) => { + ArithmeticValue::Integer(lhs ^ rhs) + } + _ => { + return Err(miette::miette!( + "Invalid arithmetic result types for bitwise XOR: {} ^ {}", + self, + other + )) + } + }; + + let mut changes = self.changes.clone(); + changes.extend(other.changes.clone()); + + Ok(ArithmeticResult { + value: result, + changes, + }) + } + + pub fn with_changes(mut self, changes: Vec) -> Self { + self.changes = changes; + self + } +} + +impl From for ArithmeticResult { + fn from(value: String) -> Self { + if let Ok(int_val) = value.parse::() { + ArithmeticResult::new(ArithmeticValue::Integer(int_val)) + } else if let Ok(float_val) = value.parse::() { + ArithmeticResult::new(ArithmeticValue::Float(float_val)) + } else { + panic!("Invalid arithmetic result: {}", value); + } + } +} + +impl FromStr for ArithmeticResult { + type Err = String; + + fn from_str(s: &str) -> Result { + Ok(s.to_string().into()) + } +} + +pub struct WordEvalResult { + pub value: Vec, + pub changes: Vec, +} + +impl WordEvalResult { + pub fn new(value: Vec, changes: Vec) -> Self { + WordEvalResult { value, changes } + } + + pub fn extend(&mut self, other: WordEvalResult) { + self.value.extend(other.value); + self.changes.extend(other.changes); + } + + pub fn join(&self, sep: &str) -> String { + self.value.join(sep) + } +} diff --git a/crates/shell/Cargo.toml b/crates/shell/Cargo.toml index 5b97bf7..1799359 100644 --- a/crates/shell/Cargo.toml +++ b/crates/shell/Cargo.toml @@ -12,6 +12,10 @@ readme.workspace = true default-run = "shell" publish = false +[lib] +name = "shell" +path = "src/lib.rs" + [[bin]] name = "shell" path = "src/main.rs" @@ -19,15 +23,25 @@ path = "src/main.rs" [features] [dependencies] -anyhow = "1.0.86" clap = { version = "4.5.17", features = ["derive"] } -deno_task_shell = { path = "../deno_task_shell" } +deno_task_shell = { path = "../deno_task_shell", features = ["shell"] } futures = "0.3.30" -rustyline = "14.0.0" -tokio = "1.39.2" +rustyline = { version = "14.0.0", features = ["derive"] } +tokio = "1.40.0" uu_ls = "0.0.27" dirs = "5.0.1" serde_json = "1.0.128" +which = "6.0.3" +uu_uname = "0.0.27" +uu_touch = "0.0.27" +uu_date = "0.0.27" +miette = { version = "7.2.0", features = ["fancy"] } +filetime = "0.2.25" +chrono = "0.4.38" +parse_datetime = "0.6.0" +dtparse = "2.0.1" +windows-sys = "0.59.0" +ctrlc = "3.4.5" [package.metadata.release] # Dont publish the binary diff --git a/crates/shell/src/commands.rs b/crates/shell/src/commands.rs deleted file mode 100644 index 1467dda..0000000 --- a/crates/shell/src/commands.rs +++ /dev/null @@ -1,26 +0,0 @@ -use std::ffi::OsString; - -use deno_task_shell::{ExecuteResult, ShellCommand, ShellCommandContext}; -use futures::future::LocalBoxFuture; - -use uu_ls::uumain as uu_ls; -pub struct LsCommand; - -impl ShellCommand for LsCommand { - fn execute(&self, context: ShellCommandContext) -> LocalBoxFuture<'static, ExecuteResult> { - let result = execute_ls(context); - Box::pin(futures::future::ready(result)) - } -} - -fn execute_ls(context: ShellCommandContext) -> ExecuteResult { - let mut args: Vec = vec![OsString::from("ls"), OsString::from("--color=auto")]; - - context - .args - .iter() - .for_each(|arg| args.push(OsString::from(arg))); - - let exit_code = uu_ls(args.into_iter()); - ExecuteResult::from_exit_code(exit_code) -} diff --git a/crates/shell/src/commands/date.rs b/crates/shell/src/commands/date.rs new file mode 100644 index 0000000..3086548 --- /dev/null +++ b/crates/shell/src/commands/date.rs @@ -0,0 +1,31 @@ +use std::ffi::OsString; + +use deno_task_shell::{ExecuteResult, ShellCommand, ShellCommandContext}; +use futures::future::LocalBoxFuture; +use uu_date::uumain as uu_date; + +pub struct DateCommand; + +impl ShellCommand for DateCommand { + fn execute(&self, mut context: ShellCommandContext) -> LocalBoxFuture<'static, ExecuteResult> { + Box::pin(futures::future::ready(match execute_date(&mut context) { + Ok(_) => ExecuteResult::from_exit_code(0), + Err(exit_code) => ExecuteResult::from_exit_code(exit_code), + })) + } +} + +fn execute_date(context: &mut ShellCommandContext) -> Result<(), i32> { + let mut args: Vec = vec![OsString::from("date")]; + + context + .args + .iter() + .for_each(|arg| args.push(OsString::from(arg))); + + let exit_code = uu_date(args.into_iter()); + if exit_code != 0 { + return Err(exit_code); + } + Ok(()) +} diff --git a/crates/shell/src/commands/mod.rs b/crates/shell/src/commands/mod.rs new file mode 100644 index 0000000..86f16db --- /dev/null +++ b/crates/shell/src/commands/mod.rs @@ -0,0 +1,142 @@ +use std::{collections::HashMap, ffi::OsString, fs, rc::Rc}; + +use deno_task_shell::{EnvChange, ExecuteResult, ShellCommand, ShellCommandContext}; +use futures::{future::LocalBoxFuture, FutureExt}; + +use uu_ls::uumain as uu_ls; + +use crate::execute; + +pub mod date; +pub mod touch; +pub mod uname; +pub mod which; + +pub use date::DateCommand; +pub use touch::TouchCommand; +pub use uname::UnameCommand; +pub use which::WhichCommand; + +pub struct LsCommand; + +pub struct AliasCommand; + +pub struct UnAliasCommand; + +pub struct SourceCommand; + +pub fn get_commands() -> HashMap> { + HashMap::from([ + ("ls".to_string(), Rc::new(LsCommand) as Rc), + ( + "alias".to_string(), + Rc::new(AliasCommand) as Rc, + ), + ( + "unalias".to_string(), + Rc::new(UnAliasCommand) as Rc, + ), + ( + "source".to_string(), + Rc::new(SourceCommand) as Rc, + ), + ( + "which".to_string(), + Rc::new(WhichCommand) as Rc, + ), + ( + "uname".to_string(), + Rc::new(UnameCommand) as Rc, + ), + ( + "touch".to_string(), + Rc::new(TouchCommand) as Rc, + ), + ( + "date".to_string(), + Rc::new(DateCommand) as Rc, + ), + ]) +} + +impl ShellCommand for AliasCommand { + fn execute(&self, context: ShellCommandContext) -> LocalBoxFuture<'static, ExecuteResult> { + if context.args.len() != 1 { + return Box::pin(futures::future::ready(ExecuteResult::from_exit_code(1))); + } + + // parse the args + let env_change = if let Some((alias, cmd)) = context.args[0].split_once('=') { + vec![EnvChange::AliasCommand(alias.into(), cmd.into())] + } else { + return Box::pin(futures::future::ready(ExecuteResult::from_exit_code(1))); + }; + + let result = ExecuteResult::Continue(0, env_change, Vec::default()); + Box::pin(futures::future::ready(result)) + } +} + +impl ShellCommand for UnAliasCommand { + fn execute(&self, context: ShellCommandContext) -> LocalBoxFuture<'static, ExecuteResult> { + if context.args.len() != 1 { + return Box::pin(futures::future::ready(ExecuteResult::from_exit_code(1))); + } + + let result = ExecuteResult::Continue( + 0, + vec![EnvChange::UnAliasCommand(context.args[0].clone())], + Vec::default(), + ); + Box::pin(futures::future::ready(result)) + } +} + +impl ShellCommand for LsCommand { + fn execute(&self, context: ShellCommandContext) -> LocalBoxFuture<'static, ExecuteResult> { + let result = execute_ls(context); + Box::pin(futures::future::ready(result)) + } +} + +fn execute_ls(context: ShellCommandContext) -> ExecuteResult { + let mut args: Vec = vec![OsString::from("ls"), OsString::from("--color=auto")]; + + context + .args + .iter() + .for_each(|arg| args.push(OsString::from(arg))); + + let exit_code = uu_ls(args.into_iter()); + ExecuteResult::from_exit_code(exit_code) +} + +impl ShellCommand for SourceCommand { + fn execute(&self, context: ShellCommandContext) -> LocalBoxFuture<'static, ExecuteResult> { + if context.args.len() != 1 { + return Box::pin(futures::future::ready(ExecuteResult::from_exit_code(1))); + } + + let script = context.args[0].clone(); + let script_file = context.state.cwd().join(script); + match fs::read_to_string(&script_file) { + Ok(content) => { + let state = context.state.clone(); + async move { + execute::execute_inner(&content, state) + .await + .unwrap_or_else(|e| { + eprintln!("Could not source script: {:?}", script_file); + eprintln!("Error: {}", e); + ExecuteResult::from_exit_code(1) + }) + } + .boxed_local() + } + Err(e) => { + eprintln!("Could not read file: {:?} ({})", script_file, e); + Box::pin(futures::future::ready(ExecuteResult::from_exit_code(1))) + } + } + } +} diff --git a/crates/shell/src/commands/touch.rs b/crates/shell/src/commands/touch.rs new file mode 100644 index 0000000..87c9481 --- /dev/null +++ b/crates/shell/src/commands/touch.rs @@ -0,0 +1,346 @@ +use std::{ + ffi::OsString, + fs::{self, OpenOptions}, + io, + path::{Path, PathBuf}, +}; + +use chrono::{DateTime, Duration, Local, NaiveDateTime, TimeZone, Timelike}; +use deno_task_shell::{ExecuteResult, ShellCommand, ShellCommandContext}; +use filetime::{set_file_times, set_symlink_file_times, FileTime}; +use futures::future::LocalBoxFuture; +use miette::{miette, IntoDiagnostic, Result}; +use uu_touch::{options, uu_app as uu_touch}; + +static ARG_FILES: &str = "files"; + +pub struct TouchCommand; + +impl ShellCommand for TouchCommand { + fn execute(&self, mut context: ShellCommandContext) -> LocalBoxFuture<'static, ExecuteResult> { + Box::pin(futures::future::ready(match execute_touch(&mut context) { + Ok(_) => ExecuteResult::from_exit_code(0), + Err(e) => { + let _ = context.stderr.write_all(format!("{:?}", e).as_bytes()); + ExecuteResult::from_exit_code(1) + } + })) + } +} + +fn execute_touch(context: &mut ShellCommandContext) -> Result<()> { + let matches = uu_touch() + .override_usage("touch [OPTION]...") + .no_binary_name(true) + .try_get_matches_from(&context.args) + .into_diagnostic()?; + + let files = match matches.get_many::(ARG_FILES) { + Some(files) => files.map(|file| { + let path = PathBuf::from(file); + if path.is_absolute() { + path + } else { + context.state.cwd().join(path) + } + }), + None => { + return Err(miette!( + "missing file operand\nTry 'touch --help' for more information." + )) + } + }; + + let (mut atime, mut mtime) = match ( + matches.get_one::(options::sources::REFERENCE), + matches.get_one::(options::sources::DATE), + ) { + (Some(reference), Some(date)) => { + let reference_path = PathBuf::from(reference); + let reference_path = if reference_path.is_absolute() { + reference_path + } else { + context.state.cwd().join(reference_path) + }; + let (atime, mtime) = stat(&reference_path, !matches.get_flag(options::NO_DEREF))?; + let atime = filetime_to_datetime(&atime) + .ok_or_else(|| miette!("Could not process the reference access time"))?; + let mtime = filetime_to_datetime(&mtime) + .ok_or_else(|| miette!("Could not process the reference modification time"))?; + Ok((parse_date(atime, date)?, parse_date(mtime, date)?)) + } + (Some(reference), None) => { + let reference_path = PathBuf::from(reference); + let reference_path = if reference_path.is_absolute() { + reference_path + } else { + context.state.cwd().join(reference_path) + }; + stat(&reference_path, !matches.get_flag(options::NO_DEREF)) + } + (None, Some(date)) => { + let timestamp = parse_date(Local::now(), date)?; + Ok((timestamp, timestamp)) + } + (None, None) => { + let timestamp = if let Some(ts) = matches.get_one::(options::sources::TIMESTAMP) + { + parse_timestamp(ts)? + } else { + datetime_to_filetime(&Local::now()) + }; + Ok((timestamp, timestamp)) + } + } + .map_err(|e| miette!("{}", e))?; + + for filename in files { + let pathbuf = if filename.to_str() == Some("-") { + pathbuf_from_stdout()? + } else { + filename + }; + + let path = pathbuf.as_path(); + + let metadata_result = if matches.get_flag(options::NO_DEREF) { + path.symlink_metadata() + } else { + path.metadata() + }; + + if let Err(e) = metadata_result { + if e.kind() != std::io::ErrorKind::NotFound { + return Err(miette!("setting times of {}: {}", path.display(), e)); + } + + if matches.get_flag(options::NO_CREATE) { + continue; + } + + if matches.get_flag(options::NO_DEREF) { + let _ = context.stderr.write_all( + format!( + "setting times of {:?}: No such file or directory", + path.display() + ) + .as_bytes(), + ); + continue; + } + + OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(path) + .map_err(|e| match e.kind() { + io::ErrorKind::NotFound => { + miette!( + "cannot touch {}: {}", + path.display(), + "No such file or directory".to_string() + ) + } + _ => miette!("cannot touch {}: {}", path.display(), e), + })?; + + // Minor optimization: if no reference time was specified, we're done. + if !matches.contains_id(options::SOURCES) { + continue; + } + } + + if matches.get_flag(options::ACCESS) + || matches.get_flag(options::MODIFICATION) + || matches.contains_id(options::TIME) + { + let st = stat(path, !matches.get_flag(options::NO_DEREF))?; + let time = matches + .get_one::(options::TIME) + .map(|s| s.as_str()) + .unwrap_or(""); + + if !(matches.get_flag(options::ACCESS) + || time.contains(&"access".to_owned()) + || time.contains(&"atime".to_owned()) + || time.contains(&"use".to_owned())) + { + atime = st.0; + } + + if !(matches.get_flag(options::MODIFICATION) + || time.contains(&"modify".to_owned()) + || time.contains(&"mtime".to_owned())) + { + mtime = st.1; + } + } + + // sets the file access and modification times for a file or a symbolic link. + // The filename, access time (atime), and modification time (mtime) are provided as inputs. + + // If the filename is not "-", indicating a special case for touch -h -, + // the code checks if the NO_DEREF flag is set, which means the user wants to + // set the times for a symbolic link itself, rather than the file it points to. + if path.to_string_lossy() == "-" { + set_file_times(path, atime, mtime) + } else if matches.get_flag(options::NO_DEREF) { + set_symlink_file_times(path, atime, mtime) + } else { + set_file_times(path, atime, mtime) + } + .map_err(|e| miette!("setting times of {}: {}", path.display(), e))?; + } + + Ok(()) +} + +fn stat(path: &Path, follow: bool) -> Result<(FileTime, FileTime)> { + let metadata = if follow { + fs::metadata(path).or_else(|_| fs::symlink_metadata(path)) + } else { + fs::symlink_metadata(path) + } + .map_err(|e| miette!("failed to get attributes of {}: {}", path.display(), e))?; + + Ok(( + FileTime::from_last_access_time(&metadata), + FileTime::from_last_modification_time(&metadata), + )) +} + +fn filetime_to_datetime(ft: &FileTime) -> Option> { + Some(DateTime::from_timestamp(ft.unix_seconds(), ft.nanoseconds())?.into()) +} + +fn parse_timestamp(s: &str) -> Result { + let now = Local::now(); + let parsed = if s.len() == 15 && s.contains('.') { + // Handle the specific format "202401010000.00" + NaiveDateTime::parse_from_str(s, "%Y%m%d%H%M.%S") + .map_err(|_| miette!("invalid date format '{}'", s))? + } else { + dtparse::parse(s) + .map(|(dt, _)| dt) + .map_err(|_| miette!("invalid date format '{}'", s))? + }; + + let local = now + .timezone() + .from_local_datetime(&parsed) + .single() + .ok_or_else(|| miette!("invalid date '{}'", s))?; + + // Handle leap seconds + let local = if parsed.second() == 59 && s.ends_with(".60") { + local + Duration::seconds(1) + } else { + local + }; + + // Check for daylight saving time issues + if (local + Duration::hours(1) - Duration::hours(1)).hour() != local.hour() { + return Err(miette!("invalid date '{}'", s)); + } + + Ok(datetime_to_filetime(&local)) +} + +// TODO: this may be a good candidate to put in fsext.rs +/// Returns a PathBuf to stdout. +/// +/// On Windows, uses GetFinalPathNameByHandleW to attempt to get the path +/// from the stdout handle. +fn pathbuf_from_stdout() -> Result { + #[cfg(all(unix, not(target_os = "android")))] + { + Ok(PathBuf::from("/dev/stdout")) + } + #[cfg(target_os = "android")] + { + Ok(PathBuf::from("/proc/self/fd/1")) + } + #[cfg(windows)] + { + use std::os::windows::prelude::AsRawHandle; + use windows_sys::Win32::Foundation::{ + GetLastError, ERROR_INVALID_PARAMETER, ERROR_NOT_ENOUGH_MEMORY, ERROR_PATH_NOT_FOUND, + HANDLE, MAX_PATH, + }; + use windows_sys::Win32::Storage::FileSystem::{ + GetFinalPathNameByHandleW, FILE_NAME_OPENED, + }; + + let handle = std::io::stdout().lock().as_raw_handle() as HANDLE; + let mut file_path_buffer: [u16; MAX_PATH as usize] = [0; MAX_PATH as usize]; + + // https://docs.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-getfinalpathnamebyhandlea#examples + // SAFETY: We transmute the handle to be able to cast *mut c_void into a + // HANDLE (i32) so rustc will let us call GetFinalPathNameByHandleW. The + // reference example code for GetFinalPathNameByHandleW implies that + // it is safe for us to leave lpszfilepath uninitialized, so long as + // the buffer size is correct. We know the buffer size (MAX_PATH) at + // compile time. MAX_PATH is a small number (260) so we can cast it + // to a u32. + let ret = unsafe { + GetFinalPathNameByHandleW( + handle, + file_path_buffer.as_mut_ptr(), + file_path_buffer.len() as u32, + FILE_NAME_OPENED, + ) + }; + + let buffer_size = match ret { + ERROR_PATH_NOT_FOUND | ERROR_NOT_ENOUGH_MEMORY | ERROR_INVALID_PARAMETER => { + return Err(miette!("GetFinalPathNameByHandleW failed with code {ret}")) + } + 0 => { + return Err(miette!( + "GetFinalPathNameByHandleW failed with code {}", + // SAFETY: GetLastError is thread-safe and has no documented memory unsafety. + unsafe { GetLastError() } + )); + } + e => e as usize, + }; + + // Don't include the null terminator + Ok(String::from_utf16(&file_path_buffer[0..buffer_size]) + .map_err(|e| miette!("Generated path is not valid UTF-16: {e}"))? + .into()) + } +} + +fn parse_date(ref_time: DateTime, s: &str) -> Result { + // Using the dtparse crate for more robust date parsing + + match dtparse::parse(s) { + Ok((naive_dt, offset)) => { + let dt = offset.map_or_else( + || Local.from_local_datetime(&naive_dt).unwrap(), + |off| DateTime::::from_naive_utc_and_offset(naive_dt, off), + ); + Ok(datetime_to_filetime(&dt)) + } + Err(_) => { + // Fallback to parsing Unix timestamp if dtparse fails + if let Some(stripped) = s.strip_prefix('@') { + stripped + .parse::() + .map(|ts| FileTime::from_unix_time(ts, 0)) + .map_err(|_| miette!("Unable to parse date: {s}")) + } else { + // Use ref_time as a base for relative date parsing + parse_datetime::parse_datetime_at_date(ref_time, s) + .map(|dt| datetime_to_filetime(&dt)) + .map_err(|_| miette!("Unable to parse date: {s}")) + } + } + } +} + +fn datetime_to_filetime(dt: &DateTime) -> FileTime { + FileTime::from_unix_time(dt.timestamp(), dt.timestamp_subsec_nanos()) +} diff --git a/crates/shell/src/commands/uname.rs b/crates/shell/src/commands/uname.rs new file mode 100644 index 0000000..2e08f2c --- /dev/null +++ b/crates/shell/src/commands/uname.rs @@ -0,0 +1,67 @@ +use deno_task_shell::{ExecuteResult, ShellCommand, ShellCommandContext}; +use futures::future::LocalBoxFuture; +use uu_uname::{options, UNameOutput}; +pub struct UnameCommand; + +fn display(uname: &UNameOutput) -> String { + let mut output = String::new(); + for name in [ + uname.kernel_name.as_ref(), + uname.nodename.as_ref(), + uname.kernel_release.as_ref(), + uname.kernel_version.as_ref(), + uname.machine.as_ref(), + uname.os.as_ref(), + uname.processor.as_ref(), + uname.hardware_platform.as_ref(), + ] + .into_iter() + .flatten() + { + output.push_str(name); + output.push(' '); + } + output +} + +impl ShellCommand for UnameCommand { + fn execute(&self, mut context: ShellCommandContext) -> LocalBoxFuture<'static, ExecuteResult> { + Box::pin(async move { + match execute_uname(&mut context) { + Ok(_) => ExecuteResult::from_exit_code(0), + Err(e) => { + context.stderr.write_line(&e).ok(); + ExecuteResult::from_exit_code(1) + } + } + }) + } +} + +fn execute_uname(context: &mut ShellCommandContext) -> Result<(), String> { + let matches = uu_uname::uu_app() + .override_usage("uname [OPTION]...") + .no_binary_name(true) + .try_get_matches_from(&context.args) + .map_err(|e| e.to_string())?; + + let options = uu_uname::Options { + all: matches.get_flag(options::ALL), + kernel_name: matches.get_flag(options::KERNEL_NAME), + nodename: matches.get_flag(options::NODENAME), + kernel_release: matches.get_flag(options::KERNEL_RELEASE), + kernel_version: matches.get_flag(options::KERNEL_VERSION), + machine: matches.get_flag(options::MACHINE), + processor: matches.get_flag(options::PROCESSOR), + hardware_platform: matches.get_flag(options::HARDWARE_PLATFORM), + os: matches.get_flag(options::OS), + }; + + let uname = UNameOutput::new(&options).unwrap(); + context + .stdout + .write_line(display(&uname).trim_end()) + .map_err(|e| e.to_string())?; + + Ok(()) +} diff --git a/crates/shell/src/commands/which.rs b/crates/shell/src/commands/which.rs new file mode 100644 index 0000000..6ddb114 --- /dev/null +++ b/crates/shell/src/commands/which.rs @@ -0,0 +1,53 @@ +use deno_task_shell::{ExecuteResult, ShellCommand, ShellCommandContext}; +use futures::future::LocalBoxFuture; + +pub struct WhichCommand; + +impl ShellCommand for WhichCommand { + fn execute(&self, mut context: ShellCommandContext) -> LocalBoxFuture<'static, ExecuteResult> { + Box::pin(futures::future::ready(match execute_which(&mut context) { + Ok(_) => ExecuteResult::from_exit_code(0), + Err(exit_code) => ExecuteResult::from_exit_code(exit_code), + })) + } +} + +fn execute_which(context: &mut ShellCommandContext) -> Result<(), i32> { + if context.args.len() != 1 { + context.stderr.write_line("Expected one argument").ok(); + return Err(1); + } + + let arg = &context.args[0]; + + if let Some(alias) = context.state.alias_map().get(arg) { + context + .stdout + .write_line(&format!("alias: \"{}\"", alias.join(" "))) + .ok(); + return Ok(()); + } + + if context.state.resolve_custom_command(arg).is_some() { + context.stdout.write_line("").ok(); + return Ok(()); + } + + if let Some(path) = context.state.env_vars().get("PATH") { + let path = std::ffi::OsString::from(path); + let which_result = which::which_in_global(arg, Some(path)) + .and_then(|mut i| i.next().ok_or(which::Error::CannotFindBinaryPath)); + + if let Ok(p) = which_result { + context.stdout.write_line(&p.to_string_lossy()).ok(); + return Ok(()); + } + } + + context + .stderr + .write_line(&format!("{} not found", arg)) + .ok(); + + Err(1) +} diff --git a/crates/shell/src/completion.rs b/crates/shell/src/completion.rs index 232b767..0206620 100644 --- a/crates/shell/src/completion.rs +++ b/crates/shell/src/completion.rs @@ -34,6 +34,12 @@ impl ShellCompleter { } } +impl Default for ShellCompleter { + fn default() -> Self { + ShellCompleter + } +} + impl Completer for ShellCompleter { type Candidate = Pair; @@ -70,7 +76,7 @@ impl Completer for ShellCompleter { } fn extract_word(line: &str, pos: usize) -> (usize, &str) { - if line.ends_with(" ") { + if line.ends_with(' ') { return (pos, ""); } let words: Vec<_> = line[..pos].split_whitespace().collect(); @@ -88,6 +94,9 @@ fn complete_filenames(_is_start: bool, word: &str, matches: &mut Vec) { // Determine the full directory path to search let search_dir = if dir_path.starts_with('/') { dir_path.to_string() + } else if let Some(stripped) = dir_path.strip_prefix('~') { + let home_dir = dirs::home_dir().unwrap(); + format!("{}{}", home_dir.display(), stripped) } else { format!("./{}", dir_path) }; diff --git a/crates/shell/src/execute.rs b/crates/shell/src/execute.rs new file mode 100644 index 0000000..f52d299 --- /dev/null +++ b/crates/shell/src/execute.rs @@ -0,0 +1,47 @@ +use deno_task_shell::{ + execute_sequential_list, AsyncCommandBehavior, ExecuteResult, ShellPipeReader, ShellPipeWriter, + ShellState, +}; +use miette::{Context, IntoDiagnostic}; + +pub async fn execute_inner(text: &str, state: ShellState) -> miette::Result { + let list = deno_task_shell::parser::parse(text); + + let mut stderr = ShellPipeWriter::stderr(); + let stdout = ShellPipeWriter::stdout(); + let stdin = ShellPipeReader::stdin(); + + if let Err(e) = list { + stderr.write_all(format!("Syntax error: {:?}", e).as_bytes())?; + return Ok(ExecuteResult::Exit(1, vec![])); + } + + // spawn a sequential list and pipe its output to the environment + let result = execute_sequential_list( + list.unwrap(), + state, + stdin, + stdout, + stderr, + AsyncCommandBehavior::Wait, + ) + .await; + + Ok(result) +} + +pub async fn execute(text: &str, state: &mut ShellState) -> miette::Result { + let result = execute_inner(text, state.clone()).await?; + + match result { + ExecuteResult::Continue(exit_code, changes, _) => { + // set CWD to the last command's CWD + state.apply_changes(&changes); + std::env::set_current_dir(state.cwd()) + .into_diagnostic() + .context("Failed to set CWD")?; + Ok(exit_code) + } + ExecuteResult::Exit(_, _) => Ok(0), + } +} diff --git a/crates/shell/src/helper.rs b/crates/shell/src/helper.rs new file mode 100644 index 0000000..5f79a11 --- /dev/null +++ b/crates/shell/src/helper.rs @@ -0,0 +1,43 @@ +use rustyline::{ + highlight::Highlighter, validate::MatchingBracketValidator, Completer, Helper, Hinter, + Validator, +}; + +use crate::completion; + +use std::borrow::Cow::Borrowed; + +#[derive(Helper, Completer, Hinter, Validator)] +pub(crate) struct ShellPromptHelper { + #[rustyline(Completer)] + completer: completion::ShellCompleter, + + #[rustyline(Validator)] + validator: MatchingBracketValidator, + + pub colored_prompt: String, +} + +impl Default for ShellPromptHelper { + fn default() -> Self { + Self { + completer: completion::ShellCompleter, + validator: MatchingBracketValidator::new(), + colored_prompt: String::new(), + } + } +} + +impl Highlighter for ShellPromptHelper { + fn highlight_prompt<'b, 's: 'b, 'p: 'b>( + &'s self, + prompt: &'p str, + default: bool, + ) -> std::borrow::Cow<'b, str> { + if default { + Borrowed(&self.colored_prompt) + } else { + Borrowed(prompt) + } + } +} diff --git a/crates/shell/src/lib.rs b/crates/shell/src/lib.rs new file mode 100644 index 0000000..bfdd005 --- /dev/null +++ b/crates/shell/src/lib.rs @@ -0,0 +1,2 @@ +pub mod commands; +pub mod execute; diff --git a/crates/shell/src/main.rs b/crates/shell/src/main.rs index 68e82a8..a1b92bc 100644 --- a/crates/shell/src/main.rs +++ b/crates/shell/src/main.rs @@ -1,88 +1,63 @@ -use std::collections::HashMap; +use std::path::Path; use std::path::PathBuf; -use std::rc::Rc; -use anyhow::Context; use clap::Parser; -use completion::ShellCompleter; -use deno_task_shell::{ - execute_sequential_list, AsyncCommandBehavior, ExecuteResult, ShellCommand, ShellPipeReader, - ShellPipeWriter, ShellState, -}; +use deno_task_shell::parser::debug_parse; +use deno_task_shell::ShellState; +use miette::Context; +use miette::IntoDiagnostic; use rustyline::error::ReadlineError; use rustyline::{CompletionType, Config, Editor}; mod commands; mod completion; +mod execute; +mod helper; -fn commands() -> HashMap> { - HashMap::from([( - "ls".to_string(), - Rc::new(commands::LsCommand) as Rc, - )]) -} - -async fn execute(text: &str, state: &mut ShellState) -> anyhow::Result { - let list = deno_task_shell::parser::parse(text); - - let mut stderr = ShellPipeWriter::stderr(); - let stdout = ShellPipeWriter::stdout(); - let stdin = ShellPipeReader::stdin(); - - if let Err(e) = list { - let _ = stderr.write_line(&format!("Syntax error: {}", e)); - return Ok(1); - } - - // spawn a sequential list and pipe its output to the environment - let result = execute_sequential_list( - list.unwrap(), - state.clone(), - stdin, - stdout, - stderr, - AsyncCommandBehavior::Wait, - ) - .await; - - match result { - ExecuteResult::Continue(exit_code, changes, _) => { - state.apply_changes(&changes); - // set CWD to the last command's CWD - std::env::set_current_dir(state.cwd()).context("Failed to set CWD")?; - Ok(exit_code) - } - ExecuteResult::Exit(_, _) => Ok(0), - } -} - +pub use execute::execute; #[derive(Parser)] struct Options { - #[clap(short, long)] + /// The path to the file that should be executed file: Option, + + #[clap(short, long)] + debug: bool, } fn init_state() -> ShellState { let env_vars = std::env::vars().collect(); let cwd = std::env::current_dir().unwrap(); - ShellState::new(env_vars, &cwd, commands()) + ShellState::new(env_vars, &cwd, commands::get_commands()) } -async fn interactive() -> anyhow::Result<()> { +async fn interactive() -> miette::Result<()> { let config = Config::builder() .history_ignore_space(true) - .completion_type(CompletionType::Circular) + .completion_type(CompletionType::List) .build(); - let mut rl = Editor::with_config(config)?; + ctrlc::set_handler(move || { + println!("Received Ctrl+C"); + }) + .expect("Error setting Ctrl-C handler"); let h = ShellCompleter::new(); + let mut rl = Editor::with_config(config).into_diagnostic()?; - rl.set_helper(Some(h)); + let helper = helper::ShellPromptHelper::default(); + rl.set_helper(Some(helper)); let mut state = init_state(); - let home = dirs::home_dir().context("Couldn't get home directory")?; + let home = dirs::home_dir().ok_or(miette::miette!("Couldn't get home directory"))?; + let history_file: PathBuf = [home.as_path(), Path::new(".shell_history")] + .iter() + .collect(); + if Path::new(history_file.as_path()).exists() { + rl.load_history(history_file.as_path()) + .into_diagnostic() + .context("Failed to read the command history")?; + } let mut _prev_exit_code = 0; loop { @@ -92,26 +67,50 @@ async fn interactive() -> anyhow::Result<()> { // Display the prompt and read a line let readline = { let cwd = state.cwd().to_string_lossy().to_string(); - let home_str = home - .to_str() - .context("Couldn't convert home directory path to UTF-8 string")?; - - let prompt = cwd - .strip_prefix(home_str) - .map(|stripped| format!("~{}$ ", stripped.replace('\\', "/"))) - .unwrap_or_else(|| format!("{}$ ", cwd)); + let home_str = home.to_str().ok_or(miette::miette!( + "Couldn't convert home directory path to UTF-8 string" + ))?; + if !state.last_command_cd() { + state.update_git_branch(); + } + + let mut git_branch: String = "".to_string(); + if state.git_repository() { + git_branch = match state.git_branch().strip_prefix("ref: refs/heads/") { + Some(stripped) => stripped.to_string(), + None => { + let mut hash = state.git_branch().to_string(); + if hash.len() > 7 { + hash = hash[0..7].to_string() + "..."; + } + hash + } + }; + git_branch = "(".to_owned() + &git_branch + ")"; + } + + let display_cwd = if let Some(stripped) = cwd.strip_prefix(home_str) { + format!("~{}", stripped.replace('\\', "/")) + } else { + cwd.to_string() + }; + + let prompt = format!("{}{git_branch}$ ", display_cwd); + let color_prompt = format!("\x1b[34m{}\x1b[31m{git_branch}\x1b[0m$ ", display_cwd); + rl.helper_mut().unwrap().colored_prompt = color_prompt; rl.readline(&prompt) }; match readline { Ok(line) => { // Add the line to history - rl.add_history_entry(line.as_str())?; + rl.add_history_entry(line.as_str()).into_diagnostic()?; // Process the input (here we just echo it back) - _prev_exit_code = execute(&line, &mut state) + let prev_exit_code = execute(&line, &mut state) .await .context("Failed to execute")?; + state.set_last_command_exit_code(prev_exit_code); // Check for exit command if line.trim().eq_ignore_ascii_case("exit") { @@ -120,10 +119,11 @@ async fn interactive() -> anyhow::Result<()> { } } Err(ReadlineError::Interrupted) => { + // We start a new prompt on Ctrl-C, like Bash does println!("CTRL-C"); - break; } Err(ReadlineError::Eof) => { + // We exit the shell on Ctrl-D, like Bash does println!("CTRL-D"); break; } @@ -133,17 +133,24 @@ async fn interactive() -> anyhow::Result<()> { } } } + rl.save_history(history_file.as_path()) + .into_diagnostic() + .context("Failed to write the command history")?; Ok(()) } #[tokio::main] -async fn main() -> anyhow::Result<()> { +async fn main() -> miette::Result<()> { let options = Options::parse(); if let Some(file) = options.file { let script_text = std::fs::read_to_string(&file).unwrap(); let mut state = init_state(); + if options.debug { + debug_parse(&script_text); + return Ok(()); + } execute(&script_text, &mut state).await?; } else { interactive().await?; diff --git a/crates/tests/Cargo.toml b/crates/tests/Cargo.toml new file mode 100644 index 0000000..3e67bae --- /dev/null +++ b/crates/tests/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "tests" +version = "0.1.0" +edition = "2021" + +[dependencies] +deno_task_shell = { path = "../deno_task_shell", features = ["shell"] } +shell = { path = "../shell" } +futures = "0.3.30" +tokio = { version = "1.40.0", features = ["full"] } +dirs = "5.0.1" +miette = "7.2.0" + +[dev-dependencies] +pretty_assertions = "1.0.0" +tempfile = "3.12.0" diff --git a/crates/tests/src/lib.rs b/crates/tests/src/lib.rs new file mode 100644 index 0000000..65c5ea7 --- /dev/null +++ b/crates/tests/src/lib.rs @@ -0,0 +1,1062 @@ +// Copyright 2018-2024 the Deno authors. MIT license. + +#[cfg(test)] +mod test_builder; +#[cfg(test)] +use deno_task_shell::ExecuteResult; +#[cfg(test)] +use futures::FutureExt; +#[cfg(test)] +use test_builder::TestBuilder; + +#[cfg(test)] +const FOLDER_SEPARATOR: char = if cfg!(windows) { '\\' } else { '/' }; + +#[tokio::test] +async fn commands() { + TestBuilder::new() + .command("echo 1") + .assert_stdout("1\n") + .run() + .await; + + TestBuilder::new() + .command("echo 1 2 3") + .assert_stdout("1 2 3\n") + .run() + .await; + + TestBuilder::new() + .command(r#"echo "1 2 3""#) + .assert_stdout("1 2 3\n") + .run() + .await; + + TestBuilder::new() + .command(r"echo 1 2\ \ \ 3") + .assert_stdout("1 2 3\n") + .run() + .await; + + TestBuilder::new() + .command(r#"echo "1 2\ \ \ 3""#) + .assert_stdout("1 2\\ \\ \\ 3\n") + .run() + .await; + + TestBuilder::new() + .command(r#"echo test$(echo "1 2")"#) + .assert_stdout("test1 2\n") + .run() + .await; + + TestBuilder::new() + .command(r#"TEST="1 2" ; echo $TEST"#) + .assert_stdout("1 2\n") + .run() + .await; + + TestBuilder::new() + .command(r#""echo" "1""#) + .assert_stdout("1\n") + .run() + .await; + + TestBuilder::new() + .command(r#""echo" "*""#) + .assert_stdout("*\n") + .run() + .await; + + TestBuilder::new() + .command("echo test-dashes") + .assert_stdout("test-dashes\n") + .run() + .await; + + TestBuilder::new() + .command("echo 'a/b'/c") + .assert_stdout("a/b/c\n") + .run() + .await; + + TestBuilder::new() + .command("echo 'a/b'ctest\"te st\"'asdf'") + .assert_stdout("a/bctestte stasdf\n") + .run() + .await; + + TestBuilder::new() + .command("echo --test=\"2\" --test='2' test\"TEST\" TEST'test'TEST 'test''test' test'test'\"test\" \"test\"\"test\"'test'") + .assert_stdout("--test=2 --test=2 testTEST TESTtestTEST testtest testtesttest testtesttest\n") + .run() + .await; + + TestBuilder::new() + .command("deno eval 'console.log(1)'") + .env_var("PATH", "") + .assert_stderr("deno: command not found\n") + .assert_exit_code(127) + .run() + .await; + + TestBuilder::new().command("unset").run().await; +} + +#[tokio::test] +async fn boolean_logic() { + TestBuilder::new() + .command("echo 1 && echo 2 || echo 3") + .assert_stdout("1\n2\n") + .run() + .await; + + TestBuilder::new() + .command("echo 1 || echo 2 && echo 3") + .assert_stdout("1\n3\n") + .run() + .await; + + TestBuilder::new() + .command("echo 1 || (echo 2 && echo 3)") + .assert_stdout("1\n") + .run() + .await; + + TestBuilder::new() + .command("false || false || (echo 2 && false) || echo 3") + .assert_stdout("2\n3\n") + .run() + .await; +} + +#[tokio::test] +async fn exit() { + TestBuilder::new() + .command("exit 1") + .assert_exit_code(1) + .run() + .await; + + TestBuilder::new() + .command("exit 5") + .assert_exit_code(5) + .run() + .await; + + TestBuilder::new() + .command("exit 258 && echo 1") + .assert_exit_code(2) + .run() + .await; + + TestBuilder::new() + .command("(exit 0) && echo 1") + .assert_stdout("1\n") + .run() + .await; + + TestBuilder::new() + .command("(exit 1) && echo 1") + .assert_exit_code(1) + .run() + .await; + + TestBuilder::new() + .command("echo 1 && (exit 1)") + .assert_stdout("1\n") + .assert_exit_code(1) + .run() + .await; + + TestBuilder::new() + .command("exit ; echo 2") + .assert_exit_code(1) + .run() + .await; + + TestBuilder::new() + .command("exit bad args") + .assert_stderr("exit: too many arguments\n") + .assert_exit_code(2) + .run() + .await; +} + +#[tokio::test] +async fn command_substitution() { + TestBuilder::new() + .command("echo $(echo 1)") + .assert_stdout("1\n") + .run() + .await; + + TestBuilder::new() + .command("echo $(echo 1 && echo 2)") + .assert_stdout("1 2\n") + .run() + .await; + + // async inside subshell should wait + TestBuilder::new() + .command("$(sleep 0.1 && echo 1 & echo echo) 2") + .assert_stdout("1 2\n") + .run() + .await; + TestBuilder::new() + .command("$(sleep 0.1 && echo 1 && exit 5 &) ; echo 2") + .assert_stdout("2\n") + .assert_stderr("1: command not found\n") + .run() + .await; +} + +#[tokio::test] +async fn sequential_lists() { + TestBuilder::new() + .command(r#"echo 1 ; sleep 0.1 && echo 4 & echo 2 ; echo 3;"#) + .assert_stdout("1\n2\n3\n4\n") + .run() + .await; +} +#[tokio::test] +async fn pipeline() { + TestBuilder::new() + .command(r#"echo 1 | echo 2 && echo 3"#) + .assert_stdout("2\n3\n") + .run() + .await; + + // TODO: implement tee in shell and then enable this test + // TestBuilder::new() + // .command(r#"echo 1 | tee output.txt"#) + // .assert_stdout("1\n") + // .assert_file_equals("output.txt", "1\n") + // .run() + // .await; + + TestBuilder::new() + .command(r#"echo 1 | cat > output.txt"#) + .assert_file_equals("output.txt", "1\n") + .run() + .await; +} + +#[tokio::test] +async fn redirects_input() { + TestBuilder::new() + .file("test.txt", "Hi!") + .command(r#"cat - < test.txt"#) + .assert_stdout("Hi!") + .run() + .await; + + TestBuilder::new() + .file("test.txt", "Hi!\n") + .command(r#"cat - < test.txt && echo There"#) + .assert_stdout("Hi!\nThere\n") + .run() + .await; + + TestBuilder::new() + .command(r#"cat - <&0"#) + .assert_stderr("deno_task_shell: input redirecting file descriptors is not implemented\n") + .assert_exit_code(1) + .run() + .await; +} + +#[tokio::test] +async fn pwd() { + TestBuilder::new() + .directory("sub_dir") + .file("file.txt", "test") + .command("pwd && cd sub_dir && pwd && cd ../ && pwd") + // the actual temp directory will get replaced here + .assert_stdout(&format!( + "$TEMP_DIR\n$TEMP_DIR{FOLDER_SEPARATOR}sub_dir\n$TEMP_DIR\n" + )) + .run() + .await; + + TestBuilder::new() + .command("pwd -M") + .assert_stderr("pwd: unsupported flag: -M\n") + .assert_exit_code(1) + .run() + .await; +} + +#[tokio::test] +async fn subshells() { + TestBuilder::new() + .command("(export TEST=1) && echo $TEST") + .assert_stdout("\n") + .assert_exit_code(0) + .run() + .await; + TestBuilder::new() + .directory("sub_dir") + .command("echo $PWD && (cd sub_dir && echo $PWD) && echo $PWD") + .assert_stdout(&format!( + "$TEMP_DIR\n$TEMP_DIR{FOLDER_SEPARATOR}sub_dir\n$TEMP_DIR\n" + )) + .assert_exit_code(0) + .run() + .await; + TestBuilder::new() + .command("export TEST=1 && (echo $TEST && unset TEST && echo $TEST) && echo $TEST") + .assert_stdout("1\n\n1\n") + .assert_exit_code(0) + .run() + .await; + TestBuilder::new() + .command("(exit 1) && echo 1") + .assert_exit_code(1) + .run() + .await; + TestBuilder::new() + .command("(exit 1) || echo 1") + .assert_stdout("1\n") + .assert_exit_code(0) + .run() + .await; +} + +#[tokio::test] +#[cfg(unix)] +async fn pwd_logical() { + TestBuilder::new() + .directory("main") + .command("ln -s main symlinked_main && cd symlinked_main && pwd && pwd -L") + .assert_stdout("$TEMP_DIR/symlinked_main\n$TEMP_DIR/main\n") + .run() + .await; +} + +#[tokio::test] +async fn cat() { + // no args + TestBuilder::new() + .command("cat") + .stdin("hello") + .assert_stdout("hello") + .run() + .await; + + // dash + TestBuilder::new() + .command("cat -") + .stdin("hello") + .assert_stdout("hello") + .run() + .await; + + // file + TestBuilder::new() + .command("cat file") + .file("file", "test") + .assert_stdout("test") + .run() + .await; + + // multiple files + TestBuilder::new() + .command("cat file1 file2") + .file("file1", "test") + .file("file2", "other") + .assert_stdout("testother") + .run() + .await; + + // multiple files and stdin + TestBuilder::new() + .command("cat file1 file2 -") + .file("file1", "test\n") + .file("file2", "other\n") + .stdin("hello") + .assert_stdout("test\nother\nhello") + .run() + .await; + + // multiple files and stdin different order + TestBuilder::new() + .command("cat file1 - file2") + .file("file1", "test\n") + .file("file2", "other\n") + .stdin("hello\n") + .assert_stdout("test\nhello\nother\n") + .run() + .await; + + // file containing a command to evaluate + TestBuilder::new() + .command("$(cat file)") + .file("file", "echo hello") + .assert_stdout("hello\n") + .run() + .await; +} + +#[tokio::test] +async fn head() { + // no args + TestBuilder::new() + .command("head") + .stdin("foo\nbar\nbaz\nqux\nquuux\ncorge\ngrault\ngarply\nwaldo\nfred\nplugh\n") + .assert_stdout("foo\nbar\nbaz\nqux\nquuux\ncorge\ngrault\ngarply\nwaldo\nfred\n") + .run() + .await; + + // dash + TestBuilder::new() + .command("head -") + .stdin("foo\nbar\nbaz\nqux\nquuux\ncorge\ngrault\ngarply\nwaldo\nfred\nplugh\n") + .assert_stdout("foo\nbar\nbaz\nqux\nquuux\ncorge\ngrault\ngarply\nwaldo\nfred\n") + .run() + .await; + + // file + TestBuilder::new() + .command("head file") + .file( + "file", + "foo\nbar\nbaz\nqux\nquuux\ncorge\ngrault\ngarply\nwaldo\nfred\nplugh\n", + ) + .assert_stdout("foo\nbar\nbaz\nqux\nquuux\ncorge\ngrault\ngarply\nwaldo\nfred\n") + .run() + .await; + + // dash + longer than internal buffer (512) + TestBuilder::new() + .command("head -") + .stdin( + "foo\nbar\nbaz\nqux\nquuux\ncorge\ngrault\ngarply\nwaldo\nfred\nplugh\n" + .repeat(10) + .as_str(), + ) + .assert_stdout("foo\nbar\nbaz\nqux\nquuux\ncorge\ngrault\ngarply\nwaldo\nfred\n") + .run() + .await; + + // file + longer than internal buffer (512) + TestBuilder::new() + .command("head file") + .file( + "file", + "foo\nbar\nbaz\nqux\nquuux\ncorge\ngrault\ngarply\nwaldo\nfred\nplugh\n" + .repeat(1024) + .as_str(), + ) + .assert_stdout("foo\nbar\nbaz\nqux\nquuux\ncorge\ngrault\ngarply\nwaldo\nfred\n") + .run() + .await; + + // shorter than 10 lines + TestBuilder::new() + .command("head") + .stdin("foo\nbar") + .assert_stdout("foo\nbar") + .run() + .await; + + // -n + TestBuilder::new() + .command("head -n 2") + .stdin("foo\nbar\nbaz\nqux\nquuux") + .assert_stdout("foo\nbar\n") + .run() + .await; + + // --lines + TestBuilder::new() + .command("head --lines=3") + .stdin("foo\nbar\nbaz\nqux\nquuux") + .assert_stdout("foo\nbar\nbaz\n") + .run() + .await; +} + +// Basic integration tests as there are unit tests in the commands +#[tokio::test] +async fn mv() { + // single file + TestBuilder::new() + .command("mv file1.txt file2.txt") + .file("file1.txt", "test") + .assert_not_exists("file1.txt") + .assert_exists("file2.txt") + .run() + .await; + + // multiple files to folder + TestBuilder::new() + .command("mkdir sub_dir && mv file1.txt file2.txt sub_dir") + .file("file1.txt", "test1") + .file("file2.txt", "test2") + .assert_not_exists("file1.txt") + .assert_not_exists("file2.txt") + .assert_exists("sub_dir/file1.txt") + .assert_exists("sub_dir/file2.txt") + .run() + .await; + + // error message + TestBuilder::new() + .command("mv file1.txt file2.txt") + .assert_exit_code(1) + .assert_stderr(&format!( + "mv: could not move file1.txt to file2.txt: {}\n", + no_such_file_error_text() + )) + .run() + .await; +} + +// Basic integration tests as there are unit tests in the commands +#[tokio::test] +async fn cp() { + // single file + TestBuilder::new() + .command("cp file1.txt file2.txt") + .file("file1.txt", "test") + .assert_exists("file1.txt") + .assert_exists("file2.txt") + .run() + .await; + + // multiple files to folder + TestBuilder::new() + .command("mkdir sub_dir && cp file1.txt file2.txt sub_dir") + .file("file1.txt", "test1") + .file("file2.txt", "test2") + .assert_exists("file1.txt") + .assert_exists("file2.txt") + .assert_exists("sub_dir/file1.txt") + .assert_exists("sub_dir/file2.txt") + .run() + .await; + + // error message + TestBuilder::new() + .command("cp file1.txt file2.txt") + .assert_exit_code(1) + .assert_stderr(&format!( + "cp: could not copy file1.txt to file2.txt: {}\n", + no_such_file_error_text() + )) + .run() + .await; +} + +// Basic integration tests as there are unit tests in the commands +#[tokio::test] +async fn mkdir() { + TestBuilder::new() + .command("mkdir sub_dir") + .assert_exists("sub_dir") + .run() + .await; + + // error message + TestBuilder::new() + .command("mkdir file.txt") + .file("file.txt", "test") + .assert_stderr("mkdir: cannot create directory 'file.txt': File exists\n") + .assert_exit_code(1) + .run() + .await; +} + +// Basic integration tests as there are unit tests in the commands +#[tokio::test] +async fn rm() { + TestBuilder::new() + .command("mkdir sub_dir && rm -d sub_dir && rm file.txt") + .file("file.txt", "") + .assert_not_exists("sub_dir") + .assert_not_exists("file.txt") + .run() + .await; + + // error message + TestBuilder::new() + .command("rm file.txt") + .assert_stderr(&format!( + "rm: cannot remove 'file.txt': {}\n", + no_such_file_error_text() + )) + .assert_exit_code(1) + .run() + .await; +} + +#[cfg(windows)] +#[tokio::test] +async fn windows_resolve_command() { + // not cross platform, but still allow this +} + +#[tokio::test] +async fn custom_command() { + // not cross platform, but still allow this + TestBuilder::new() + .command("add 1 2") + .custom_command( + "add", + Box::new(|mut context| { + async move { + let mut sum = 0; + for val in context.args { + sum += val.parse::().unwrap(); + } + let _ = context.stderr.write_line(&sum.to_string()); + ExecuteResult::from_exit_code(0) + } + .boxed_local() + }), + ) + .assert_stderr("3\n") + .run() + .await; +} + +#[tokio::test] +async fn glob_basic() { + TestBuilder::new() + .file("test.txt", "test\n") + .file("test2.txt", "test2\n") + .command("cat *.txt") + .assert_stdout("test\ntest2\n") + .run() + .await; + + TestBuilder::new() + .file("test.txt", "test\n") + .file("test2.txt", "test2\n") + .command("cat test?.txt") + .assert_stdout("test2\n") + .run() + .await; + + TestBuilder::new() + .file("test.txt", "test\n") + .file("testa.txt", "testa\n") + .file("test2.txt", "test2\n") + .command("cat test[0-9].txt") + .assert_stdout("test2\n") + .run() + .await; + + TestBuilder::new() + .file("test.txt", "test\n") + .file("testa.txt", "testa\n") + .file("test2.txt", "test2\n") + .command("cat test[!a-z].txt") + .assert_stdout("test2\n") + .run() + .await; + + TestBuilder::new() + .file("test.txt", "test\n") + .file("testa.txt", "testa\n") + .file("test2.txt", "test2\n") + .command("cat test[a-z].txt") + .assert_stdout("testa\n") + .run() + .await; + + TestBuilder::new() + .directory("sub_dir/sub") + .file("sub_dir/sub/1.txt", "1\n") + .file("sub_dir/2.txt", "2\n") + .file("sub_dir/other.ts", "other\n") + .file("3.txt", "3\n") + .command("cat */*.txt") + .assert_stdout("2\n") + .run() + .await; + + TestBuilder::new() + .directory("sub_dir/sub") + .file("sub_dir/sub/1.txt", "1\n") + .file("sub_dir/2.txt", "2\n") + .file("sub_dir/other.ts", "other\n") + .file("3.txt", "3\n") + .command("cat **/*.txt") + .assert_stdout("3\n2\n1\n") + .run() + .await; + + TestBuilder::new() + .directory("sub_dir/sub") + .file("sub_dir/sub/1.txt", "1\n") + .file("sub_dir/2.txt", "2\n") + .file("sub_dir/other.ts", "other\n") + .file("3.txt", "3\n") + .command("cat $PWD/**/*.txt") + .assert_stdout("3\n2\n1\n") + .run() + .await; + + TestBuilder::new() + .directory("dir") + .file("dir/1.txt", "1\n") + .file("dir_1.txt", "2\n") + .command("cat dir*1.txt") + .assert_stdout("2\n") + .run() + .await; + + TestBuilder::new() + .file("test.txt", "test\n") + .file("test2.txt", "test2\n") + .command("cat *.ts") + .assert_stderr("glob: no matches found '$TEMP_DIR/*.ts'\n") + .assert_exit_code(1) + .run() + .await; + + let mut builder = TestBuilder::new(); + let temp_dir_path = builder.temp_dir_path(); + let error_pos = temp_dir_path.to_string_lossy().len() + 1; + builder.file("test.txt", "test\n") + .file("test2.txt", "test2\n") + .command("cat [].ts") + .assert_stderr(&format!("glob: no matches found '$TEMP_DIR/[].ts'. Pattern syntax error near position {}: invalid range pattern\n", error_pos)) + .assert_exit_code(1) + .run() + .await; + + TestBuilder::new() + .file("test.txt", "test\n") + .file("test2.txt", "test2\n") + .command("cat *.ts || echo 2") + .assert_stderr("glob: no matches found '$TEMP_DIR/*.ts'\n") + .assert_stdout("2\n") + .assert_exit_code(0) + .run() + .await; + + TestBuilder::new() + .file("test.txt", "test\n") + .file("test2.txt", "test2\n") + .command("cat *.ts 2> /dev/null || echo 2") + .assert_stderr("") + .assert_stdout("2\n") + .assert_exit_code(0) + .run() + .await; + + TestBuilder::new() + .command("echo --inspect='[::0]:3366'") + .assert_stderr("") + .assert_stdout("--inspect=[::0]:3366\n") + .assert_exit_code(0) + .run() + .await; +} + +#[tokio::test] +async fn glob_case_insensitive() { + TestBuilder::new() + .file("TEST.txt", "test\n") + .file("testa.txt", "testa\n") + .file("test2.txt", "test2\n") + .command("cat tes*.txt") + .assert_stdout("test\ntest2\ntesta\n") + .run() + .await; +} + +#[tokio::test] +async fn paren_escapes() { + TestBuilder::new() + .command(r"echo \( foo bar \)") + .assert_stdout("( foo bar )\n") + .run() + .await; +} + +#[tokio::test] +async fn uname() { + TestBuilder::new() + .command("uname") + .assert_exit_code(0) + .check_stdout(false) + .run() + .await; + + TestBuilder::new() + .command("uname -a") + .assert_exit_code(0) + .check_stdout(false) + .run() + .await; +} + +#[tokio::test] +async fn which() { + TestBuilder::new() + .command("which ls") + .assert_exit_code(0) + .assert_stdout("\n") + .run() + .await; + + TestBuilder::new() + .command("which bla foo") + .assert_exit_code(1) + .assert_stderr("Expected one argument\n") + .run() + .await; + + TestBuilder::new() + .command("alias ll=\"ls -al\" && which ll") + .assert_exit_code(0) + .assert_stdout("alias: \"ls -al\"\n") + .run() + .await; +} + +#[tokio::test] +async fn arithmetic() { + TestBuilder::new() + .command("echo $((1 + 2 * 3 + (4 / 5)))") + .assert_stdout("7\n") + .run() + .await; + + TestBuilder::new() + .command("echo $((a=1, b=2))") + .assert_stdout("2\n") + .run() + .await; + + TestBuilder::new() + .command("echo $((a=1, b=2, a+b))") + .assert_stdout("3\n") + .run() + .await; + + TestBuilder::new() + .command("echo $((1 + 2))") + .assert_stdout("3\n") + .run() + .await; + + TestBuilder::new() + .command("echo $((5 * 4))") + .assert_stdout("20\n") + .run() + .await; + + TestBuilder::new() + .command("echo $((10 / 3))") + .assert_stdout("3\n") + .run() + .await; + + TestBuilder::new() + .command("echo $((2 ** 3))") + .assert_stdout("8\n") + .run() + .await; + + TestBuilder::new() + .command("echo $((2 << 3))") + .assert_stdout("16\n") + .run() + .await; + + TestBuilder::new() + .command("echo $((2 << 3))") + .assert_stdout("16\n") + .run() + .await; +} + +#[tokio::test] +async fn date() { + TestBuilder::new() + .command("date") + .assert_exit_code(0) + .check_stdout(false) + .run() + .await; + + TestBuilder::new() + .command("date +%Y-%m-%d") + .assert_exit_code(0) + .check_stdout(false) + .run() + .await; +} + +#[tokio::test] +async fn if_clause() { + TestBuilder::new() + .command(r#"FOO=2; if [[ $FOO == 1 ]]; then echo "FOO is 1"; elif [[ $FOO -eq 2 ]]; then echo "FOO is 2"; else echo "FOO is not 1 or 2"; fi"#) + .assert_stdout("FOO is 2\n") + .run() + .await; + TestBuilder::new() + .command(r#"FOO=3; if [[ $FOO == 1 ]]; then echo "FOO is 1"; elif [[ $FOO -eq 2 ]]; then echo "FOO is 2"; else echo "FOO is not 1 or 2"; fi"#) + .assert_stdout("FOO is not 1 or 2\n") + .run() + .await; + + TestBuilder::new() + .command(r#"FOO=1; if [[ $FOO == 1 ]]; then echo "FOO is 1"; elif [[ $FOO -eq 2 ]]; then echo "FOO is 2"; else echo "FOO is not 1 or 2"; fi"#) + .assert_stdout("FOO is 1\n") + .run() + .await; + + TestBuilder::new() + .script_file("../../scripts/if_else.sh") + .assert_exit_code(0) + .assert_stdout("FOO is 2\n") + .assert_stdout("FOO is 2\n") + .assert_stdout("FOO is 2\n") + .assert_stdout("FOO is 2\n") + .run() + .await; +} + +#[tokio::test] +async fn touch() { + TestBuilder::new() + .command("touch file.txt") + .assert_exists("file.txt") + .check_stdout(false) + .run() + .await; + + TestBuilder::new() + .command("touch -m file.txt") + .assert_exists("file.txt") + .run() + .await; + + TestBuilder::new() + .command("touch -c nonexistent.txt") + .assert_not_exists("nonexistent.txt") + .run() + .await; + + TestBuilder::new() + .command("touch file1.txt file2.txt") + .assert_exists("file1.txt") + .assert_exists("file2.txt") + .run() + .await; + + TestBuilder::new() + .command("touch -d 'Tue Feb 20 14:30:00 2024' posix_locale.txt") + .assert_exists("posix_locale.txt") + .run() + .await; + + TestBuilder::new() + .command("touch -d '2024-02-20' iso_8601.txt") + .assert_exists("iso_8601.txt") + .run() + .await; + + TestBuilder::new() + .command("touch -t 202402201430.00 yyyymmddhhmmss.txt") + .assert_exists("yyyymmddhhmmss.txt") + .run() + .await; + + TestBuilder::new() + .command("touch -d '2024-02-20 14:30:00.000000' yyyymmddhhmmss_ms.txt") + .assert_exists("yyyymmddhhmmss_ms.txt") + .run() + .await; + + TestBuilder::new() + .command("touch -d '2024-02-20 14:30' yyyy_mm_dd_hh_mm.txt") + .assert_exists("yyyy_mm_dd_hh_mm.txt") + .run() + .await; + + TestBuilder::new() + .command("touch -t 202402201430 yyyymmddhhmm.txt") + .assert_exists("yyyymmddhhmm.txt") + .run() + .await; + + TestBuilder::new() + .command("touch -d '2024-02-20 14:30 +0000' yyyymmddhhmm_offset.txt") + .assert_exists("yyyymmddhhmm_offset.txt") + .run() + .await; + + TestBuilder::new() + .command("touch file.txt && touch -r file.txt reference.txt") + .assert_exists("reference.txt") + .run() + .await; + // Test for non-existent file with -c option + TestBuilder::new() + .command("touch -c nonexistent.txt") + .assert_not_exists("nonexistent.txt") + .run() + .await; + + // Test for invalid date format + TestBuilder::new() + .command("touch -d 'invalid date' invalid_date.txt") + .assert_stderr_contains("Unable to parse date: invalid date\n") + .assert_exit_code(1) + .run() + .await; + + // Test for invalid timestamp format + TestBuilder::new() + .command("touch -t 9999999999 invalid_timestamp.txt") + .assert_stderr_contains("invalid date format '9999999999'\n") + .assert_exit_code(1) + .run() + .await; + + TestBuilder::new() + .command("touch $TEMP_DIR/absolute_path.txt") + .assert_exists("$TEMP_DIR/absolute_path.txt") + .run() + .await; + + TestBuilder::new() + .command("touch $TEMP_DIR/non_existent_dir/non_existent.txt") + .assert_stderr_contains("No such file or directory") + .assert_exit_code(1) + .run() + .await; + + // TODO: implement ln in shell and then enable this test + // // Test with -h option on a symlink + // TestBuilder::new() + // .command("touch original.txt && ln -s original.txt symlink.txt && touch -h symlink.txt") + // .assert_exists("symlink.txt") + // .run() + // .await; + + // Test with multiple files, including one that doesn't exist + TestBuilder::new() + .command("touch existing.txt && touch existing.txt nonexistent.txt another_existing.txt") + .assert_exists("existing.txt") + .assert_exists("nonexistent.txt") + .assert_exists("another_existing.txt") + .run() + .await; +} + +#[cfg(test)] +fn no_such_file_error_text() -> &'static str { + if cfg!(windows) { + "The system cannot find the file specified. (os error 2)" + } else { + "No such file or directory (os error 2)" + } +} diff --git a/crates/tests/src/test_builder.rs b/crates/tests/src/test_builder.rs new file mode 100644 index 0000000..e45720f --- /dev/null +++ b/crates/tests/src/test_builder.rs @@ -0,0 +1,327 @@ +// Copyright 2018-2024 the Deno authors. MIT license. +use futures::future::LocalBoxFuture; +use miette::IntoDiagnostic; +use pretty_assertions::assert_eq; +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; +use std::rc::Rc; +use tokio::task::JoinHandle; + +use deno_task_shell::execute_with_pipes; +use deno_task_shell::fs_util; +use deno_task_shell::parser::parse; +use deno_task_shell::pipe; +use deno_task_shell::ExecuteResult; +use deno_task_shell::ShellCommand; +use deno_task_shell::ShellCommandContext; +use deno_task_shell::ShellPipeWriter; +use deno_task_shell::ShellState; + +type FnShellCommandExecute = + Box LocalBoxFuture<'static, ExecuteResult>>; + +struct FnShellCommand(FnShellCommandExecute); + +impl ShellCommand for FnShellCommand { + fn execute(&self, context: ShellCommandContext) -> LocalBoxFuture<'static, ExecuteResult> { + (self.0)(context) + } +} + +// Clippy is complaining about them all having `File` prefixes, +// but there might be non-file variants in the future. +#[allow(clippy::enum_variant_names)] +enum TestAssertion { + FileExists(String), + FileNotExists(String), + FileTextEquals(String, String), +} + +struct TempDir { + // hold to keep it alive until drop + _inner: tempfile::TempDir, + cwd: PathBuf, +} + +impl TempDir { + pub fn new() -> Self { + let temp_dir = tempfile::tempdir().unwrap(); + let cwd = fs_util::canonicalize_path(temp_dir.path()).unwrap(); + Self { + _inner: temp_dir, + cwd, + } + } +} + +pub struct TestBuilder { + // it is much much faster to lazily create this + temp_dir: Option, + env_vars: HashMap, + custom_commands: HashMap>, + command: String, + stdin: Vec, + expected_exit_code: i32, + expected_stderr: String, + expected_stdout: String, + expected_stderr_contains: String, + assertions: Vec, + assert_stdout: bool, + assert_stderr: bool, +} + +impl Default for TestBuilder { + fn default() -> Self { + Self::new() + } +} + +impl TestBuilder { + pub fn new() -> Self { + let env_vars = std::env::vars() + .map(|(key, value)| { + // For some very strange reason, key will sometimes be cased as "Path" + // or other times "PATH" on Windows. Since keys are case-insensitive on + // Windows, normalize the keys to be upper case. + if cfg!(windows) { + // need to normalize on windows + (key.to_uppercase(), value) + } else { + (key, value) + } + }) + .collect(); + + Self { + temp_dir: None, + env_vars, + custom_commands: shell::commands::get_commands(), + command: Default::default(), + stdin: Default::default(), + expected_exit_code: 0, + expected_stderr: Default::default(), + expected_stdout: Default::default(), + expected_stderr_contains: Default::default(), + assertions: Default::default(), + assert_stdout: true, + assert_stderr: false, + } + } + + pub fn ensure_temp_dir(&mut self) -> &mut Self { + self.get_temp_dir(); + self + } + + fn get_temp_dir(&mut self) -> &mut TempDir { + if self.temp_dir.is_none() { + self.temp_dir = Some(TempDir::new()); + } + self.temp_dir.as_mut().unwrap() + } + + pub fn temp_dir_path(&mut self) -> PathBuf { + self.get_temp_dir().cwd.clone() + } + + pub fn command(&mut self, command: &str) -> &mut Self { + self.command = command.to_string(); + self + } + + pub fn script_file(&mut self, path: &str) -> &mut Self { + self.command(fs::read_to_string(path).unwrap().as_str()); + self + } + + pub fn stdin(&mut self, stdin: &str) -> &mut Self { + self.stdin = stdin.as_bytes().to_vec(); + self + } + + pub fn directory(&mut self, path: &str) -> &mut Self { + let temp_dir = self.get_temp_dir(); + fs::create_dir_all(temp_dir.cwd.join(path)).unwrap(); + self + } + + pub fn env_var(&mut self, name: &str, value: &str) -> &mut Self { + self.env_vars.insert(name.to_string(), value.to_string()); + self + } + + pub fn custom_command(&mut self, name: &str, execute: FnShellCommandExecute) -> &mut Self { + self.custom_commands + .insert(name.to_string(), Rc::new(FnShellCommand(execute))); + self + } + + pub fn file(&mut self, path: &str, text: &str) -> &mut Self { + let temp_dir = self.get_temp_dir(); + fs::write(temp_dir.cwd.join(path), text).unwrap(); + self + } + + pub fn assert_exit_code(&mut self, code: i32) -> &mut Self { + self.expected_exit_code = code; + self + } + + pub fn assert_stderr(&mut self, output: &str) -> &mut Self { + self.expected_stderr.push_str(output); + self.assert_stderr = true; + self.expected_stderr_contains.clear(); + self + } + + pub fn assert_stderr_contains(&mut self, output: &str) -> &mut Self { + self.expected_stderr_contains.push_str(output); + self.assert_stderr = false; + self.expected_stderr.clear(); + self + } + + pub fn assert_stdout(&mut self, output: &str) -> &mut Self { + self.expected_stdout.push_str(output); + self + } + + pub fn check_stdout(&mut self, check_stdout: bool) -> &mut Self { + self.assert_stdout = check_stdout; + self + } + + pub fn assert_exists(&mut self, path: &str) -> &mut Self { + self.ensure_temp_dir(); + let temp_dir = if let Some(temp_dir) = &self.temp_dir { + temp_dir.cwd.display().to_string() + } else { + "NO_TEMP_DIR".to_string() + }; + self.assertions.push(TestAssertion::FileExists( + path.to_string().replace("$TEMP_DIR", &temp_dir), + )); + self + } + + pub fn assert_not_exists(&mut self, path: &str) -> &mut Self { + self.ensure_temp_dir(); + self.assertions + .push(TestAssertion::FileNotExists(path.to_string())); + self + } + + pub fn assert_file_equals(&mut self, path: &str, file_text: &str) -> &mut Self { + self.ensure_temp_dir(); + self.assertions.push(TestAssertion::FileTextEquals( + path.to_string(), + file_text.to_string(), + )); + self + } + + pub async fn run(&mut self) { + std::env::set_var("NO_GRAPHICS", "1"); + + let list = parse(&self.command).unwrap(); + let cwd = if let Some(temp_dir) = &self.temp_dir { + temp_dir.cwd.clone() + } else { + std::env::temp_dir() + }; + let (stdin, mut stdin_writer) = pipe(); + stdin_writer.write_all(&self.stdin).unwrap(); + drop(stdin_writer); // prevent a deadlock by dropping the writer + let (stdout, stdout_handle) = get_output_writer_and_handle(); + let (stderr, stderr_handle) = get_output_writer_and_handle(); + + let local_set = tokio::task::LocalSet::new(); + self.env_var("TEMP_DIR", &cwd.display().to_string()); + let state = ShellState::new( + self.env_vars.clone(), + &cwd, + self.custom_commands.drain().collect(), + ); + let exit_code = local_set + .run_until(execute_with_pipes(list, state, stdin, stdout, stderr)) + .await; + let temp_dir = if let Some(temp_dir) = &self.temp_dir { + temp_dir.cwd.display().to_string() + } else { + "NO_TEMP_DIR".to_string() + }; + let stderr_output = stderr_handle.await.unwrap(); + if self.assert_stderr { + assert_eq!( + stderr_output, + self.expected_stderr.replace("$TEMP_DIR", &temp_dir), + "\n\nFailed for: {}", + self.command + ); + } else if !self.expected_stderr_contains.is_empty() { + assert!( + stderr_output.contains( + &self + .expected_stderr_contains + .replace("$TEMP_DIR", &temp_dir) + ), + "\n\nFailed for: {}\nExpected stderr to contain: {}", + self.command, + self.expected_stderr_contains + ); + } + if self.assert_stdout { + assert_eq!( + stdout_handle.await.unwrap(), + self.expected_stdout.replace("$TEMP_DIR", &temp_dir), + "\n\nFailed for: {}", + self.command + ); + } + assert_eq!( + exit_code, self.expected_exit_code, + "\n\nFailed for: {}", + self.command + ); + + for assertion in &self.assertions { + match assertion { + TestAssertion::FileExists(path) => { + let path_to_check = cwd.join(path); + + assert!( + path_to_check.exists(), + "\n\nFailed for: {}\nExpected '{}' to exist.", + self.command, + path, + ) + } + TestAssertion::FileNotExists(path) => { + assert!( + !cwd.join(path).exists(), + "\n\nFailed for: {}\nExpected '{}' to not exist.", + self.command, + path, + ) + } + TestAssertion::FileTextEquals(path, text) => { + let actual_text = std::fs::read_to_string(cwd.join(path)) + .into_diagnostic() + .unwrap(); + assert_eq!( + &actual_text, text, + "\n\nFailed for: {}\nPath: {}", + self.command, path, + ) + } + } + } + } +} + +fn get_output_writer_and_handle() -> (ShellPipeWriter, JoinHandle) { + let (reader, writer) = pipe(); + let handle = reader.pipe_to_string_handle(); + (writer, handle) +} diff --git a/scripts/arithmetic.sh b/scripts/arithmetic.sh new file mode 100644 index 0000000..50c0752 --- /dev/null +++ b/scripts/arithmetic.sh @@ -0,0 +1 @@ +echo $((2 ** 3)) \ No newline at end of file diff --git a/scripts/exit_code.sh b/scripts/exit_code.sh new file mode 100644 index 0000000..cc1464a --- /dev/null +++ b/scripts/exit_code.sh @@ -0,0 +1 @@ +echo $? \ No newline at end of file diff --git a/scripts/for_loop.sh b/scripts/for_loop.sh new file mode 100644 index 0000000..9174943 --- /dev/null +++ b/scripts/for_loop.sh @@ -0,0 +1,23 @@ +# for i in {1..10}; do +# echo $i +# done + + +for i in $(seq 1 2 20); do + echo $i +done + + +# for i in {1..10..2}; do +# echo $i +# done + + +# for i in $(1,2,3,4,5); do +# echo $i +# done + + +for i in $(ls); do + printf "%s\n" "$i" +done \ No newline at end of file diff --git a/scripts/hello_world.sh b/scripts/hello_world.sh new file mode 100644 index 0000000..5543a6b --- /dev/null +++ b/scripts/hello_world.sh @@ -0,0 +1 @@ +echo "Hello World!" \ No newline at end of file diff --git a/scripts/if_else.sh b/scripts/if_else.sh new file mode 100644 index 0000000..5da3368 --- /dev/null +++ b/scripts/if_else.sh @@ -0,0 +1,41 @@ +FOO=2 +if [[ $FOO -eq 1 ]]; +then + echo "FOO is 1"; +elif [[ $FOO -eq 2 ]]; +then + echo "FOO is 2"; +else + echo "FOO is not 1 or 2"; +fi + +FOO=2 +if [[ $FOO -eq 1 ]]; then + echo "FOO is 1" +elif [[ $FOO -eq 2 ]]; then + echo "FOO is 2" +else + echo "FOO is not 1 or 2" +fi + +FOO=2 +if [[ $FOO -eq 1 ]]; +then + echo "FOO is 1"; +elif [[ $FOO -eq 2 ]]; +then + echo "FOO is 2"; +else + echo "FOO is not 1 or 2"; +fi + +FOO=2 +if [[ $FOO -eq 1 ]] +then + echo "FOO is 1"; +elif [[ $FOO -eq 2 ]] +then + echo "FOO is 2"; +else + echo "FOO is not 1 or 2"; +fi \ No newline at end of file diff --git a/scripts/source.sh b/scripts/source.sh new file mode 100644 index 0000000..6dcd2e0 --- /dev/null +++ b/scripts/source.sh @@ -0,0 +1 @@ +echo $PATH > ~/output.txt diff --git a/scripts/tilde_expansion.sh b/scripts/tilde_expansion.sh new file mode 100644 index 0000000..155cb69 --- /dev/null +++ b/scripts/tilde_expansion.sh @@ -0,0 +1,7 @@ +ls ~/Desktop + +echo ~$var + +echo ~\/bin + +echo ~parsabahraminejad/Desktop diff --git a/scripts/while_loop.sh b/scripts/while_loop.sh new file mode 100644 index 0000000..eb0a5ea --- /dev/null +++ b/scripts/while_loop.sh @@ -0,0 +1,5 @@ +COUNTER=-5 +while [[ $COUNTER -lt 5 ]]; do + echo The counter is $COUNTER + let COUNTER=COUNTER+1 +done \ No newline at end of file