├── .devcontainer └── devcontainer.json ├── .github └── workflows │ ├── coverage.yml │ ├── enforce-sha.yaml │ ├── release.yml │ ├── rust-linting.yml │ ├── rust-tests.yml │ └── typos.yml ├── .gitignore ├── .vscode └── launch.json ├── Cargo.lock ├── Cargo.toml ├── LICENSE.txt ├── README.md ├── codecov.yml ├── crates ├── deno_task_shell │ ├── .gitignore │ ├── .rustfmt.toml │ ├── Cargo.lock │ ├── Cargo.toml │ ├── LICENSE.md │ ├── README.md │ └── src │ │ ├── grammar.pest │ │ ├── lib.rs │ │ ├── parser.rs │ │ └── shell │ │ ├── command.rs │ │ ├── commands │ │ ├── args.rs │ │ ├── cat.rs │ │ ├── cd.rs │ │ ├── cp_mv.rs │ │ ├── echo.rs │ │ ├── executable.rs │ │ ├── exit.rs │ │ ├── export.rs │ │ ├── head.rs │ │ ├── mkdir.rs │ │ ├── mod.rs │ │ ├── pwd.rs │ │ ├── rm.rs │ │ ├── sleep.rs │ │ ├── unset.rs │ │ └── xargs.rs │ │ ├── execute.rs │ │ ├── fs_util.rs │ │ ├── mod.rs │ │ └── types.rs ├── shell │ ├── Cargo.toml │ └── src │ │ ├── commands │ │ ├── date.rs │ │ ├── mod.rs │ │ ├── printenv.rs │ │ ├── set.rs │ │ ├── time.rs │ │ ├── touch.rs │ │ ├── uname.rs │ │ └── which.rs │ │ ├── completion.rs │ │ ├── execute.rs │ │ ├── helper.rs │ │ ├── lib.rs │ │ └── main.rs └── tests │ ├── Cargo.toml │ ├── src │ ├── lib.rs │ ├── test_builder.rs │ └── test_runner.rs │ └── test-data │ ├── arithmetic.sh │ ├── case.sh │ ├── conditions.sh │ ├── echo.sh │ ├── loop.sh │ ├── source.sh │ └── variable_expansion.sh ├── scripts ├── arithmetic.sh ├── case_1.sh ├── exit.sh ├── exit_code.sh ├── for_loop.sh ├── hello_world.sh ├── if_else.sh ├── script_1.sh ├── script_2.sh ├── script_3.sh ├── script_4.sh ├── script_5.sh ├── script_6.sh ├── source.sh ├── tilde_expansion.sh └── while_loop.sh └── typos.toml /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "image": "mcr.microsoft.com/devcontainers/universal:2", 3 | "features": { 4 | "ghcr.io/devcontainers/features/rust:1": {} 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yml: -------------------------------------------------------------------------------- 1 | name: coverage 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | branches: 8 | - main 9 | pull_request: 10 | branches: 11 | - main 12 | 13 | jobs: 14 | test: 15 | name: coverage 16 | runs-on: ubuntu-latest 17 | container: 18 | image: xd009642/tarpaulin:develop-nightly 19 | options: --security-opt seccomp=unconfined 20 | steps: 21 | - name: Checkout repository 22 | uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2 23 | 24 | - name: Generate code coverage 25 | run: | 26 | cargo +nightly tarpaulin --verbose --all-features --workspace --timeout 120 --out xml 27 | 28 | - name: Upload to codecov.io 29 | uses: codecov/codecov-action@f32b3a3741e1053eb607407145bc9619351dc93b # v2 30 | with: 31 | token: ${{secrets.CODECOV_TOKEN}} 32 | fail_ci_if_error: true 33 | -------------------------------------------------------------------------------- /.github/workflows/enforce-sha.yaml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: [main] 4 | pull_request: 5 | 6 | name: Security 7 | 8 | jobs: 9 | ensure-pinned-actions: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 14 | - name: Ensure SHA pinned actions 15 | uses: zgosalvez/github-actions-ensure-sha-pinned-actions@25ed13d0628a1601b4b44048e63cc4328ed03633 # v3 16 | with: 17 | allowlist: | 18 | prefix-dev/ 19 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Build 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | strategy: 13 | matrix: 14 | target: 15 | - x86_64-unknown-linux-gnu 16 | - x86_64-pc-windows-gnu 17 | - x86_64-apple-darwin 18 | - aarch64-apple-darwin 19 | 20 | steps: 21 | - name: Checkout code 22 | uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3 23 | 24 | - name: Install Rust 25 | uses: actions-rs/toolchain@16499b5e05bf2e26879000db0c1d13f7e13fa3af # v1 26 | with: 27 | toolchain: stable 28 | target: ${{ matrix.target }} 29 | override: true 30 | 31 | - name: Install Dependencies 32 | run: rustup target add ${{ matrix.target }} 33 | 34 | - name: Build 35 | run: cargo build --release --target ${{ matrix.target }} 36 | 37 | - name: Upload Artifact 38 | uses: actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v3 39 | with: 40 | name: shell-${{ matrix.target }} 41 | path: target/${{ matrix.target }}/release/shell 42 | 43 | release: 44 | needs: build 45 | runs-on: ubuntu-latest 46 | 47 | steps: 48 | - name: Checkout code 49 | uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3 50 | 51 | - name: Upload to Release 52 | uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v1 53 | with: 54 | files: | 55 | **/shell-x86_64-unknown-linux-gnu 56 | **/shell-x86_64-pc-windows-gnu.exe 57 | **/shell-x86_64-apple-darwin 58 | **/shell-aarch64-apple-darwin 59 | env: 60 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 61 | -------------------------------------------------------------------------------- /.github/workflows/rust-linting.yml: -------------------------------------------------------------------------------- 1 | name: Rust Linting 2 | 3 | concurrency: 4 | group: ${{ github.workflow }}-${{ github.ref }} 5 | cancel-in-progress: true 6 | 7 | on: 8 | push: 9 | branches: 10 | - main 11 | pull_request: 12 | branches: 13 | - main 14 | 15 | jobs: 16 | fmt: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Checkout code 20 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 21 | 22 | - name: Set up Rust 23 | uses: actions-rs/toolchain@16499b5e05bf2e26879000db0c1d13f7e13fa3af # v1 24 | with: 25 | toolchain: stable 26 | profile: minimal 27 | override: true 28 | 29 | - name: Run cargo fmt 30 | run: cargo fmt -- --check 31 | 32 | clippy: 33 | runs-on: ubuntu-latest 34 | steps: 35 | - name: Checkout code 36 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 37 | 38 | - name: Set up Rust 39 | uses: actions-rs/toolchain@16499b5e05bf2e26879000db0c1d13f7e13fa3af # v1 40 | with: 41 | toolchain: stable 42 | profile: minimal 43 | override: true 44 | 45 | - uses: Swatinem/rust-cache@f0deed1e0edfc6a9be95417288c0e1099b1eeec3 # v2 46 | 47 | - name: Run cargo clippy 48 | run: cargo clippy --all-targets --workspace -- -D warnings 49 | -------------------------------------------------------------------------------- /.github/workflows/rust-tests.yml: -------------------------------------------------------------------------------- 1 | name: Rust 2 | 3 | concurrency: 4 | group: ${{ github.workflow }}-${{ github.ref }} 5 | cancel-in-progress: true 6 | 7 | on: 8 | push: 9 | branches: 10 | - main 11 | pull_request: 12 | branches: 13 | - main 14 | 15 | jobs: 16 | tests: 17 | strategy: 18 | matrix: 19 | os: [ubuntu-latest, windows-latest, macos-latest] 20 | runs-on: ${{ matrix.os }} 21 | steps: 22 | - name: Checkout code 23 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 24 | 25 | - name: Set up Rust 26 | uses: actions-rs/toolchain@16499b5e05bf2e26879000db0c1d13f7e13fa3af # v1 27 | with: 28 | toolchain: stable 29 | profile: minimal 30 | override: true 31 | 32 | - uses: Swatinem/rust-cache@f0deed1e0edfc6a9be95417288c0e1099b1eeec3 # v2 33 | 34 | - name: Run tests 35 | run: cargo test --workspace --all-targets 36 | -------------------------------------------------------------------------------- /.github/workflows/typos.yml: -------------------------------------------------------------------------------- 1 | name: Typos 2 | 3 | concurrency: 4 | group: ${{ github.workflow }}-${{ github.ref }} 5 | cancel-in-progress: true 6 | 7 | on: 8 | push: 9 | branches: 10 | - main 11 | pull_request: 12 | branches: 13 | - main 14 | jobs: 15 | typos: 16 | name: spell check 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Checkout Actions Repository 20 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 21 | - name: Check spelling 22 | uses: crate-ci/typos@b48ba0f02b2a623fe5852b679366636e783ada3d # master 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "lldb", 9 | "request": "launch", 10 | "name": "Debug unit tests in library 'deno_task_shell'", 11 | "cargo": { 12 | "args": [ 13 | "test", 14 | "--no-run", 15 | "--lib", 16 | "--package=deno_task_shell" 17 | ], 18 | "filter": { 19 | "name": "deno_task_shell", 20 | "kind": "lib" 21 | } 22 | }, 23 | "args": [], 24 | "cwd": "${workspaceFolder}" 25 | }, 26 | { 27 | "type": "lldb", 28 | "request": "launch", 29 | "name": "Debug unit tests in library 'shell'", 30 | "cargo": { 31 | "args": [ 32 | "test", 33 | "--no-run", 34 | "--lib", 35 | "--package=shell" 36 | ], 37 | "filter": { 38 | "name": "shell", 39 | "kind": "lib" 40 | } 41 | }, 42 | "args": [], 43 | "cwd": "${workspaceFolder}" 44 | }, 45 | { 46 | "type": "lldb", 47 | "request": "launch", 48 | "name": "Debug executable 'shell'", 49 | "cargo": { 50 | "args": [ 51 | "build", 52 | "--bin=shell", 53 | "--package=shell" 54 | ], 55 | "filter": { 56 | "name": "shell", 57 | "kind": "bin" 58 | } 59 | }, 60 | "args": [], 61 | "cwd": "${workspaceFolder}" 62 | }, 63 | { 64 | "type": "lldb", 65 | "request": "launch", 66 | "name": "Debug unit tests in executable 'shell'", 67 | "cargo": { 68 | "args": [ 69 | "test", 70 | "--no-run", 71 | "--bin=shell", 72 | "--package=shell" 73 | ], 74 | "filter": { 75 | "name": "shell", 76 | "kind": "bin" 77 | } 78 | }, 79 | "args": [], 80 | "cwd": "${workspaceFolder}" 81 | }, 82 | { 83 | "type": "lldb", 84 | "request": "launch", 85 | "name": "Debug unit tests in library 'tests'", 86 | "cargo": { 87 | "args": [ 88 | "test", 89 | "--no-run", 90 | "--lib", 91 | "--package=tests" 92 | ], 93 | "filter": { 94 | "name": "tests", 95 | "kind": "lib" 96 | } 97 | }, 98 | "args": [], 99 | "cwd": "${workspaceFolder}" 100 | } 101 | ] 102 | } -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["crates/*"] 3 | resolver = "2" 4 | 5 | [workspace.package] 6 | categories = ["development-tools"] 7 | homepage = "https://github.com/prefix-dev/shell" 8 | repository = "https://github.com/prefix-dev/shell" 9 | license = "BSD-3-Clause" 10 | edition = "2021" 11 | readme = "README.md" -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024 prefix.dev GmbH 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 14 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 15 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 16 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 17 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 18 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 19 | OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![image](https://github.com/user-attachments/assets/74ad3cdd-9890-4b41-b42f-7eaed269f505) 2 | 3 | # 🦀 shell - fast, cross-platform Bash compatible shell 🚀 4 | 5 | This shell looks and feels like bash, but works **natively on Windows** (and macOS / Linux)! No emulation needed. 6 | 7 | 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. 8 | 9 | The project is written in Rust. 10 | 11 | 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.). 12 | 13 | This new shell also already has _tab completion_ for files and directories, and _history_ support thanks to `rustyline`. 14 | 15 | The project is still very early alpha stage but can already be used as a daily 16 | driver on all platforms. 17 | 18 | ## Screenshots 19 | 20 | macOS: 21 | 22 | [](https://github.com/user-attachments/assets/7f5c72ed-2bce-4f64-8a53-792d153cf574) 23 | 24 | Windows: 25 | 26 | ![Windows](https://github.com/user-attachments/assets/6982534c-066e-4b26-a1ec-b11cea7a3ffb) 27 | 28 | ## How to run this 29 | 30 | To compile and run the project, you need to have Rust & Cargo installed. 31 | 32 | ```bash 33 | # To start an interactive shell 34 | cargo r 35 | 36 | # To run a script 37 | cargo r -- ./scripts/hello_world.sh 38 | 39 | # To run a script and continue in interactive mode 40 | cargo r -- ./scripts/hello_world.sh --interact 41 | ``` 42 | 43 | ## License 44 | 45 | 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`). 46 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: off 4 | patch: off 5 | comment: 6 | layout: "diff, files" 7 | -------------------------------------------------------------------------------- /crates/deno_task_shell/.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | /target 3 | -------------------------------------------------------------------------------- /crates/deno_task_shell/.rustfmt.toml: -------------------------------------------------------------------------------- 1 | max_width = 80 2 | edition = "2021" 3 | -------------------------------------------------------------------------------- /crates/deno_task_shell/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "deno_task_shell" 3 | version = "0.17.0" 4 | authors = ["the Deno authors"] 5 | documentation = "https://docs.rs/deno_task_shell" 6 | edition = "2021" 7 | homepage = "https://deno.land/" 8 | license = "MIT" 9 | repository = "https://github.com/denoland/deno_task_shell" 10 | description = "Cross platform scripting for deno task" 11 | 12 | [features] 13 | default = ["shell"] 14 | shell = ["futures", "glob", "os_pipe", "path-dedot", "tokio", "tokio-util"] 15 | serialization = ["serde"] 16 | 17 | [dependencies] 18 | futures = { version = "0.3.31", optional = true } 19 | glob = { version = "0.3.2", optional = true } 20 | path-dedot = { version = "3.1.1", optional = true } 21 | tokio = { version = "1", features = ["fs", "io-std", "io-util", "macros", "process", "rt-multi-thread", "sync", "time"], optional = true } 22 | tokio-util = { version = "0.7.13", optional = true } 23 | os_pipe = { version = "1.2.1", optional = true } 24 | serde = { version = "1", features = ["derive"], optional = true } 25 | thiserror = "2.0.11" 26 | pest = { version="2.7.15", features = ["miette-error"] } 27 | pest_derive = "2.7.15" 28 | dirs = "6.0.0" 29 | pest_ascii_tree = "0.1.0" 30 | miette = { version = "7.5.0", features = ["fancy"] } 31 | lazy_static = "1.5.0" 32 | 33 | [dev-dependencies] 34 | tempfile = "3.16.0" 35 | parking_lot = "0.12.3" 36 | serde_json = "1.0.138" 37 | pretty_assertions = "1.4.1" 38 | -------------------------------------------------------------------------------- /crates/deno_task_shell/LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright 2018-2024 the Deno authors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /crates/deno_task_shell/README.md: -------------------------------------------------------------------------------- 1 | # deno_task_shell 2 | 3 | [![](https://img.shields.io/crates/v/deno_task_shell.svg)](https://crates.io/crates/deno_task_shell) 4 | 5 | ```rs 6 | // parse 7 | let list = deno_task_shell::parser::parse(&text)?; 8 | 9 | // execute 10 | let env_vars = HashMap::from(&[ 11 | ("SOME_VAR".to_string(), "value".to_string()), 12 | ]); 13 | let cwd = std::env::current_dir()?; 14 | 15 | let exit_code = deno_task_shell::execute( 16 | list, 17 | env_vars, 18 | &cwd, 19 | Default::default(), // custom commands 20 | ).await; 21 | ``` 22 | -------------------------------------------------------------------------------- /crates/deno_task_shell/src/grammar.pest: -------------------------------------------------------------------------------- 1 | // grammar.pest 2 | 3 | // Whitespace and comments 4 | WHITESPACE = _{ " " | "\t" | ("\\" ~ WHITESPACE* ~ NEWLINE) } 5 | COMMENT = _{ "#" ~ (!NEWLINE ~ ANY)* } 6 | NUMBER = @{ INT ~ ("." ~ ASCII_DIGIT*)? ~ (^"e" ~ INT)? } 7 | INT = { ("+" | "-")? ~ ASCII_DIGIT+ } 8 | 9 | // Basic tokens 10 | QUOTED_WORD = { DOUBLE_QUOTED | SINGLE_QUOTED } 11 | 12 | UNQUOTED_PENDING_WORD = ${ 13 | (TILDE_PREFIX ~ (!(OPERATOR | WHITESPACE | NEWLINE) ~ ( 14 | EXIT_STATUS | 15 | UNQUOTED_ESCAPE_CHAR | 16 | "$" ~ ARITHMETIC_EXPRESSION | 17 | SUB_COMMAND | 18 | VARIABLE_EXPANSION | 19 | UNQUOTED_CHAR | 20 | QUOTED_WORD 21 | ))*) 22 | | 23 | (!(OPERATOR | WHITESPACE | NEWLINE) ~ ( 24 | EXIT_STATUS | 25 | UNQUOTED_ESCAPE_CHAR | 26 | "$" ~ ARITHMETIC_EXPRESSION | 27 | SUB_COMMAND | 28 | VARIABLE_EXPANSION | 29 | UNQUOTED_CHAR | 30 | QUOTED_WORD 31 | ))+ 32 | } 33 | 34 | QUOTED_PENDING_WORD = ${ ( 35 | EXIT_STATUS | 36 | QUOTED_ESCAPE_CHAR | 37 | "$" ~ ARITHMETIC_EXPRESSION | 38 | SUB_COMMAND | 39 | VARIABLE_EXPANSION | 40 | QUOTED_CHAR 41 | )* } 42 | 43 | PARAMETER_PENDING_WORD = ${ 44 | TILDE_PREFIX ~ ( !"}" ~ !":" ~ ( 45 | EXIT_STATUS | 46 | PARAMETER_ESCAPE_CHAR | 47 | "$" ~ ARITHMETIC_EXPRESSION | 48 | SUB_COMMAND | 49 | VARIABLE_EXPANSION | 50 | QUOTED_WORD | 51 | QUOTED_CHAR 52 | ))* | 53 | ( !"}" ~ !":" ~ ( 54 | EXIT_STATUS | 55 | PARAMETER_ESCAPE_CHAR | 56 | "$" ~ ARITHMETIC_EXPRESSION | 57 | SUB_COMMAND | 58 | VARIABLE_EXPANSION | 59 | QUOTED_WORD | 60 | QUOTED_CHAR 61 | ))+ 62 | } 63 | 64 | FILE_NAME_PENDING_WORD = ${ 65 | (TILDE_PREFIX ~ (!(WHITESPACE | OPERATOR | NEWLINE) ~ ( 66 | UNQUOTED_ESCAPE_CHAR | 67 | VARIABLE_EXPANSION | 68 | UNQUOTED_CHAR | 69 | QUOTED_WORD 70 | ))*) 71 | | 72 | (!(WHITESPACE | OPERATOR | NEWLINE) ~ ( 73 | UNQUOTED_ESCAPE_CHAR | 74 | VARIABLE_EXPANSION | 75 | UNQUOTED_CHAR | 76 | QUOTED_WORD 77 | ))+ 78 | } 79 | 80 | UNQUOTED_ESCAPE_CHAR = ${ ("\\" ~ "$" | "$" ~ !"(" ~ !"{" ~ !VARIABLE) | "\\" ~ (" " | "`" | "\"" | "(" | ")") } 81 | QUOTED_ESCAPE_CHAR = ${ "\\" ~ "$" | "$" ~ !"(" ~ !"{" ~ !(ASCII_DIGIT | VARIABLE) | "\\" ~ ("`" | "\"" | "(" | ")" | "'") } 82 | PARAMETER_ESCAPE_CHAR = ${ "\\" ~ "$" | "$" ~ !"(" ~ !"{" ~ !VARIABLE | "\\" ~ "}" } 83 | 84 | UNQUOTED_CHAR = ${ ("\\" ~ " ") | !("]]" | "[[" | "(" | ")" | "<" | ">" | "|" | "&" | ";" | "\"" | "'" | "$") ~ ANY } 85 | QUOTED_CHAR = ${ !"\"" ~ ANY } 86 | 87 | VARIABLE_EXPANSION = ${ 88 | "$" ~ ( 89 | "{" ~ VARIABLE ~ VARIABLE_MODIFIER? ~ "}" | 90 | SPECIAL_PARAM | 91 | VARIABLE 92 | ) 93 | } 94 | 95 | SPECIAL_PARAM = ${ ARGNUM | "@" | "#" | "?" | "$" | "*" } 96 | ARGNUM = ${ ASCII_NONZERO_DIGIT ~ ASCII_DIGIT* | "0" } 97 | VARIABLE = ${ (ASCII_ALPHA | "_") ~ (ASCII_ALPHANUMERIC | "_")* } 98 | 99 | VARIABLE_MODIFIER = _{ 100 | VAR_DEFAULT_VALUE | 101 | VAR_ASSIGN_DEFAULT | 102 | VAR_ALTERNATE_VALUE | 103 | VAR_SUBSTRING 104 | } 105 | 106 | VAR_DEFAULT_VALUE = !{ ":-" ~ PARAMETER_PENDING_WORD? } 107 | VAR_ASSIGN_DEFAULT = !{ ":=" ~ PARAMETER_PENDING_WORD } 108 | VAR_ALTERNATE_VALUE = !{ ":+" ~ PARAMETER_PENDING_WORD } 109 | VAR_SUBSTRING = !{ ":" ~ PARAMETER_PENDING_WORD ~ (":" ~ PARAMETER_PENDING_WORD)? } 110 | 111 | TILDE_PREFIX = ${ 112 | "~" ~ (!(OPERATOR | WHITESPACE | NEWLINE | "/") ~ ( 113 | (!("\"" | "'" | "$" | "\\" | "/") ~ ANY) 114 | ))* 115 | } 116 | 117 | ASSIGNMENT_TILDE_PREFIX = ${ 118 | "~" ~ (!(OPERATOR | WHITESPACE | NEWLINE | "/" | ":") ~ 119 | (!("\"" | "'" | "$" | "\\" | "/") ~ ANY) 120 | )* 121 | } 122 | 123 | SUB_COMMAND = { "$(" ~ !("(") ~ complete_command ~ ")" } 124 | 125 | DOUBLE_QUOTED = @{ "\"" ~ QUOTED_PENDING_WORD ~ "\"" } 126 | SINGLE_QUOTED = @{ "'" ~ (!"'" ~ ANY)* ~ "'" } 127 | 128 | NAME = ${ (ASCII_ALPHA | "_") ~ (ASCII_ALPHANUMERIC | "_")* } 129 | ASSIGNMENT_WORD = ${ NAME ~ "=" ~ ASSIGNMENT_VALUE? } 130 | ASSIGNMENT_VALUE = ${ 131 | ASSIGNMENT_TILDE_PREFIX ~ 132 | ((":" ~ ASSIGNMENT_TILDE_PREFIX) | (!":" ~ UNQUOTED_PENDING_WORD))* | 133 | UNQUOTED_PENDING_WORD 134 | } 135 | IO_NUMBER = @{ ASCII_DIGIT+ } 136 | 137 | // Special tokens 138 | AND_IF = { "&&" } 139 | OR_IF = { "||" } 140 | DSEMI = { ";;" } 141 | LESS = { "<" } 142 | GREAT = { ">" } 143 | DLESS = { "<<" } 144 | DGREAT = { ">>" } 145 | LESSAND = { "<&" } 146 | GREATAND = { ">&" } 147 | LESSGREAT = { "<>" } 148 | DLESSDASH = { "<<-" } 149 | CLOBBER = { ">|" } 150 | AMPERSAND = { "&" } 151 | EXIT_STATUS = ${ "$?" } 152 | 153 | // Operators 154 | OPERATOR = _{ 155 | AND_IF | OR_IF | DSEMI | DLESS | DGREAT | LESSAND | GREATAND | LESSGREAT | DLESSDASH | CLOBBER | 156 | "(" | ")" | "{" | "}" | ";" | "&" | "|" | "<" | ">" 157 | } 158 | 159 | // Reserved words 160 | If = _{ "if" } 161 | Then = _{ "then" } 162 | Else = { "else" } 163 | Elif = { "elif" } 164 | Fi = _{ "fi" } 165 | Do = _{ "do" } 166 | Done = _{ "done" } 167 | Case = _{ "case" } 168 | Esac = { "esac" } 169 | While = _{ "while" } 170 | Until = _{ "until" } 171 | For = _{ "for" } 172 | Lbrace = { "{" } 173 | Rbrace = { "}" } 174 | Bang = { "!" } 175 | In = _{ "in" } 176 | Stdout = ${ "|" ~ !"|" ~ !"&"} 177 | StdoutStderr = { "|&" } 178 | 179 | RESERVED_WORD = _{ 180 | (If | Then | Else | Elif | Fi | Done | Do | 181 | Case | Esac | While | Until | For | 182 | Lbrace | Rbrace | Bang | In | 183 | StdoutStderr | Stdout) ~ &(WHITESPACE | NEWLINE | EOI) 184 | } 185 | 186 | // Main grammar rules 187 | complete_command = { list? ~ (separator+ ~ list)* ~ separator? } 188 | list = !{ and_or ~ (separator_op ~ and_or)* ~ separator_op? } 189 | and_or = !{ (pipeline | ASSIGNMENT_WORD+) ~ ((AND_IF | OR_IF) ~ linebreak ~ and_or)? } 190 | pipeline = !{ Bang? ~ pipe_sequence } 191 | pipe_sequence = !{ command ~ ((StdoutStderr | Stdout) ~ linebreak ~ pipe_sequence)? } 192 | 193 | command = !{ 194 | compound_command ~ redirect_list? | 195 | simple_command | 196 | function_definition 197 | } 198 | 199 | compound_command = { 200 | brace_group | 201 | ARITHMETIC_EXPRESSION | 202 | subshell | 203 | for_clause | 204 | case_clause | 205 | if_clause | 206 | while_clause | 207 | until_clause 208 | } 209 | 210 | ARITHMETIC_EXPRESSION = !{ "((" ~ arithmetic_sequence ~ "))" } 211 | arithmetic_sequence = !{ arithmetic_expr ~ ("," ~ arithmetic_expr)* } 212 | arithmetic_expr = { variable_assignment | triple_conditional_expr | binary_arithmetic_expr | binary_conditional_expression | unary_arithmetic_expr | VARIABLE | parentheses_expr | NUMBER } 213 | parentheses_expr = { "(" ~ arithmetic_sequence ~ ")" } 214 | 215 | variable_assignment = !{ 216 | VARIABLE ~ assignment_operator ~ arithmetic_expr 217 | } 218 | 219 | triple_conditional_expr = !{ 220 | (variable_assignment | binary_arithmetic_expr | binary_conditional_expression | unary_arithmetic_expr | VARIABLE | parentheses_expr | NUMBER) ~ 221 | "?" ~ (variable_assignment | binary_arithmetic_expr | binary_conditional_expression | unary_arithmetic_expr | VARIABLE | parentheses_expr | NUMBER) ~ 222 | ":" ~ (variable_assignment | binary_arithmetic_expr | binary_conditional_expression | unary_arithmetic_expr | VARIABLE | parentheses_expr | NUMBER) 223 | } 224 | 225 | binary_arithmetic_expr = _{ 226 | (binary_conditional_expression | unary_arithmetic_expr | variable_assignment | VARIABLE | parentheses_expr | NUMBER) ~ 227 | (binary_arithmetic_op ~ 228 | (binary_conditional_expression | unary_arithmetic_expr | variable_assignment | VARIABLE | parentheses_expr | NUMBER) 229 | )+ 230 | } 231 | 232 | binary_arithmetic_op = _{ 233 | add | subtract | power | multiply | divide | modulo | left_shift | right_shift | 234 | bitwise_and | bitwise_xor | bitwise_or | logical_and | logical_or 235 | } 236 | 237 | add = { "+" } 238 | subtract = { "-" } 239 | multiply = { "*" } 240 | divide = { "/" } 241 | modulo = { "%" } 242 | power = { "**" } 243 | left_shift = { "<<" } 244 | right_shift = { ">>" } 245 | bitwise_and = { "&" } 246 | bitwise_xor = { "^" } 247 | bitwise_or = { "|" } 248 | logical_and = { "&&" } 249 | logical_or = { "||" } 250 | 251 | unary_arithmetic_expr = !{ 252 | (unary_arithmetic_op | post_arithmetic_op) ~ (parentheses_expr | VARIABLE | NUMBER) | 253 | (parentheses_expr | VARIABLE | NUMBER) ~ post_arithmetic_op 254 | } 255 | 256 | unary_arithmetic_op = _{ 257 | unary_plus | unary_minus | logical_not | bitwise_not 258 | } 259 | 260 | unary_plus = { "+" } 261 | unary_minus = { "-" } 262 | logical_not = { "!" } 263 | bitwise_not = { "~" } 264 | 265 | post_arithmetic_op = !{ 266 | increment | decrement 267 | } 268 | 269 | increment = { "++" } 270 | decrement = { "--" } 271 | 272 | assignment_operator = _{ 273 | assign | multiply_assign | divide_assign | modulo_assign | add_assign | subtract_assign | 274 | left_shift_assign | right_shift_assign | bitwise_and_assign | bitwise_xor_assign | bitwise_or_assign 275 | } 276 | 277 | assign = { "=" } 278 | multiply_assign = { "*=" } 279 | divide_assign = { "/=" } 280 | modulo_assign = { "%=" } 281 | add_assign = { "+=" } 282 | subtract_assign = { "-=" } 283 | left_shift_assign = { "<<=" } 284 | right_shift_assign = { ">>=" } 285 | bitwise_and_assign = { "&=" } 286 | bitwise_xor_assign = { "^=" } 287 | bitwise_or_assign = { "|=" } 288 | 289 | subshell = !{ "(" ~ compound_list ~ ")" } 290 | compound_list = !{ (newline_list? ~ term ~ separator?)+ } 291 | term = !{ and_or ~ (separator ~ and_or)* } 292 | 293 | for_clause = { 294 | For ~ name ~ linebreak ~ 295 | (In ~ (brace_group | wordlist)? ~ (";" | NEWLINE))? ~ 296 | do_group 297 | } 298 | 299 | case_clause = !{ 300 | Case ~ UNQUOTED_PENDING_WORD ~ linebreak ~ 301 | linebreak ~ In ~ linebreak ~ 302 | (case_list | case_list_ns)? ~ 303 | Esac 304 | } 305 | 306 | case_list = !{ 307 | case_item+ 308 | } 309 | 310 | case_list_ns = !{ 311 | case_item_ns+ 312 | } 313 | 314 | case_item = !{ 315 | "("? ~ pattern ~ ")" ~ (compound_list | linebreak) ~ DSEMI ~ linebreak 316 | } 317 | 318 | case_item_ns = !{ 319 | "("? ~ pattern ~ ")" ~ compound_list? ~ linebreak 320 | } 321 | 322 | pattern = !{ 323 | (UNQUOTED_PENDING_WORD) ~ ("|" ~ UNQUOTED_PENDING_WORD)* 324 | } 325 | 326 | if_clause = !{ 327 | If ~ conditional_expression ~ 328 | linebreak ~ Then ~ linebreak ~ complete_command ~ linebreak ~ 329 | else_part? ~ linebreak ~ Fi 330 | } 331 | 332 | else_part = !{ 333 | Elif ~ conditional_expression ~ linebreak ~ Then ~ complete_command ~ linebreak ~ else_part? | 334 | Else ~ linebreak ~ complete_command 335 | } 336 | 337 | conditional_expression = !{ 338 | ("[[" ~ (unary_conditional_expression | binary_conditional_expression | UNQUOTED_PENDING_WORD) ~ "]]" ~ ";"?) | 339 | ("[" ~ (unary_conditional_expression | binary_conditional_expression | UNQUOTED_PENDING_WORD) ~ "]" ~ ";"?) | 340 | ("test" ~ (unary_conditional_expression | binary_conditional_expression | UNQUOTED_PENDING_WORD) ~ ";"?) 341 | } 342 | 343 | unary_conditional_expression = !{ 344 | file_conditional_op ~ FILE_NAME_PENDING_WORD | 345 | variable_conditional_op ~ VARIABLE | 346 | string_conditional_op ~ UNQUOTED_PENDING_WORD 347 | } 348 | 349 | file_conditional_op = !{ 350 | "-a" | "-b" | "-c" | "-d" | "-e" | "-f" | "-g" | "-h" | "-k" | 351 | "-p" | "-r" | "-s" | "-u" | "-w" | "-x" | "-G" | "-L" | 352 | "-N" | "-O" | "-S" 353 | } 354 | 355 | variable_conditional_op = !{ 356 | "-v" | "-R" 357 | } 358 | 359 | string_conditional_op = !{ 360 | "-n" | "-z" 361 | } 362 | 363 | binary_conditional_expression = !{ 364 | UNQUOTED_PENDING_WORD ~ ( 365 | binary_bash_conditional_op | 366 | binary_posix_conditional_op 367 | ) ~ UNQUOTED_PENDING_WORD 368 | } 369 | 370 | binary_bash_conditional_op = !{ 371 | "==" | "=" | "!=" | "<" | ">" 372 | } 373 | 374 | binary_posix_conditional_op = !{ 375 | "-eq" | "-ne" | "-lt" | "-le" | "-gt" | "-ge" 376 | } 377 | 378 | while_clause = !{ While ~ conditional_expression ~ do_group } 379 | until_clause = !{ Until ~ conditional_expression ~ do_group } 380 | 381 | function_definition = !{ fname ~ "(" ~ ")" ~ linebreak ~ function_body } 382 | function_body = !{ compound_command ~ redirect_list? } 383 | 384 | fname = @{ RESERVED_WORD | NAME | ASSIGNMENT_WORD | UNQUOTED_PENDING_WORD } 385 | name = @{ NAME } 386 | 387 | brace_group = !{ Lbrace ~ compound_list ~ Rbrace } 388 | do_group = !{ Do ~ compound_list ~ Done } 389 | 390 | simple_command = !{ 391 | cmd_prefix ~ cmd_word ~ cmd_suffix? | 392 | (!cmd_prefix ~ cmd_name ~ cmd_suffix?) 393 | } 394 | 395 | cmd_prefix = !{ (io_redirect | ASSIGNMENT_WORD)+ } 396 | cmd_suffix = !{ (io_redirect | UNQUOTED_PENDING_WORD)+ } 397 | cmd_name = @{ !RESERVED_WORD ~ UNQUOTED_PENDING_WORD } 398 | cmd_word = @{ (ASSIGNMENT_WORD | UNQUOTED_PENDING_WORD) } 399 | 400 | redirect_list = !{ io_redirect+ } 401 | io_redirect = !{ (IO_NUMBER | AMPERSAND)? ~ (io_file | io_here) } 402 | io_file = !{ 403 | LESS ~ filename | 404 | GREAT ~ filename | 405 | DGREAT ~ filename | 406 | LESSAND ~ filename | 407 | GREATAND ~ filename | 408 | LESSGREAT ~ filename | 409 | CLOBBER ~ filename 410 | } 411 | filename = _{ FILE_NAME_PENDING_WORD } 412 | io_here = !{ (DLESS | DLESSDASH) ~ here_end } 413 | here_end = @{ ("\"" ~ UNQUOTED_PENDING_WORD ~ "\"") | UNQUOTED_PENDING_WORD } 414 | 415 | newline_list = _{ NEWLINE+ } 416 | linebreak = _{ NEWLINE* } 417 | separator_op = { "&" | ";" } 418 | separator = _{ separator_op ~ linebreak | newline_list } 419 | 420 | wordlist = !{ UNQUOTED_PENDING_WORD+ } 421 | 422 | // Entry point 423 | FILE = { SOI ~ complete_command ~ EOI } -------------------------------------------------------------------------------- /crates/deno_task_shell/src/lib.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2024 the Deno authors. MIT license. 2 | 3 | #![deny(clippy::print_stderr)] 4 | #![deny(clippy::print_stdout)] 5 | #![deny(clippy::unused_async)] 6 | 7 | pub mod parser; 8 | 9 | #[cfg(feature = "shell")] 10 | mod shell; 11 | 12 | #[cfg(feature = "shell")] 13 | pub use shell::*; 14 | -------------------------------------------------------------------------------- /crates/deno_task_shell/src/shell/command.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2024 the Deno authors. MIT license. 2 | 3 | use std::borrow::Cow; 4 | use std::io::BufRead; 5 | use std::io::BufReader; 6 | use std::io::Read; 7 | use std::path::Path; 8 | use std::path::PathBuf; 9 | 10 | use crate::parser::CommandInner; 11 | use crate::shell::types::ShellState; 12 | use crate::ExecutableCommand; 13 | use crate::ExecuteResult; 14 | use crate::FutureExecuteResult; 15 | use crate::ShellCommand; 16 | use crate::ShellCommandContext; 17 | use futures::FutureExt; 18 | use miette::{miette, Result}; 19 | use thiserror::Error; 20 | 21 | #[derive(Debug, Clone)] 22 | pub struct UnresolvedCommandName { 23 | pub name: String, 24 | pub base_dir: PathBuf, 25 | } 26 | 27 | pub fn execute_unresolved_command_name( 28 | command_name: UnresolvedCommandName, 29 | mut context: ShellCommandContext, 30 | ) -> FutureExecuteResult { 31 | async move { 32 | let args = context.args.clone(); 33 | let command = 34 | match resolve_command(&command_name, &mut context, &args).await { 35 | Ok(command_path) => command_path, 36 | Err(ResolveCommandError::CommandPath(err)) => { 37 | let _ = context.stderr.write_line(&format!("{}", err)); 38 | return ExecuteResult::Continue( 39 | err.exit_code(), 40 | Vec::new(), 41 | Vec::new(), 42 | ); 43 | } 44 | Err(ResolveCommandError::FailedShebang(err)) => { 45 | let _ = context 46 | .stderr 47 | .write_line(&format!("{}: {}", command_name.name, err)); 48 | return ExecuteResult::Continue( 49 | err.exit_code(), 50 | Vec::new(), 51 | Vec::new(), 52 | ); 53 | } 54 | }; 55 | match command.command_name { 56 | CommandName::Resolved(path) => { 57 | ExecutableCommand::new(command_name.name, path) 58 | .execute(context) 59 | .await 60 | } 61 | CommandName::Unresolved(command_name) => { 62 | context.args = command.args.into_owned(); 63 | execute_unresolved_command_name(command_name, context).await 64 | } 65 | } 66 | } 67 | .boxed_local() 68 | } 69 | 70 | enum CommandName { 71 | Resolved(PathBuf), 72 | Unresolved(UnresolvedCommandName), 73 | } 74 | 75 | struct ResolvedCommand<'a> { 76 | command_name: CommandName, 77 | args: Cow<'a, Vec>, 78 | } 79 | 80 | #[derive(Error, Debug)] 81 | enum ResolveCommandError { 82 | #[error(transparent)] 83 | CommandPath(#[from] ResolveCommandPathError), 84 | #[error(transparent)] 85 | FailedShebang(#[from] FailedShebangError), 86 | } 87 | 88 | #[derive(Error, Debug)] 89 | enum FailedShebangError { 90 | #[error(transparent)] 91 | CommandPath(#[from] ResolveCommandPathError), 92 | 93 | #[error("{0}")] 94 | MietteError(String), 95 | } 96 | 97 | impl From for FailedShebangError { 98 | fn from(err: miette::Error) -> Self { 99 | FailedShebangError::MietteError(err.to_string()) 100 | } 101 | } 102 | 103 | impl FailedShebangError { 104 | pub fn exit_code(&self) -> i32 { 105 | match self { 106 | FailedShebangError::CommandPath(err) => err.exit_code(), 107 | FailedShebangError::MietteError(_) => 1, 108 | } 109 | } 110 | } 111 | 112 | async fn resolve_command<'a>( 113 | command_name: &UnresolvedCommandName, 114 | context: &mut ShellCommandContext, 115 | original_args: &'a Vec, 116 | ) -> Result, ResolveCommandError> { 117 | let command_path = match resolve_command_path( 118 | &command_name.name, 119 | &command_name.base_dir, 120 | &context.state, 121 | ) { 122 | Ok(command_path) => command_path, 123 | Err(err) => return Err(err.into()), 124 | }; 125 | 126 | // only bother checking for a shebang when the path has a slash 127 | // in it because for global commands someone on Windows likely 128 | // won't have a script with a shebang in it on Windows 129 | if command_name.name.contains('/') { 130 | if let Some(shebang) = 131 | resolve_shebang(&command_path).map_err(|err| { 132 | ResolveCommandError::FailedShebang( 133 | FailedShebangError::MietteError(err.to_string()), 134 | ) 135 | })? 136 | { 137 | let (shebang_command_name, mut args) = if shebang.string_split { 138 | let mut args = parse_shebang_args(&shebang.command, context) 139 | .await 140 | .map_err(|e| { 141 | FailedShebangError::MietteError(e.to_string()) 142 | })?; 143 | args.push(command_path.to_string_lossy().to_string()); 144 | (args.remove(0), args) 145 | } else { 146 | ( 147 | shebang.command, 148 | vec![command_path.to_string_lossy().to_string()], 149 | ) 150 | }; 151 | args.extend(original_args.iter().cloned()); 152 | return Ok(ResolvedCommand { 153 | command_name: CommandName::Unresolved(UnresolvedCommandName { 154 | name: shebang_command_name, 155 | base_dir: command_path.parent().unwrap().to_path_buf(), 156 | }), 157 | args: Cow::Owned(args), 158 | }); 159 | } 160 | } 161 | 162 | Ok(ResolvedCommand { 163 | command_name: CommandName::Resolved(command_path), 164 | args: Cow::Borrowed(original_args), 165 | }) 166 | } 167 | 168 | async fn parse_shebang_args( 169 | text: &str, 170 | context: &mut ShellCommandContext, 171 | ) -> Result> { 172 | fn err_unsupported(text: &str) -> Result> { 173 | miette::bail!("unsupported shebang. Please report this as a bug (https://github.com/prefix.dev/shell).\n\nShebang: {}", text) 174 | } 175 | 176 | let mut args = crate::parser::parse(text)?; 177 | if args.items.len() != 1 { 178 | return err_unsupported(text); 179 | } 180 | let item = args.items.remove(0); 181 | if item.is_async { 182 | return err_unsupported(text); 183 | } 184 | let pipeline = match item.sequence { 185 | crate::parser::Sequence::Pipeline(pipeline) => pipeline, 186 | _ => return err_unsupported(text), 187 | }; 188 | if pipeline.negated { 189 | return err_unsupported(text); 190 | } 191 | let cmd = match pipeline.inner { 192 | crate::parser::PipelineInner::Command(cmd) => cmd, 193 | crate::parser::PipelineInner::PipeSequence(_) => { 194 | return err_unsupported(text) 195 | } 196 | }; 197 | if cmd.redirect.is_some() { 198 | return err_unsupported(text); 199 | } 200 | let cmd = match cmd.inner { 201 | CommandInner::Simple(cmd) => cmd, 202 | CommandInner::Subshell(_) => return err_unsupported(text), 203 | CommandInner::If(_) => return err_unsupported(text), 204 | CommandInner::For(_) => return err_unsupported(text), 205 | CommandInner::While(_) => return err_unsupported(text), 206 | CommandInner::ArithmeticExpression(_) => return err_unsupported(text), 207 | CommandInner::Case(_) => return err_unsupported(text), 208 | }; 209 | if !cmd.env_vars.is_empty() { 210 | return err_unsupported(text); 211 | } 212 | 213 | let result = super::execute::evaluate_args( 214 | cmd.args, 215 | &mut context.state, 216 | context.stdin.clone(), 217 | context.stderr.clone(), 218 | ) 219 | .await 220 | .map_err(|e| miette!(e.to_string()))?; 221 | Ok(result.value) 222 | } 223 | 224 | /// Errors for executable commands. 225 | #[derive(Error, Debug, PartialEq)] 226 | pub enum ResolveCommandPathError { 227 | #[error("{}: command not found", .0)] 228 | CommandNotFound(String), 229 | #[error("command name was empty")] 230 | CommandEmpty, 231 | } 232 | 233 | impl ResolveCommandPathError { 234 | pub fn exit_code(&self) -> i32 { 235 | match self { 236 | // Use the Exit status that is used in bash: https://www.gnu.org/software/bash/manual/bash.html#Exit-Status 237 | ResolveCommandPathError::CommandNotFound(_) => 127, 238 | ResolveCommandPathError::CommandEmpty => 1, 239 | } 240 | } 241 | } 242 | 243 | pub fn resolve_command_path( 244 | command_name: &str, 245 | base_dir: &Path, 246 | state: &ShellState, 247 | ) -> Result { 248 | resolve_command_path_inner(command_name, base_dir, state, || { 249 | std::env::current_exe().map_err(|e| miette!(e.to_string())) 250 | }) 251 | } 252 | 253 | fn resolve_command_path_inner( 254 | command_name: &str, 255 | base_dir: &Path, 256 | state: &ShellState, 257 | current_exe: impl FnOnce() -> Result, 258 | ) -> Result { 259 | if command_name.is_empty() { 260 | return Err(ResolveCommandPathError::CommandEmpty); 261 | } 262 | 263 | // Special handling to use the current executable for deno. 264 | // This is to ensure deno tasks that use deno work in environments 265 | // that don't have deno on the path and to ensure it use the current 266 | // version of deno being executed rather than the one on the path, 267 | // which has caused some confusion. 268 | if command_name == "deno" { 269 | if let Ok(exe_path) = current_exe() { 270 | // this condition exists to make the tests pass because it's not 271 | // using the deno as the current executable 272 | let file_stem = exe_path.file_stem().map(|s| s.to_string_lossy()); 273 | if file_stem.map(|s| s.to_string()) == Some("deno".to_string()) { 274 | return Ok(exe_path); 275 | } 276 | } 277 | } 278 | 279 | // check for absolute 280 | if PathBuf::from(command_name).is_absolute() { 281 | return Ok(PathBuf::from(command_name)); 282 | } 283 | 284 | // then relative 285 | if command_name.contains('/') 286 | || (cfg!(windows) && command_name.contains('\\')) 287 | { 288 | return Ok(base_dir.join(command_name)); 289 | } 290 | 291 | // now search based on the current environment state 292 | let mut search_dirs = vec![base_dir.to_path_buf()]; 293 | if let Some(path) = state.get_var("PATH") { 294 | for folder in path.split(if cfg!(windows) { ';' } else { ':' }) { 295 | search_dirs.push(PathBuf::from(folder)); 296 | } 297 | } 298 | let path_exts = if cfg!(windows) { 299 | let uc_command_name = command_name.to_uppercase(); 300 | let path_ext = state 301 | .get_var("PATHEXT") 302 | .map(|s| s.as_str()) 303 | .unwrap_or(".EXE;.CMD;.BAT;.COM"); 304 | let command_exts = path_ext 305 | .split(';') 306 | .map(|s| s.trim().to_uppercase()) 307 | .filter(|s| !s.is_empty()) 308 | .collect::>(); 309 | if command_exts.is_empty() 310 | || command_exts 311 | .iter() 312 | .any(|ext| uc_command_name.ends_with(ext)) 313 | { 314 | None // use the command name as-is 315 | } else { 316 | Some(command_exts) 317 | } 318 | } else { 319 | None 320 | }; 321 | 322 | for search_dir in search_dirs { 323 | let paths = if let Some(path_exts) = &path_exts { 324 | let mut paths = Vec::new(); 325 | for path_ext in path_exts { 326 | paths.push(search_dir.join(format!("{command_name}{path_ext}"))) 327 | } 328 | paths 329 | } else { 330 | vec![search_dir.join(command_name)] 331 | }; 332 | for path in paths { 333 | // don't use tokio::fs::metadata here as it was never returning 334 | // in some circumstances for some reason 335 | if let Ok(metadata) = std::fs::metadata(&path) { 336 | if metadata.is_file() { 337 | return Ok(path); 338 | } 339 | } 340 | } 341 | } 342 | Err(ResolveCommandPathError::CommandNotFound( 343 | command_name.to_string(), 344 | )) 345 | } 346 | 347 | struct Shebang { 348 | string_split: bool, 349 | command: String, 350 | } 351 | 352 | fn resolve_shebang( 353 | file_path: &Path, 354 | ) -> Result, std::io::Error> { 355 | let mut file = match std::fs::File::open(file_path) { 356 | Ok(file) => file, 357 | Err(err) if err.kind() == std::io::ErrorKind::NotFound => { 358 | return Ok(None); 359 | } 360 | Err(err) => return Err(err), 361 | }; 362 | let text = b"#!/usr/bin/env "; 363 | let mut buffer = vec![0; text.len()]; 364 | match file.read_exact(&mut buffer) { 365 | Ok(_) if buffer == text => (), 366 | _ => return Ok(None), 367 | } 368 | 369 | let mut reader = BufReader::new(file); 370 | let mut line = String::new(); 371 | reader.read_line(&mut line)?; 372 | if line.is_empty() { 373 | return Ok(None); 374 | } 375 | let line = line.trim(); 376 | 377 | Ok(Some(if let Some(command) = line.strip_prefix("-S ") { 378 | Shebang { 379 | string_split: true, 380 | command: command.to_string(), 381 | } 382 | } else { 383 | Shebang { 384 | string_split: false, 385 | command: line.to_string(), 386 | } 387 | })) 388 | } 389 | 390 | #[cfg(test)] 391 | mod local_test { 392 | use super::*; 393 | 394 | #[test] 395 | fn should_resolve_current_exe_path_for_deno() { 396 | let cwd = std::env::current_dir().unwrap(); 397 | let state = ShellState::new( 398 | Default::default(), 399 | &std::env::current_dir().unwrap(), 400 | Default::default(), 401 | ); 402 | let path = resolve_command_path_inner("deno", &cwd, &state, || { 403 | Ok(PathBuf::from("/bin/deno")) 404 | }) 405 | .unwrap(); 406 | assert_eq!(path, PathBuf::from("/bin/deno")); 407 | 408 | let path = resolve_command_path_inner("deno", &cwd, &state, || { 409 | Ok(PathBuf::from("/bin/deno.exe")) 410 | }) 411 | .unwrap(); 412 | assert_eq!(path, PathBuf::from("/bin/deno.exe")); 413 | } 414 | 415 | #[test] 416 | fn should_error_on_unknown_command() { 417 | let cwd = std::env::current_dir().unwrap(); 418 | let state = 419 | ShellState::new(Default::default(), &cwd, Default::default()); 420 | // Command not found 421 | let result = resolve_command_path_inner("foobar", &cwd, &state, || { 422 | Ok(PathBuf::from("/bin/deno")) 423 | }); 424 | assert_eq!( 425 | result, 426 | Err(ResolveCommandPathError::CommandNotFound( 427 | "foobar".to_string() 428 | )) 429 | ); 430 | // Command empty 431 | let result = resolve_command_path_inner("", &cwd, &state, || { 432 | Ok(PathBuf::from("/bin/deno")) 433 | }); 434 | assert_eq!(result, Err(ResolveCommandPathError::CommandEmpty)); 435 | } 436 | } 437 | -------------------------------------------------------------------------------- /crates/deno_task_shell/src/shell/commands/args.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2024 the Deno authors. MIT license. 2 | 3 | use miette::bail; 4 | use miette::Result; 5 | 6 | #[derive(Debug, PartialEq, Eq)] 7 | pub enum ArgKind<'a> { 8 | PlusFlag(char), 9 | ShortFlag(char), 10 | LongFlag(&'a str), 11 | Arg(&'a str), 12 | } 13 | 14 | impl ArgKind<'_> { 15 | pub fn bail_unsupported(&self) -> Result<()> { 16 | match self { 17 | ArgKind::Arg(arg) => { 18 | bail!("unsupported argument: {}", arg) 19 | } 20 | ArgKind::LongFlag(name) => { 21 | bail!("unsupported flag: --{}", name) 22 | } 23 | ArgKind::ShortFlag(name) => { 24 | bail!("unsupported flag: -{}", name) 25 | } 26 | ArgKind::PlusFlag(name) => { 27 | bail!("unsupported flag: +{}", name) 28 | } 29 | } 30 | } 31 | } 32 | 33 | pub fn parse_arg_kinds(flags: &[String]) -> Vec { 34 | let mut result = Vec::new(); 35 | let mut had_dash_dash = false; 36 | for arg in flags { 37 | if had_dash_dash { 38 | result.push(ArgKind::Arg(arg)); 39 | } else if arg == "-" { 40 | result.push(ArgKind::Arg("-")); 41 | } else if arg == "--" { 42 | had_dash_dash = true; 43 | } else if let Some(flag) = arg.strip_prefix("--") { 44 | result.push(ArgKind::LongFlag(flag)); 45 | } else if let Some(flags) = arg.strip_prefix('-') { 46 | if flags.parse::().is_ok() { 47 | result.push(ArgKind::Arg(arg)); 48 | } else { 49 | for c in flags.chars() { 50 | result.push(ArgKind::ShortFlag(c)); 51 | } 52 | } 53 | } else if let Some(flags) = arg.strip_prefix('+') { 54 | if flags.parse::().is_ok() { 55 | result.push(ArgKind::Arg(arg)); 56 | } else { 57 | for c in flags.chars() { 58 | result.push(ArgKind::PlusFlag(c)); 59 | } 60 | } 61 | } else { 62 | result.push(ArgKind::Arg(arg)); 63 | } 64 | } 65 | result 66 | } 67 | 68 | #[cfg(test)] 69 | mod test { 70 | use super::*; 71 | use pretty_assertions::assert_eq; 72 | 73 | #[test] 74 | fn parses() { 75 | let data = vec![ 76 | "-f".to_string(), 77 | "-ab".to_string(), 78 | "--force".to_string(), 79 | "testing".to_string(), 80 | "other".to_string(), 81 | "-1".to_string(), 82 | "-6.4".to_string(), 83 | "--".to_string(), 84 | "--test".to_string(), 85 | "-t".to_string(), 86 | ]; 87 | let args = parse_arg_kinds(&data); 88 | assert_eq!( 89 | args, 90 | vec![ 91 | ArgKind::ShortFlag('f'), 92 | ArgKind::ShortFlag('a'), 93 | ArgKind::ShortFlag('b'), 94 | ArgKind::LongFlag("force"), 95 | ArgKind::Arg("testing"), 96 | ArgKind::Arg("other"), 97 | ArgKind::Arg("-1"), 98 | ArgKind::Arg("-6.4"), 99 | ArgKind::Arg("--test"), 100 | ArgKind::Arg("-t"), 101 | ] 102 | ) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /crates/deno_task_shell/src/shell/commands/cat.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2024 the Deno authors. MIT license. 2 | 3 | use futures::future::LocalBoxFuture; 4 | use futures::FutureExt as _; 5 | use miette::IntoDiagnostic; 6 | use miette::Result; 7 | use std::io::IsTerminal; 8 | use std::path::Path; 9 | use tokio::fs::File; 10 | use tokio::io::AsyncReadExt as _; 11 | 12 | use crate::shell::commands::execute_with_cancellation; 13 | use crate::shell::types::ExecuteResult; 14 | use crate::ShellPipeReader; 15 | use crate::ShellPipeWriter; 16 | 17 | use super::args::parse_arg_kinds; 18 | use super::args::ArgKind; 19 | use super::ShellCommand; 20 | use super::ShellCommandContext; 21 | 22 | pub struct CatCommand; 23 | 24 | impl ShellCommand for CatCommand { 25 | fn execute( 26 | &self, 27 | context: ShellCommandContext, 28 | ) -> LocalBoxFuture<'static, ExecuteResult> { 29 | async move { 30 | execute_with_cancellation!( 31 | cat_command( 32 | context.state.cwd(), 33 | context.args, 34 | context.stdin, 35 | context.stdout, 36 | context.stderr 37 | ), 38 | context.state.token() 39 | ) 40 | } 41 | .boxed_local() 42 | } 43 | } 44 | 45 | async fn cat_command( 46 | cwd: &Path, 47 | args: Vec, 48 | stdin: ShellPipeReader, 49 | mut stdout: ShellPipeWriter, 50 | mut stderr: ShellPipeWriter, 51 | ) -> ExecuteResult { 52 | match execute_cat(cwd, args, stdin, &mut stdout).await { 53 | Ok(()) => ExecuteResult::Continue(0, Vec::new(), Vec::new()), 54 | Err(err) => { 55 | let _ = stderr.write_line(&format!("cat: {err}")); 56 | ExecuteResult::Continue(1, Vec::new(), Vec::new()) 57 | } 58 | } 59 | } 60 | 61 | async fn execute_cat( 62 | cwd: &Path, 63 | args: Vec, 64 | stdin: ShellPipeReader, 65 | stdout: &mut ShellPipeWriter, 66 | ) -> Result<()> { 67 | let flags = parse_args(args)?; 68 | let mut buf = vec![0; 1024]; 69 | 70 | for path in flags.paths { 71 | if path == "-" { 72 | stdin.clone().pipe_to_sender(stdout.clone())?; 73 | } else { 74 | match File::open(cwd.join(&path)).await { 75 | Ok(mut file) => { 76 | let mut new_line = true; 77 | loop { 78 | let size = 79 | file.read(&mut buf).await.into_diagnostic()?; 80 | if size == 0 { 81 | if let ShellPipeWriter::Stdout = stdout { 82 | if !new_line && std::io::stdout().is_terminal() 83 | { 84 | stdout.write_all(b"%\n")?; 85 | } 86 | } 87 | break; 88 | } 89 | stdout.write_all(&buf[..size])?; 90 | new_line = buf[size - 1] == b'\n'; 91 | } 92 | } 93 | Err(err) => { 94 | miette::bail!(format!("{path}: {err}")); 95 | } 96 | } 97 | } 98 | } 99 | 100 | Ok(()) 101 | } 102 | 103 | #[derive(Debug, PartialEq)] 104 | struct CatFlags { 105 | paths: Vec, 106 | } 107 | 108 | fn parse_args(args: Vec) -> Result { 109 | let mut paths = Vec::new(); 110 | for arg in parse_arg_kinds(&args) { 111 | match arg { 112 | ArgKind::Arg(file_name) => { 113 | paths.push(file_name.to_string()); 114 | } 115 | // for now, we don't support any arguments 116 | _ => arg.bail_unsupported()?, 117 | } 118 | } 119 | 120 | if paths.is_empty() { 121 | paths.push("-".to_string()); 122 | } 123 | 124 | Ok(CatFlags { paths }) 125 | } 126 | 127 | #[cfg(test)] 128 | mod test { 129 | use super::*; 130 | use pretty_assertions::assert_eq; 131 | 132 | #[test] 133 | fn parses_args() { 134 | assert_eq!( 135 | parse_args(vec![]).unwrap(), 136 | CatFlags { 137 | paths: vec!["-".to_string()] 138 | } 139 | ); 140 | assert_eq!( 141 | parse_args(vec!["path".to_string()]).unwrap(), 142 | CatFlags { 143 | paths: vec!["path".to_string()] 144 | } 145 | ); 146 | assert_eq!( 147 | parse_args(vec!["path".to_string(), "-".to_string()]).unwrap(), 148 | CatFlags { 149 | paths: vec!["path".to_string(), "-".to_string()] 150 | } 151 | ); 152 | assert_eq!( 153 | parse_args(vec!["path".to_string(), "other-path".to_string()]) 154 | .unwrap(), 155 | CatFlags { 156 | paths: vec!["path".to_string(), "other-path".to_string()] 157 | } 158 | ); 159 | assert_eq!( 160 | parse_args(vec!["--flag".to_string()]) 161 | .err() 162 | .unwrap() 163 | .to_string(), 164 | "unsupported flag: --flag" 165 | ); 166 | assert_eq!( 167 | parse_args(vec!["-t".to_string()]) 168 | .err() 169 | .unwrap() 170 | .to_string(), 171 | "unsupported flag: -t" 172 | ); 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /crates/deno_task_shell/src/shell/commands/cd.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2024 the Deno authors. MIT license. 2 | 3 | use std::path::Path; 4 | use std::path::PathBuf; 5 | 6 | use futures::future::LocalBoxFuture; 7 | use miette::bail; 8 | use miette::Result; 9 | use path_dedot::ParseDot; 10 | 11 | use crate::shell::fs_util; 12 | use crate::shell::types::EnvChange; 13 | use crate::shell::types::ExecuteResult; 14 | 15 | use super::ShellCommand; 16 | use super::ShellCommandContext; 17 | 18 | pub struct CdCommand; 19 | 20 | fn resolve_directory( 21 | dir: &str, 22 | cwd: &Path, 23 | prev_cwd: Option<&PathBuf>, 24 | ) -> Result { 25 | match dir { 26 | "-" => Ok(prev_cwd 27 | .ok_or_else(|| miette::miette!("No previous directory"))? 28 | .to_path_buf()), 29 | "~" => dirs::home_dir() 30 | .ok_or_else(|| miette::miette!("Home directory not found")), 31 | _ if dir.starts_with("~/") => { 32 | let home = dirs::home_dir() 33 | .ok_or_else(|| miette::miette!("Home directory not found"))?; 34 | Ok(home.join(&dir[2..])) 35 | } 36 | _ => Ok(cwd.join(dir)), 37 | } 38 | } 39 | 40 | fn execute_cd( 41 | cwd: &Path, 42 | prev_cwd: Option<&PathBuf>, 43 | args: Vec, 44 | ) -> Result { 45 | let path = if args.is_empty() { 46 | "~".to_string() 47 | } else if args.len() > 1 { 48 | bail!("too many arguments") 49 | } else { 50 | args[0].clone() 51 | }; 52 | 53 | let new_dir = resolve_directory(&path, cwd, prev_cwd)?; 54 | let new_dir = new_dir 55 | .parse_dot() 56 | .map(|p| p.to_path_buf()) 57 | .unwrap_or_else(|_| fs_util::canonicalize_path(&new_dir).unwrap()); 58 | 59 | if !new_dir.is_dir() { 60 | bail!("{}: Not a directory", path) 61 | } 62 | Ok(new_dir) 63 | } 64 | 65 | impl ShellCommand for CdCommand { 66 | fn execute( 67 | &self, 68 | mut context: ShellCommandContext, 69 | ) -> LocalBoxFuture<'static, ExecuteResult> { 70 | Box::pin(async move { 71 | match execute_cd( 72 | context.state.cwd(), 73 | context.state.previous_cwd(), 74 | context.args, 75 | ) { 76 | Ok(new_dir) => ExecuteResult::Continue( 77 | 0, 78 | vec![EnvChange::Cd(new_dir)], 79 | Vec::new(), 80 | ), 81 | Err(err) => { 82 | let _ = context.stderr.write_line(&format!("cd: {err}")); 83 | ExecuteResult::Continue(1, Vec::new(), Vec::new()) 84 | } 85 | } 86 | }) 87 | } 88 | } 89 | 90 | #[cfg(test)] 91 | mod test { 92 | use super::*; 93 | use std::fs; 94 | use tempfile::tempdir; 95 | 96 | #[test] 97 | fn test_cd_previous() { 98 | let dir = tempdir().unwrap(); 99 | let dir_path = fs_util::canonicalize_path(dir.path()).unwrap(); 100 | let sub_dir = dir_path.join("sub"); 101 | std::fs::create_dir(&sub_dir).unwrap(); 102 | 103 | // Test cd - 104 | let sub_dir = Some(sub_dir); 105 | let result = 106 | execute_cd(&dir_path, sub_dir.as_ref(), vec!["-".to_string()]) 107 | .unwrap(); 108 | assert_eq!(Some(result), sub_dir); 109 | } 110 | 111 | #[test] 112 | fn test_directory_navigation() { 113 | let dir = tempdir().unwrap(); 114 | let dir_path = fs_util::canonicalize_path(dir.path()).unwrap(); 115 | let prev_dir = dir_path.join("prev"); 116 | fs::create_dir(&prev_dir).unwrap(); 117 | let prev_dir = Some(prev_dir); 118 | // Test basic navigation 119 | let result = execute_cd(&dir_path, prev_dir.as_ref(), vec![]).unwrap(); 120 | assert_eq!(result, dirs::home_dir().unwrap()); 121 | 122 | // Test cd - 123 | let result = 124 | execute_cd(&dir_path, prev_dir.as_ref(), vec!["-".to_string()]) 125 | .unwrap(); 126 | assert_eq!(Some(result), prev_dir); 127 | 128 | // Test home expansion 129 | let result = 130 | execute_cd(&dir_path, prev_dir.as_ref(), vec!["~".to_string()]) 131 | .unwrap(); 132 | assert_eq!(result, dirs::home_dir().unwrap()); 133 | 134 | // Test non-existent directory 135 | let err = execute_cd( 136 | &dir_path, 137 | prev_dir.as_ref(), 138 | vec!["non-existent".to_string()], 139 | ) 140 | .unwrap_err(); 141 | assert!(err.to_string().contains("Not a directory")); 142 | 143 | // Test file instead of directory 144 | fs::write(dir_path.join("file.txt"), "").unwrap(); 145 | let err = execute_cd( 146 | &dir_path, 147 | prev_dir.as_ref(), 148 | vec!["file.txt".to_string()], 149 | ) 150 | .unwrap_err(); 151 | assert!(err.to_string().contains("Not a directory")); 152 | 153 | // Test too many arguments 154 | let err = execute_cd( 155 | &dir_path, 156 | prev_dir.as_ref(), 157 | vec!["a".to_string(), "b".to_string()], 158 | ) 159 | .unwrap_err(); 160 | assert!(err.to_string().contains("too many arguments")); 161 | } 162 | 163 | #[test] 164 | fn test_path_resolution() { 165 | let dir = tempdir().unwrap(); 166 | let dir_path = fs_util::canonicalize_path(dir.path()).unwrap(); 167 | let prev_dir = Some(dir_path.clone()); 168 | // Test nested directory 169 | fs::create_dir_all(dir_path.join("a/b/c")).unwrap(); 170 | let result = 171 | execute_cd(&dir_path, prev_dir.as_ref(), vec!["a/b/c".to_string()]) 172 | .unwrap(); 173 | assert_eq!(result, dir_path.join("a/b/c")); 174 | 175 | // Test dot navigation 176 | let result = execute_cd( 177 | &dir_path.join("a/b/c"), 178 | prev_dir.as_ref(), 179 | vec!["../..".to_string()], 180 | ) 181 | .unwrap(); 182 | assert_eq!(result, dir_path.join("a")); 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /crates/deno_task_shell/src/shell/commands/echo.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2024 the Deno authors. MIT license. 2 | 3 | use futures::future::LocalBoxFuture; 4 | 5 | use crate::shell::types::ExecuteResult; 6 | 7 | use super::ShellCommand; 8 | use super::ShellCommandContext; 9 | 10 | pub struct EchoCommand; 11 | 12 | impl ShellCommand for EchoCommand { 13 | fn execute( 14 | &self, 15 | mut context: ShellCommandContext, 16 | ) -> LocalBoxFuture<'static, ExecuteResult> { 17 | let _ = context.stdout.write_line(&context.args.join(" ")); 18 | Box::pin(futures::future::ready(ExecuteResult::from_exit_code(0))) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /crates/deno_task_shell/src/shell/commands/executable.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2024 the Deno authors. MIT license. 2 | 3 | use std::path::PathBuf; 4 | 5 | use crate::ExecuteResult; 6 | use crate::FutureExecuteResult; 7 | use crate::ShellCommand; 8 | use crate::ShellCommandContext; 9 | use futures::FutureExt; 10 | 11 | /// Command that resolves the command name and 12 | /// executes it in a separate process. 13 | pub struct ExecutableCommand { 14 | display_name: String, 15 | command_path: PathBuf, 16 | } 17 | 18 | impl ExecutableCommand { 19 | pub fn new(display_name: String, command_path: PathBuf) -> Self { 20 | Self { 21 | display_name, 22 | command_path, 23 | } 24 | } 25 | } 26 | 27 | impl ShellCommand for ExecutableCommand { 28 | fn execute(&self, context: ShellCommandContext) -> FutureExecuteResult { 29 | let display_name = self.display_name.clone(); 30 | let command_name = self.command_path.clone(); 31 | async move { 32 | let mut stderr = context.stderr; 33 | let mut sub_command = tokio::process::Command::new(&command_name); 34 | let child = sub_command 35 | .current_dir(context.state.cwd()) 36 | .args(context.args) 37 | .env_clear() 38 | .envs(context.state.env_vars()) 39 | .stdout(context.stdout.into_stdio()) 40 | .stdin(context.stdin.into_stdio()) 41 | .stderr(stderr.clone().into_stdio()) 42 | .spawn(); 43 | 44 | let mut child = match child { 45 | Ok(child) => child, 46 | Err(err) => { 47 | let _ = stderr.write_line(&format!( 48 | "Error launching '{}': {}", 49 | display_name, err 50 | )); 51 | return ExecuteResult::Continue(1, Vec::new(), Vec::new()); 52 | } 53 | }; 54 | 55 | // avoid deadlock since this is holding onto the pipes 56 | drop(sub_command); 57 | 58 | tokio::select! { 59 | result = child.wait() => match result { 60 | Ok(status) => ExecuteResult::Continue( 61 | status.code().unwrap_or(1), 62 | Vec::new(), 63 | Vec::new(), 64 | ), 65 | Err(err) => { 66 | let _ = stderr.write_line(&format!("{}", err)); 67 | ExecuteResult::Continue(1, Vec::new(), Vec::new()) 68 | } 69 | }, 70 | _ = context.state.token().cancelled() => { 71 | let _ = child.kill().await; 72 | ExecuteResult::for_cancellation() 73 | } 74 | } 75 | } 76 | .boxed_local() 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /crates/deno_task_shell/src/shell/commands/exit.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2024 the Deno authors. MIT license. 2 | 3 | use futures::future::LocalBoxFuture; 4 | use miette::bail; 5 | use miette::Result; 6 | 7 | use crate::shell::types::ExecuteResult; 8 | 9 | use super::args::parse_arg_kinds; 10 | use super::args::ArgKind; 11 | use super::ShellCommand; 12 | use super::ShellCommandContext; 13 | 14 | pub struct ExitCommand; 15 | 16 | impl ShellCommand for ExitCommand { 17 | fn execute( 18 | &self, 19 | mut context: ShellCommandContext, 20 | ) -> LocalBoxFuture<'static, ExecuteResult> { 21 | let result = match execute_exit(context.args) { 22 | Ok(code) => ExecuteResult::Exit(code, Vec::new(), Vec::new()), 23 | Err(err) => { 24 | context.stderr.write_line(&format!("exit: {err}")).unwrap(); 25 | ExecuteResult::Exit(2, Vec::new(), Vec::new()) 26 | } 27 | }; 28 | Box::pin(futures::future::ready(result)) 29 | } 30 | } 31 | 32 | fn execute_exit(args: Vec) -> Result { 33 | let exit_code = parse_args(args)?; 34 | 35 | Ok(if exit_code < 0 { 36 | let code = -exit_code % 256; 37 | 256 - code 38 | } else { 39 | exit_code % 256 40 | }) 41 | } 42 | 43 | fn parse_args(args: Vec) -> Result { 44 | let args = parse_arg_kinds(&args); 45 | let mut paths = Vec::new(); 46 | for arg in args { 47 | match arg { 48 | ArgKind::Arg(arg) => { 49 | paths.push(arg); 50 | } 51 | _ => arg.bail_unsupported()?, 52 | } 53 | } 54 | 55 | match paths.len() { 56 | 0 => Ok(1), 57 | 1 => { 58 | let arg = paths.remove(0).to_string(); 59 | match arg.parse::() { 60 | Ok(value) => Ok(value), 61 | Err(_) => bail!("numeric argument required."), 62 | } 63 | } 64 | _ => { 65 | bail!("too many arguments") 66 | } 67 | } 68 | } 69 | 70 | #[cfg(test)] 71 | mod test { 72 | use super::*; 73 | 74 | #[test] 75 | fn parses_args() { 76 | assert_eq!(parse_args(vec![]).unwrap(), 1); 77 | assert_eq!(parse_args(vec!["5".to_string()]).unwrap(), 5); 78 | assert_eq!( 79 | parse_args(vec!["test".to_string()]) 80 | .err() 81 | .unwrap() 82 | .to_string(), 83 | "numeric argument required." 84 | ); 85 | assert_eq!( 86 | parse_args(vec!["1".to_string(), "2".to_string()]) 87 | .err() 88 | .unwrap() 89 | .to_string(), 90 | "too many arguments" 91 | ); 92 | assert_eq!( 93 | parse_args(vec!["-a".to_string()]) 94 | .err() 95 | .unwrap() 96 | .to_string(), 97 | "unsupported flag: -a" 98 | ); 99 | assert_eq!( 100 | parse_args(vec!["--a".to_string()]) 101 | .err() 102 | .unwrap() 103 | .to_string(), 104 | "unsupported flag: --a" 105 | ); 106 | } 107 | 108 | #[test] 109 | fn executes_exit() { 110 | assert_eq!(execute_exit(vec![]).unwrap(), 1); 111 | assert_eq!(execute_exit(vec!["0".to_string()]).unwrap(), 0); 112 | assert_eq!(execute_exit(vec!["255".to_string()]).unwrap(), 255); 113 | assert_eq!(execute_exit(vec!["256".to_string()]).unwrap(), 0); 114 | assert_eq!(execute_exit(vec!["257".to_string()]).unwrap(), 1); 115 | assert_eq!(execute_exit(vec!["-1".to_string()]).unwrap(), 255); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /crates/deno_task_shell/src/shell/commands/export.rs: -------------------------------------------------------------------------------- 1 | use super::{ShellCommand, ShellCommandContext}; 2 | use crate::shell::types::{EnvChange, ExecuteResult}; 3 | use futures::future::LocalBoxFuture; 4 | 5 | fn is_valid_identifier(name: &str) -> bool { 6 | if name.is_empty() { 7 | return false; 8 | } 9 | let first_char = name.chars().next().unwrap(); 10 | if first_char.is_ascii_digit() { 11 | return false; 12 | } 13 | name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') 14 | } 15 | 16 | pub struct ExportCommand; 17 | 18 | impl ShellCommand for ExportCommand { 19 | fn execute( 20 | &self, 21 | mut context: ShellCommandContext, 22 | ) -> LocalBoxFuture<'static, ExecuteResult> { 23 | let mut changes = Vec::new(); 24 | 25 | for arg in context.args { 26 | if let Some(equals_index) = arg.find('=') { 27 | let arg_name = &arg[..equals_index]; 28 | 29 | if !is_valid_identifier(arg_name) { 30 | let _ = context.stderr.write_line(&format!( 31 | "export: '{}': not a valid identifier", 32 | arg_name 33 | )); 34 | return Box::pin(futures::future::ready( 35 | ExecuteResult::Continue(1, Vec::new(), Vec::new()), 36 | )); 37 | } 38 | 39 | let arg_value = &arg[equals_index + 1..]; 40 | changes.push(EnvChange::SetEnvVar( 41 | arg_name.to_string(), 42 | arg_value.to_string(), 43 | )); 44 | } 45 | } 46 | 47 | Box::pin(futures::future::ready(ExecuteResult::Continue( 48 | 0, 49 | changes, 50 | Vec::new(), 51 | ))) 52 | } 53 | } 54 | 55 | #[cfg(test)] 56 | mod tests { 57 | use super::*; 58 | 59 | #[test] 60 | fn test_identifier_validation() { 61 | assert!(is_valid_identifier("valid")); 62 | assert!(is_valid_identifier("VALID_2")); 63 | assert!(!is_valid_identifier("2invalid")); 64 | assert!(!is_valid_identifier("")); 65 | assert!(!is_valid_identifier("no spaces")); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /crates/deno_task_shell/src/shell/commands/head.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2024 the Deno authors. MIT license. 2 | 3 | use std::fs::File; 4 | use std::io::Read; 5 | 6 | use futures::future::LocalBoxFuture; 7 | use miette::bail; 8 | use miette::IntoDiagnostic; 9 | use miette::Result; 10 | use tokio_util::sync::CancellationToken; 11 | 12 | use crate::ExecuteResult; 13 | use crate::ShellCommand; 14 | use crate::ShellCommandContext; 15 | use crate::ShellPipeWriter; 16 | 17 | use super::args::parse_arg_kinds; 18 | use super::args::ArgKind; 19 | 20 | pub struct HeadCommand; 21 | 22 | impl ShellCommand for HeadCommand { 23 | fn execute( 24 | &self, 25 | context: ShellCommandContext, 26 | ) -> LocalBoxFuture<'static, ExecuteResult> { 27 | let mut stderr = context.stderr.clone(); 28 | let result = match execute_head(context) { 29 | Ok(result) => result, 30 | Err(err) => { 31 | let _ = stderr.write_line(&format!("head: {err}")); 32 | ExecuteResult::from_exit_code(1) 33 | } 34 | }; 35 | Box::pin(futures::future::ready(result)) 36 | } 37 | } 38 | 39 | fn copy_lines Result>( 40 | writer: &mut ShellPipeWriter, 41 | max_lines: u64, 42 | cancellation_token: &CancellationToken, 43 | mut read: F, 44 | buffer_size: usize, 45 | ) -> Result { 46 | let mut written_lines = 0; 47 | let mut buffer = vec![0; buffer_size]; 48 | while written_lines < max_lines { 49 | if cancellation_token.is_cancelled() { 50 | return Ok(ExecuteResult::for_cancellation()); 51 | } 52 | let read_bytes = read(&mut buffer)?; 53 | if read_bytes == 0 { 54 | break; 55 | } 56 | 57 | if cancellation_token.is_cancelled() { 58 | return Ok(ExecuteResult::for_cancellation()); 59 | } 60 | 61 | let mut written_bytes: usize = 0; 62 | let split_lines = buffer[..read_bytes].split(|&b| b == b'\n'); 63 | for line in split_lines { 64 | if written_lines >= max_lines 65 | || (written_bytes + line.len()) >= read_bytes 66 | { 67 | break; 68 | } 69 | writer.write_all(line)?; 70 | writer.write_all(b"\n")?; 71 | written_bytes += line.len() + 1; 72 | written_lines += 1; 73 | } 74 | 75 | if written_lines < max_lines && written_bytes < read_bytes { 76 | writer.write_all(&buffer[written_bytes..read_bytes])?; 77 | } 78 | } 79 | 80 | Ok(ExecuteResult::from_exit_code(0)) 81 | } 82 | 83 | fn execute_head(mut context: ShellCommandContext) -> Result { 84 | let flags = parse_args(context.args)?; 85 | if flags.path == "-" { 86 | copy_lines( 87 | &mut context.stdout, 88 | flags.lines, 89 | context.state.token(), 90 | |buf| context.stdin.read(buf), 91 | 512, 92 | ) 93 | } else { 94 | let path = flags.path; 95 | match File::open(context.state.cwd().join(&path)) { 96 | Ok(mut file) => copy_lines( 97 | &mut context.stdout, 98 | flags.lines, 99 | context.state.token(), 100 | |buf| file.read(buf).into_diagnostic(), 101 | 512, 102 | ), 103 | Err(err) => { 104 | context.stderr.write_line(&format!("head: {path}: {err}"))?; 105 | Ok(ExecuteResult::from_exit_code(1)) 106 | } 107 | } 108 | } 109 | } 110 | 111 | #[derive(Debug, PartialEq)] 112 | struct HeadFlags { 113 | path: String, 114 | lines: u64, 115 | } 116 | 117 | fn parse_args(args: Vec) -> Result { 118 | let mut path: Option = None; 119 | let mut lines: Option = None; 120 | let mut iterator = parse_arg_kinds(&args).into_iter(); 121 | while let Some(arg) = iterator.next() { 122 | match arg { 123 | ArgKind::Arg(file_name) => { 124 | if path.is_none() { 125 | path = Some(file_name.to_string()); 126 | continue; 127 | } 128 | 129 | // for now, we only support one file 130 | // TODO: support multiple files 131 | bail!("only one file is supported for now"); 132 | } 133 | ArgKind::ShortFlag('n') => match iterator.next() { 134 | Some(ArgKind::Arg(arg)) => { 135 | lines = Some(arg.parse::().into_diagnostic()?); 136 | } 137 | _ => bail!("expected a value following -n"), 138 | }, 139 | ArgKind::LongFlag(flag) => { 140 | if flag == "lines" || flag == "lines=" { 141 | bail!("expected a value for --lines"); 142 | } else if let Some(arg) = flag.strip_prefix("lines=") { 143 | lines = Some(arg.parse::().into_diagnostic()?); 144 | } else { 145 | arg.bail_unsupported()? 146 | } 147 | } 148 | _ => arg.bail_unsupported()?, 149 | } 150 | } 151 | 152 | Ok(HeadFlags { 153 | path: path.unwrap_or("-".to_string()), 154 | lines: lines.unwrap_or(10), 155 | }) 156 | } 157 | 158 | #[cfg(test)] 159 | mod test { 160 | use crate::pipe; 161 | use std::cmp::min; 162 | 163 | use super::*; 164 | use pretty_assertions::assert_eq; 165 | 166 | async fn copies_lines( 167 | // #[case] 168 | buffer_size: usize, 169 | ) { 170 | let (reader, mut writer) = pipe(); 171 | let reader_handle = reader.pipe_to_string_handle(); 172 | let data = b"foo\nbar\nbaz\nqux\n"; 173 | let data_length = data.len(); 174 | let mut offset = 0; 175 | let result = copy_lines( 176 | &mut writer, 177 | 2, 178 | &CancellationToken::new(), 179 | |buffer| { 180 | if offset >= data.len() { 181 | return Ok(0); 182 | } 183 | let buffer_length = buffer.len(); 184 | let read_length = min(buffer_length, data_length); 185 | buffer[..read_length] 186 | .copy_from_slice(&data[offset..(offset + read_length)]); 187 | offset += read_length; 188 | Ok(read_length) 189 | }, 190 | buffer_size, 191 | ); 192 | drop(writer); // Drop the writer ahead of the reader to prevent a deadlock. 193 | assert_eq!(reader_handle.await.unwrap(), "foo\nbar\n"); 194 | assert_eq!(result.unwrap().into_exit_code_and_handles().0, 0); 195 | } 196 | 197 | #[tokio::test] 198 | async fn copies_lines_with_shorter_buffer_size() { 199 | copies_lines(2).await; 200 | } 201 | 202 | #[tokio::test] 203 | async fn copies_lines_with_buffer_size_to_match_each_line_length() { 204 | copies_lines(4).await; 205 | } 206 | 207 | #[tokio::test] 208 | async fn copies_lines_with_buffer_of_one_and_half_times_of_each_line_length( 209 | ) { 210 | copies_lines(6).await; 211 | } 212 | 213 | #[tokio::test] 214 | async fn copies_lines_with_long_buffer_size() { 215 | copies_lines(512).await; 216 | } 217 | 218 | #[test] 219 | fn parses_args() { 220 | assert_eq!( 221 | parse_args(vec![]).unwrap(), 222 | HeadFlags { 223 | path: "-".to_string(), 224 | lines: 10 225 | } 226 | ); 227 | assert_eq!( 228 | parse_args(vec!["-n".to_string(), "5".to_string()]).unwrap(), 229 | HeadFlags { 230 | path: "-".to_string(), 231 | lines: 5 232 | } 233 | ); 234 | assert_eq!( 235 | parse_args(vec!["--lines=5".to_string()]).unwrap(), 236 | HeadFlags { 237 | path: "-".to_string(), 238 | lines: 5 239 | } 240 | ); 241 | assert_eq!( 242 | parse_args(vec!["path".to_string()]).unwrap(), 243 | HeadFlags { 244 | path: "path".to_string(), 245 | lines: 10 246 | } 247 | ); 248 | assert_eq!( 249 | parse_args(vec![ 250 | "-n".to_string(), 251 | "5".to_string(), 252 | "path".to_string() 253 | ]) 254 | .unwrap(), 255 | HeadFlags { 256 | path: "path".to_string(), 257 | lines: 5 258 | } 259 | ); 260 | assert_eq!( 261 | parse_args(vec!["--lines=5".to_string(), "path".to_string()]) 262 | .unwrap(), 263 | HeadFlags { 264 | path: "path".to_string(), 265 | lines: 5 266 | } 267 | ); 268 | assert_eq!( 269 | parse_args(vec![ 270 | "path".to_string(), 271 | "-n".to_string(), 272 | "5".to_string() 273 | ]) 274 | .unwrap(), 275 | HeadFlags { 276 | path: "path".to_string(), 277 | lines: 5 278 | } 279 | ); 280 | assert_eq!( 281 | parse_args(vec!["path".to_string(), "--lines=5".to_string()]) 282 | .unwrap(), 283 | HeadFlags { 284 | path: "path".to_string(), 285 | lines: 5 286 | } 287 | ); 288 | assert_eq!( 289 | parse_args(vec!["-n".to_string()]) 290 | .err() 291 | .unwrap() 292 | .to_string(), 293 | "expected a value following -n" 294 | ); 295 | assert_eq!( 296 | parse_args(vec!["--lines".to_string()]) 297 | .err() 298 | .unwrap() 299 | .to_string(), 300 | "expected a value for --lines" 301 | ); 302 | assert_eq!( 303 | parse_args(vec!["--lines=".to_string()]) 304 | .err() 305 | .unwrap() 306 | .to_string(), 307 | "expected a value for --lines" 308 | ); 309 | assert_eq!( 310 | parse_args(vec!["--flag".to_string()]) 311 | .err() 312 | .unwrap() 313 | .to_string(), 314 | "unsupported flag: --flag" 315 | ); 316 | assert_eq!( 317 | parse_args(vec!["-t".to_string()]) 318 | .err() 319 | .unwrap() 320 | .to_string(), 321 | "unsupported flag: -t" 322 | ); 323 | } 324 | } 325 | -------------------------------------------------------------------------------- /crates/deno_task_shell/src/shell/commands/mkdir.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2024 the Deno authors. MIT license. 2 | 3 | use futures::future::LocalBoxFuture; 4 | use futures::FutureExt; 5 | use miette::bail; 6 | use miette::Result; 7 | use std::path::Path; 8 | 9 | use crate::shell::types::ExecuteResult; 10 | use crate::shell::types::ShellPipeWriter; 11 | 12 | use super::args::parse_arg_kinds; 13 | use super::args::ArgKind; 14 | use super::execute_with_cancellation; 15 | use super::ShellCommand; 16 | use super::ShellCommandContext; 17 | 18 | pub struct MkdirCommand; 19 | 20 | impl ShellCommand for MkdirCommand { 21 | fn execute( 22 | &self, 23 | context: ShellCommandContext, 24 | ) -> LocalBoxFuture<'static, ExecuteResult> { 25 | async move { 26 | execute_with_cancellation!( 27 | mkdir_command( 28 | context.state.cwd(), 29 | context.args, 30 | context.stderr 31 | ), 32 | context.state.token() 33 | ) 34 | } 35 | .boxed_local() 36 | } 37 | } 38 | 39 | async fn mkdir_command( 40 | cwd: &Path, 41 | args: Vec, 42 | mut stderr: ShellPipeWriter, 43 | ) -> ExecuteResult { 44 | match execute_mkdir(cwd, args).await { 45 | Ok(()) => ExecuteResult::Continue(0, Vec::new(), Vec::new()), 46 | Err(err) => { 47 | let _ = stderr.write_line(&format!("mkdir: {err}")); 48 | ExecuteResult::Continue(1, Vec::new(), Vec::new()) 49 | } 50 | } 51 | } 52 | 53 | async fn execute_mkdir(cwd: &Path, args: Vec) -> Result<()> { 54 | let flags = parse_args(args)?; 55 | for specified_path in &flags.paths { 56 | let path = cwd.join(specified_path); 57 | if path.is_file() || !flags.parents && path.is_dir() { 58 | bail!("cannot create directory '{}': File exists", specified_path); 59 | } 60 | if flags.parents { 61 | if let Err(err) = tokio::fs::create_dir_all(&path).await { 62 | bail!("cannot create directory '{}': {}", specified_path, err); 63 | } 64 | } else if let Err(err) = tokio::fs::create_dir(&path).await { 65 | bail!("cannot create directory '{}': {}", specified_path, err); 66 | } 67 | } 68 | Ok(()) 69 | } 70 | 71 | #[derive(Default, Debug, PartialEq)] 72 | struct MkdirFlags { 73 | parents: bool, 74 | paths: Vec, 75 | } 76 | 77 | fn parse_args(args: Vec) -> Result { 78 | let mut result = MkdirFlags::default(); 79 | 80 | for arg in parse_arg_kinds(&args) { 81 | match arg { 82 | ArgKind::LongFlag("parents") | ArgKind::ShortFlag('p') => { 83 | result.parents = true; 84 | } 85 | ArgKind::Arg(path) => { 86 | result.paths.push(path.to_string()); 87 | } 88 | ArgKind::LongFlag(_) 89 | | ArgKind::ShortFlag(_) 90 | | ArgKind::PlusFlag(_) => arg.bail_unsupported()?, 91 | } 92 | } 93 | 94 | if result.paths.is_empty() { 95 | bail!("missing operand"); 96 | } 97 | 98 | Ok(result) 99 | } 100 | 101 | #[cfg(test)] 102 | mod test { 103 | use tempfile::tempdir; 104 | 105 | use super::*; 106 | use std::fs; 107 | 108 | #[test] 109 | fn parses_args() { 110 | assert_eq!( 111 | parse_args(vec![ 112 | "--parents".to_string(), 113 | "a".to_string(), 114 | "b".to_string(), 115 | ]) 116 | .unwrap(), 117 | MkdirFlags { 118 | parents: true, 119 | paths: vec!["a".to_string(), "b".to_string()], 120 | } 121 | ); 122 | assert_eq!( 123 | parse_args(vec![ 124 | "-p".to_string(), 125 | "a".to_string(), 126 | "b".to_string(), 127 | ]) 128 | .unwrap(), 129 | MkdirFlags { 130 | parents: true, 131 | paths: vec!["a".to_string(), "b".to_string()], 132 | } 133 | ); 134 | assert_eq!( 135 | parse_args(vec!["--parents".to_string()]) 136 | .err() 137 | .unwrap() 138 | .to_string(), 139 | "missing operand", 140 | ); 141 | assert_eq!( 142 | parse_args(vec![ 143 | "--parents".to_string(), 144 | "-p".to_string(), 145 | "-u".to_string(), 146 | "a".to_string(), 147 | ]) 148 | .err() 149 | .unwrap() 150 | .to_string(), 151 | "unsupported flag: -u", 152 | ); 153 | assert_eq!( 154 | parse_args(vec![ 155 | "--parents".to_string(), 156 | "--random-flag".to_string(), 157 | "a".to_string(), 158 | ]) 159 | .err() 160 | .unwrap() 161 | .to_string(), 162 | "unsupported flag: --random-flag", 163 | ); 164 | } 165 | 166 | #[tokio::test] 167 | async fn test_creates() { 168 | let dir = tempdir().unwrap(); 169 | let file_path = dir.path().join("file.txt"); 170 | let sub_dir_path = dir.path().join("folder"); 171 | fs::write(&file_path, "").unwrap(); 172 | fs::create_dir(sub_dir_path).unwrap(); 173 | 174 | assert_eq!( 175 | execute_mkdir(dir.path(), vec!["file.txt".to_string()],) 176 | .await 177 | .err() 178 | .unwrap() 179 | .to_string(), 180 | "cannot create directory 'file.txt': File exists" 181 | ); 182 | 183 | assert_eq!( 184 | execute_mkdir( 185 | dir.path(), 186 | vec!["-p".to_string(), "file.txt".to_string()], 187 | ) 188 | .await 189 | .err() 190 | .unwrap() 191 | .to_string(), 192 | "cannot create directory 'file.txt': File exists" 193 | ); 194 | 195 | assert_eq!( 196 | execute_mkdir(dir.path(), vec!["folder".to_string()],) 197 | .await 198 | .err() 199 | .unwrap() 200 | .to_string(), 201 | "cannot create directory 'folder': File exists" 202 | ); 203 | 204 | // should work because of -p 205 | execute_mkdir(dir.path(), vec!["-p".to_string(), "folder".to_string()]) 206 | .await 207 | .unwrap(); 208 | 209 | execute_mkdir(dir.path(), vec!["other".to_string()]) 210 | .await 211 | .unwrap(); 212 | assert!(dir.path().join("other").exists()); 213 | 214 | // sub folder 215 | assert_eq!( 216 | execute_mkdir(dir.path(), vec!["sub/folder".to_string()],) 217 | .await 218 | .err() 219 | .unwrap() 220 | .to_string(), 221 | format!( 222 | "cannot create directory 'sub/folder': {}", 223 | no_such_file_error_text() 224 | ) 225 | ); 226 | 227 | execute_mkdir( 228 | dir.path(), 229 | vec!["-p".to_string(), "sub/folder".to_string()], 230 | ) 231 | .await 232 | .unwrap(); 233 | assert!(dir.path().join("sub").join("folder").exists()); 234 | } 235 | 236 | fn no_such_file_error_text() -> &'static str { 237 | if cfg!(windows) { 238 | "The system cannot find the path specified. (os error 3)" 239 | } else { 240 | "No such file or directory (os error 2)" 241 | } 242 | } 243 | } 244 | -------------------------------------------------------------------------------- /crates/deno_task_shell/src/shell/commands/mod.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2024 the Deno authors. MIT license. 2 | 3 | mod args; 4 | mod cat; 5 | mod cd; 6 | mod cp_mv; 7 | mod echo; 8 | mod executable; 9 | mod exit; 10 | mod export; 11 | mod head; 12 | mod mkdir; 13 | mod pwd; 14 | mod rm; 15 | mod sleep; 16 | mod unset; 17 | mod xargs; 18 | 19 | use std::collections::HashMap; 20 | use std::rc::Rc; 21 | 22 | use futures::future::LocalBoxFuture; 23 | 24 | pub use executable::ExecutableCommand; 25 | 26 | pub use args::parse_arg_kinds; 27 | pub use args::ArgKind; 28 | 29 | use super::types::ExecuteResult; 30 | use super::types::FutureExecuteResult; 31 | use super::types::ShellPipeReader; 32 | use super::types::ShellPipeWriter; 33 | use super::types::ShellState; 34 | 35 | pub fn builtin_commands() -> HashMap> { 36 | HashMap::from([ 37 | ( 38 | "cat".to_string(), 39 | Rc::new(cat::CatCommand) as Rc, 40 | ), 41 | ( 42 | "cd".to_string(), 43 | Rc::new(cd::CdCommand) as Rc, 44 | ), 45 | ( 46 | "cp".to_string(), 47 | Rc::new(cp_mv::CpCommand) as Rc, 48 | ), 49 | ( 50 | "echo".to_string(), 51 | Rc::new(echo::EchoCommand) as Rc, 52 | ), 53 | ( 54 | "exit".to_string(), 55 | Rc::new(exit::ExitCommand) as Rc, 56 | ), 57 | ( 58 | "export".to_string(), 59 | Rc::new(export::ExportCommand) as Rc, 60 | ), 61 | ( 62 | "head".to_string(), 63 | Rc::new(head::HeadCommand) as Rc, 64 | ), 65 | ( 66 | "mkdir".to_string(), 67 | Rc::new(mkdir::MkdirCommand) as Rc, 68 | ), 69 | ( 70 | "mv".to_string(), 71 | Rc::new(cp_mv::MvCommand) as Rc, 72 | ), 73 | ( 74 | "pwd".to_string(), 75 | Rc::new(pwd::PwdCommand) as Rc, 76 | ), 77 | ( 78 | "rm".to_string(), 79 | Rc::new(rm::RmCommand) as Rc, 80 | ), 81 | ( 82 | "sleep".to_string(), 83 | Rc::new(sleep::SleepCommand) as Rc, 84 | ), 85 | ( 86 | "true".to_string(), 87 | Rc::new(ExitCodeCommand(0)) as Rc, 88 | ), 89 | ( 90 | "false".to_string(), 91 | Rc::new(ExitCodeCommand(1)) as Rc, 92 | ), 93 | ( 94 | "unset".to_string(), 95 | Rc::new(unset::UnsetCommand) as Rc, 96 | ), 97 | ( 98 | "xargs".to_string(), 99 | Rc::new(xargs::XargsCommand) as Rc, 100 | ), 101 | ]) 102 | } 103 | 104 | pub struct ExecuteCommandArgsContext { 105 | pub args: Vec, 106 | pub state: ShellState, 107 | pub stdin: ShellPipeReader, 108 | pub stdout: ShellPipeWriter, 109 | pub stderr: ShellPipeWriter, 110 | } 111 | 112 | pub struct ShellCommandContext { 113 | pub args: Vec, 114 | pub state: ShellState, 115 | pub stdin: ShellPipeReader, 116 | pub stdout: ShellPipeWriter, 117 | pub stderr: ShellPipeWriter, 118 | pub execute_command_args: 119 | Box FutureExecuteResult>, 120 | } 121 | 122 | pub trait ShellCommand { 123 | fn execute( 124 | &self, 125 | context: ShellCommandContext, 126 | ) -> LocalBoxFuture<'static, ExecuteResult>; 127 | } 128 | 129 | macro_rules! execute_with_cancellation { 130 | ($result_expr:expr, $token:expr) => { 131 | tokio::select! { 132 | result = $result_expr => { 133 | result 134 | }, 135 | _ = $token.cancelled() => { 136 | ExecuteResult::for_cancellation() 137 | } 138 | } 139 | }; 140 | } 141 | 142 | pub(super) use execute_with_cancellation; 143 | 144 | struct ExitCodeCommand(i32); 145 | 146 | impl ShellCommand for ExitCodeCommand { 147 | fn execute( 148 | &self, 149 | _context: ShellCommandContext, 150 | ) -> LocalBoxFuture<'static, ExecuteResult> { 151 | // ignores additional arguments 152 | Box::pin(futures::future::ready(ExecuteResult::from_exit_code( 153 | self.0, 154 | ))) 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /crates/deno_task_shell/src/shell/commands/pwd.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2024 the Deno authors. MIT license. 2 | 3 | use futures::future::LocalBoxFuture; 4 | use miette::Context; 5 | use miette::Result; 6 | use std::path::Path; 7 | 8 | use crate::shell::fs_util; 9 | use crate::shell::types::ExecuteResult; 10 | 11 | use super::args::parse_arg_kinds; 12 | use super::args::ArgKind; 13 | use super::ShellCommand; 14 | use super::ShellCommandContext; 15 | 16 | pub struct PwdCommand; 17 | 18 | impl ShellCommand for PwdCommand { 19 | fn execute( 20 | &self, 21 | mut context: ShellCommandContext, 22 | ) -> LocalBoxFuture<'static, ExecuteResult> { 23 | let result = match execute_pwd(context.state.cwd(), context.args) { 24 | Ok(output) => { 25 | let _ = context.stdout.write_line(&output); 26 | ExecuteResult::from_exit_code(0) 27 | } 28 | Err(err) => { 29 | let _ = context.stderr.write_line(&format!("pwd: {err}")); 30 | ExecuteResult::from_exit_code(1) 31 | } 32 | }; 33 | Box::pin(futures::future::ready(result)) 34 | } 35 | } 36 | 37 | fn execute_pwd(cwd: &Path, args: Vec) -> Result { 38 | let flags = parse_args(args)?; 39 | let cwd = if flags.logical { 40 | fs_util::canonicalize_path(cwd).with_context(|| { 41 | format!("error canonicalizing: {}", cwd.display()) 42 | })? 43 | } else { 44 | cwd.to_path_buf() 45 | }; 46 | Ok(cwd.display().to_string()) 47 | } 48 | 49 | #[derive(Debug, PartialEq)] 50 | struct PwdFlags { 51 | logical: bool, 52 | } 53 | 54 | fn parse_args(args: Vec) -> Result { 55 | let mut logical = false; 56 | for arg in parse_arg_kinds(&args) { 57 | match arg { 58 | ArgKind::ShortFlag('L') => { 59 | logical = true; 60 | } 61 | ArgKind::ShortFlag('P') => { 62 | // ignore, this is the default 63 | } 64 | ArgKind::Arg(_) => { 65 | // args are ignored by pwd 66 | } 67 | _ => arg.bail_unsupported()?, 68 | } 69 | } 70 | 71 | Ok(PwdFlags { logical }) 72 | } 73 | 74 | #[cfg(test)] 75 | mod test { 76 | use super::*; 77 | use pretty_assertions::assert_eq; 78 | 79 | #[test] 80 | fn parses_args() { 81 | assert_eq!(parse_args(vec![]).unwrap(), PwdFlags { logical: false }); 82 | assert_eq!( 83 | parse_args(vec!["-P".to_string()]).unwrap(), 84 | PwdFlags { logical: false } 85 | ); 86 | assert_eq!( 87 | parse_args(vec!["-L".to_string()]).unwrap(), 88 | PwdFlags { logical: true } 89 | ); 90 | assert!(parse_args(vec!["test".to_string()]).is_ok()); 91 | assert_eq!( 92 | parse_args(vec!["--flag".to_string()]) 93 | .err() 94 | .unwrap() 95 | .to_string(), 96 | "unsupported flag: --flag" 97 | ); 98 | assert_eq!( 99 | parse_args(vec!["-t".to_string()]) 100 | .err() 101 | .unwrap() 102 | .to_string(), 103 | "unsupported flag: -t" 104 | ); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /crates/deno_task_shell/src/shell/commands/rm.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2024 the Deno authors. MIT license. 2 | 3 | use futures::future::LocalBoxFuture; 4 | use futures::FutureExt; 5 | use miette::bail; 6 | use miette::Result; 7 | use std::io::ErrorKind; 8 | use std::path::Path; 9 | 10 | use crate::shell::types::ExecuteResult; 11 | use crate::shell::types::ShellPipeWriter; 12 | 13 | use super::args::parse_arg_kinds; 14 | use super::args::ArgKind; 15 | use super::execute_with_cancellation; 16 | use super::ShellCommand; 17 | use super::ShellCommandContext; 18 | 19 | pub struct RmCommand; 20 | 21 | impl ShellCommand for RmCommand { 22 | fn execute( 23 | &self, 24 | context: ShellCommandContext, 25 | ) -> LocalBoxFuture<'static, ExecuteResult> { 26 | async move { 27 | execute_with_cancellation!( 28 | rm_command(context.state.cwd(), context.args, context.stderr), 29 | context.state.token() 30 | ) 31 | } 32 | .boxed_local() 33 | } 34 | } 35 | 36 | async fn rm_command( 37 | cwd: &Path, 38 | args: Vec, 39 | mut stderr: ShellPipeWriter, 40 | ) -> ExecuteResult { 41 | match execute_remove(cwd, args).await { 42 | Ok(()) => ExecuteResult::from_exit_code(0), 43 | Err(err) => { 44 | let _ = stderr.write_line(&format!("rm: {err}")); 45 | ExecuteResult::from_exit_code(1) 46 | } 47 | } 48 | } 49 | 50 | async fn execute_remove(cwd: &Path, args: Vec) -> Result<()> { 51 | let flags = parse_args(args)?; 52 | for specified_path in &flags.paths { 53 | let path = cwd.join(specified_path); 54 | let result = if flags.recursive { 55 | if path.is_dir() { 56 | tokio::fs::remove_dir_all(&path).await 57 | } else { 58 | remove_file_or_dir(&path, &flags).await 59 | } 60 | } else { 61 | remove_file_or_dir(&path, &flags).await 62 | }; 63 | if let Err(err) = result { 64 | if err.kind() != ErrorKind::NotFound || !flags.force { 65 | bail!("cannot remove '{}': {}", specified_path, err); 66 | } 67 | } 68 | } 69 | 70 | Ok(()) 71 | } 72 | 73 | async fn remove_file_or_dir( 74 | path: &Path, 75 | flags: &RmFlags, 76 | ) -> std::io::Result<()> { 77 | if flags.dir && path.is_dir() { 78 | tokio::fs::remove_dir(path).await 79 | } else { 80 | tokio::fs::remove_file(path).await 81 | } 82 | } 83 | 84 | #[derive(Default, Debug, PartialEq)] 85 | struct RmFlags { 86 | force: bool, 87 | recursive: bool, 88 | dir: bool, 89 | paths: Vec, 90 | } 91 | 92 | fn parse_args(args: Vec) -> Result { 93 | let mut result = RmFlags::default(); 94 | 95 | for arg in parse_arg_kinds(&args) { 96 | match arg { 97 | ArgKind::LongFlag("recursive") 98 | | ArgKind::ShortFlag('r') 99 | | ArgKind::ShortFlag('R') => { 100 | result.recursive = true; 101 | } 102 | ArgKind::LongFlag("dir") | ArgKind::ShortFlag('d') => { 103 | result.dir = true; 104 | } 105 | ArgKind::LongFlag("force") | ArgKind::ShortFlag('f') => { 106 | result.force = true; 107 | } 108 | ArgKind::Arg(path) => { 109 | result.paths.push(path.to_string()); 110 | } 111 | ArgKind::LongFlag(_) 112 | | ArgKind::ShortFlag(_) 113 | | ArgKind::PlusFlag(_) => arg.bail_unsupported()?, 114 | } 115 | } 116 | 117 | if result.paths.is_empty() { 118 | bail!("missing operand"); 119 | } 120 | 121 | Ok(result) 122 | } 123 | 124 | #[cfg(test)] 125 | mod test { 126 | use tempfile::tempdir; 127 | 128 | use super::*; 129 | use std::fs; 130 | 131 | #[test] 132 | fn parses_args() { 133 | assert_eq!( 134 | parse_args(vec![ 135 | "--recursive".to_string(), 136 | "--dir".to_string(), 137 | "a".to_string(), 138 | "b".to_string(), 139 | ]) 140 | .unwrap(), 141 | RmFlags { 142 | recursive: true, 143 | dir: true, 144 | paths: vec!["a".to_string(), "b".to_string()], 145 | ..Default::default() 146 | } 147 | ); 148 | assert_eq!( 149 | parse_args(vec![ 150 | "-rf".to_string(), 151 | "a".to_string(), 152 | "b".to_string(), 153 | ]) 154 | .unwrap(), 155 | RmFlags { 156 | recursive: true, 157 | force: true, 158 | dir: false, 159 | paths: vec!["a".to_string(), "b".to_string()], 160 | } 161 | ); 162 | assert_eq!( 163 | parse_args(vec!["-d".to_string(), "a".to_string()]).unwrap(), 164 | RmFlags { 165 | recursive: false, 166 | force: false, 167 | dir: true, 168 | paths: vec!["a".to_string()], 169 | } 170 | ); 171 | assert_eq!( 172 | parse_args(vec!["--recursive".to_string(), "-f".to_string(),]) 173 | .err() 174 | .unwrap() 175 | .to_string(), 176 | "missing operand", 177 | ); 178 | assert_eq!( 179 | parse_args(vec![ 180 | "--recursive".to_string(), 181 | "-u".to_string(), 182 | "a".to_string(), 183 | ]) 184 | .err() 185 | .unwrap() 186 | .to_string(), 187 | "unsupported flag: -u", 188 | ); 189 | assert_eq!( 190 | parse_args(vec![ 191 | "--recursive".to_string(), 192 | "--random-flag".to_string(), 193 | "a".to_string(), 194 | ]) 195 | .err() 196 | .unwrap() 197 | .to_string(), 198 | "unsupported flag: --random-flag", 199 | ); 200 | } 201 | 202 | #[tokio::test] 203 | async fn test_force() { 204 | let dir = tempdir().unwrap(); 205 | let existent_file = dir.path().join("existent.txt"); 206 | fs::write(&existent_file, "").unwrap(); 207 | 208 | execute_remove( 209 | dir.path(), 210 | vec!["-f".to_string(), "non_existent.txt".to_string()], 211 | ) 212 | .await 213 | .unwrap(); 214 | 215 | let result = 216 | execute_remove(dir.path(), vec!["non_existent.txt".to_string()]) 217 | .await; 218 | assert_eq!( 219 | result.err().unwrap().to_string(), 220 | format!( 221 | "cannot remove 'non_existent.txt': {}", 222 | no_such_file_error_text() 223 | ) 224 | ); 225 | 226 | assert!(existent_file.exists()); 227 | execute_remove(dir.path(), vec!["existent.txt".to_string()]) 228 | .await 229 | .unwrap(); 230 | assert!(!existent_file.exists()); 231 | } 232 | 233 | #[tokio::test] 234 | async fn test_recursive() { 235 | let dir = tempdir().unwrap(); 236 | let existent_file = dir.path().join("existent.txt"); 237 | fs::write(&existent_file, "").unwrap(); 238 | 239 | let result = execute_remove( 240 | dir.path(), 241 | vec!["-r".to_string(), "non_existent.txt".to_string()], 242 | ) 243 | .await; 244 | assert_eq!( 245 | result.err().unwrap().to_string(), 246 | format!( 247 | "cannot remove 'non_existent.txt': {}", 248 | no_such_file_error_text() 249 | ) 250 | ); 251 | 252 | // test on a file 253 | assert!(existent_file.exists()); 254 | execute_remove( 255 | dir.path(), 256 | vec!["-r".to_string(), "existent.txt".to_string()], 257 | ) 258 | .await 259 | .unwrap(); 260 | assert!(!existent_file.exists()); 261 | 262 | // test on a directory 263 | let sub_dir = dir.path().join("folder").join("sub"); 264 | fs::create_dir_all(&sub_dir).unwrap(); 265 | let sub_file = sub_dir.join("file.txt"); 266 | fs::write(&sub_file, "test").unwrap(); 267 | assert!(sub_file.exists()); 268 | execute_remove( 269 | dir.path(), 270 | vec!["-r".to_string(), "folder".to_string()], 271 | ) 272 | .await 273 | .unwrap(); 274 | assert!(!sub_file.exists()); 275 | 276 | let result = execute_remove( 277 | dir.path(), 278 | vec!["-r".to_string(), "folder".to_string()], 279 | ) 280 | .await; 281 | assert_eq!( 282 | result.err().unwrap().to_string(), 283 | format!("cannot remove 'folder': {}", no_such_file_error_text()) 284 | ); 285 | execute_remove( 286 | dir.path(), 287 | vec!["-rf".to_string(), "folder".to_string()], 288 | ) 289 | .await 290 | .unwrap(); 291 | } 292 | 293 | #[tokio::test] 294 | async fn test_dir() { 295 | let dir = tempdir().unwrap(); 296 | let existent_file = dir.path().join("existent.txt"); 297 | let existent_dir = dir.path().join("sub_dir"); 298 | let existent_dir_files = dir.path().join("sub_dir_files"); 299 | fs::write(&existent_file, "").unwrap(); 300 | fs::create_dir(&existent_dir).unwrap(); 301 | fs::create_dir(&existent_dir_files).unwrap(); 302 | fs::write(existent_dir_files.join("file.txt"), "").unwrap(); 303 | 304 | assert!(execute_remove( 305 | dir.path(), 306 | vec!["-d".to_string(), "existent.txt".to_string()], 307 | ) 308 | .await 309 | .is_ok()); 310 | 311 | assert!(execute_remove( 312 | dir.path(), 313 | vec!["-d".to_string(), "sub_dir".to_string()], 314 | ) 315 | .await 316 | .is_ok()); 317 | assert!(!existent_dir.exists()); 318 | 319 | let result = execute_remove( 320 | dir.path(), 321 | vec!["-d".to_string(), "sub_dir_files".to_string()], 322 | ) 323 | .await; 324 | assert_eq!( 325 | result.err().unwrap().to_string(), 326 | format!( 327 | "cannot remove 'sub_dir_files': {}", 328 | directory_not_empty_text() 329 | ), 330 | ); 331 | assert!(existent_dir_files.exists()); 332 | } 333 | 334 | fn no_such_file_error_text() -> &'static str { 335 | if cfg!(windows) { 336 | "The system cannot find the file specified. (os error 2)" 337 | } else { 338 | "No such file or directory (os error 2)" 339 | } 340 | } 341 | 342 | fn directory_not_empty_text() -> &'static str { 343 | if cfg!(windows) { 344 | "The directory is not empty. (os error 145)" 345 | } else if cfg!(target_os = "macos") { 346 | "Directory not empty (os error 66)" 347 | } else { 348 | "Directory not empty (os error 39)" 349 | } 350 | } 351 | } 352 | -------------------------------------------------------------------------------- /crates/deno_task_shell/src/shell/commands/sleep.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2024 the Deno authors. MIT license. 2 | 3 | use std::time::Duration; 4 | 5 | use futures::future::LocalBoxFuture; 6 | use futures::FutureExt; 7 | use miette::bail; 8 | use miette::IntoDiagnostic; 9 | use miette::Result; 10 | 11 | use crate::shell::types::ExecuteResult; 12 | use crate::shell::types::ShellPipeWriter; 13 | 14 | use super::args::parse_arg_kinds; 15 | use super::args::ArgKind; 16 | use super::execute_with_cancellation; 17 | use super::ShellCommand; 18 | use super::ShellCommandContext; 19 | 20 | pub struct SleepCommand; 21 | 22 | impl ShellCommand for SleepCommand { 23 | fn execute( 24 | &self, 25 | context: ShellCommandContext, 26 | ) -> LocalBoxFuture<'static, ExecuteResult> { 27 | async move { 28 | execute_with_cancellation!( 29 | sleep_command(context.args, context.stderr), 30 | context.state.token() 31 | ) 32 | } 33 | .boxed_local() 34 | } 35 | } 36 | 37 | async fn sleep_command( 38 | args: Vec, 39 | mut stderr: ShellPipeWriter, 40 | ) -> ExecuteResult { 41 | match execute_sleep(args).await { 42 | Ok(()) => ExecuteResult::from_exit_code(0), 43 | Err(err) => { 44 | let _ = stderr.write_line(&format!("sleep: {err}")); 45 | ExecuteResult::from_exit_code(1) 46 | } 47 | } 48 | } 49 | 50 | async fn execute_sleep(args: Vec) -> Result<()> { 51 | let ms = parse_args(args)?; 52 | tokio::time::sleep(Duration::from_millis(ms)).await; 53 | Ok(()) 54 | } 55 | 56 | fn parse_arg(arg: &str) -> Result { 57 | if let Some(t) = arg.strip_suffix('s') { 58 | return t.parse().into_diagnostic(); 59 | } 60 | if let Some(t) = arg.strip_suffix('m') { 61 | return Ok(t.parse::().into_diagnostic()? * 60.); 62 | } 63 | if let Some(t) = arg.strip_suffix('h') { 64 | return Ok(t.parse::().into_diagnostic()? * 60. * 60.); 65 | } 66 | if let Some(t) = arg.strip_suffix('d') { 67 | return Ok(t.parse::().into_diagnostic()? * 60. * 60. * 24.); 68 | } 69 | 70 | arg.parse().into_diagnostic() 71 | } 72 | 73 | fn parse_args(args: Vec) -> Result { 74 | // the time to sleep is the sum of all the arguments 75 | let mut total_time_ms = 0; 76 | let mut had_value = false; 77 | for arg in parse_arg_kinds(&args) { 78 | match arg { 79 | ArgKind::Arg(arg) => match parse_arg(arg) { 80 | Ok(value_s) => { 81 | let ms = (value_s * 1000f64) as u64; 82 | total_time_ms += ms; 83 | had_value = true; 84 | } 85 | Err(err) => { 86 | bail!( 87 | "error parsing argument '{}' to number: {}", 88 | arg, 89 | err 90 | ); 91 | } 92 | }, 93 | ArgKind::LongFlag(_) 94 | | ArgKind::ShortFlag(_) 95 | | ArgKind::PlusFlag(_) => arg.bail_unsupported()?, 96 | } 97 | } 98 | if !had_value { 99 | bail!("missing operand"); 100 | } 101 | Ok(total_time_ms) 102 | } 103 | 104 | #[cfg(test)] 105 | mod test { 106 | use std::time::Instant; 107 | 108 | use super::*; 109 | 110 | #[test] 111 | fn should_parse_arg() { 112 | assert_eq!(parse_arg("1").unwrap(), 1.); 113 | assert_eq!(parse_arg("1s").unwrap(), 1.); 114 | assert_eq!(parse_arg("1m").unwrap(), 1. * 60.); 115 | assert_eq!(parse_arg("1h").unwrap(), 1. * 60. * 60.); 116 | assert_eq!(parse_arg("1d").unwrap(), 1. * 60. * 60. * 24.); 117 | assert!(parse_arg("d").err().is_some()); 118 | } 119 | 120 | #[test] 121 | fn should_parse_args() { 122 | let value = parse_args(vec![ 123 | "0.5".to_string(), 124 | "1m".to_string(), 125 | "1.25".to_string(), 126 | ]) 127 | .unwrap(); 128 | assert_eq!(value, 500 + 1000 * 60 + 1250); 129 | 130 | let result = parse_args(vec![]).err().unwrap(); 131 | assert_eq!(result.to_string(), "missing operand"); 132 | 133 | let result = parse_args(vec!["test".to_string()]).err().unwrap(); 134 | assert_eq!( 135 | result.to_string(), 136 | "error parsing argument 'test' to number: invalid float literal" 137 | ); 138 | } 139 | 140 | #[tokio::test] 141 | async fn should_execute() { 142 | let time = Instant::now(); 143 | execute_sleep(vec!["0.1".to_string()]).await.unwrap(); 144 | assert!(time.elapsed().as_millis() >= 100); 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /crates/deno_task_shell/src/shell/commands/unset.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2024 the Deno authors. MIT license. 2 | 3 | use futures::future::LocalBoxFuture; 4 | use miette::bail; 5 | use miette::Result; 6 | 7 | use crate::shell::types::ExecuteResult; 8 | use crate::EnvChange; 9 | 10 | use super::ShellCommand; 11 | use super::ShellCommandContext; 12 | 13 | pub struct UnsetCommand; 14 | 15 | impl ShellCommand for UnsetCommand { 16 | fn execute( 17 | &self, 18 | mut context: ShellCommandContext, 19 | ) -> LocalBoxFuture<'static, ExecuteResult> { 20 | let result = match parse_names(context.args) { 21 | Ok(names) => ExecuteResult::Continue( 22 | 0, 23 | names.into_iter().map(EnvChange::UnsetVar).collect(), 24 | Vec::new(), 25 | ), 26 | Err(err) => { 27 | let _ = context.stderr.write_line(&format!("unset: {err}")); 28 | ExecuteResult::Continue(1, Vec::new(), Vec::new()) 29 | } 30 | }; 31 | Box::pin(futures::future::ready(result)) 32 | } 33 | } 34 | 35 | fn parse_names(mut args: Vec) -> Result> { 36 | match args.first() { 37 | None => { 38 | // Running the actual `unset` with no argument completes with success. 39 | Ok(args) 40 | } 41 | Some(flag) if flag == "-f" => bail!("unsupported flag: -f"), 42 | Some(flag) if flag == "-v" => { 43 | // It's fine to use `swap_remove` (instead of `remove`) because the order 44 | // of args doesn't matter for `unset` command. 45 | args.swap_remove(0); 46 | Ok(args) 47 | } 48 | Some(_) => Ok(args), 49 | } 50 | } 51 | 52 | #[cfg(test)] 53 | mod test { 54 | use super::*; 55 | 56 | #[test] 57 | fn parse_args() { 58 | assert_eq!( 59 | parse_names(vec!["VAR1".to_string()]).unwrap(), 60 | vec!["VAR1".to_string()] 61 | ); 62 | assert_eq!( 63 | parse_names(vec!["VAR1".to_string(), "VAR2".to_string()]).unwrap(), 64 | vec!["VAR1".to_string(), "VAR2".to_string()] 65 | ); 66 | assert!(parse_names(vec![]).unwrap().is_empty()); 67 | assert_eq!( 68 | parse_names(vec![ 69 | "-f".to_string(), 70 | "VAR1".to_string(), 71 | "VAR2".to_string() 72 | ]) 73 | .err() 74 | .unwrap() 75 | .to_string(), 76 | "unsupported flag: -f".to_string() 77 | ); 78 | assert_eq!( 79 | parse_names(vec![ 80 | "-v".to_string(), 81 | "VAR1".to_string(), 82 | "VAR2".to_string() 83 | ]) 84 | .unwrap(), 85 | vec!["VAR2".to_string(), "VAR1".to_string()] 86 | ); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /crates/deno_task_shell/src/shell/commands/xargs.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2024 the Deno authors. MIT license. 2 | 3 | use futures::future::LocalBoxFuture; 4 | use futures::FutureExt; 5 | use miette::bail; 6 | use miette::IntoDiagnostic; 7 | use miette::Result; 8 | 9 | use crate::shell::types::ExecuteResult; 10 | use crate::shell::types::ShellPipeReader; 11 | use crate::ExecuteCommandArgsContext; 12 | 13 | use super::args::parse_arg_kinds; 14 | use super::args::ArgKind; 15 | use super::ShellCommand; 16 | use super::ShellCommandContext; 17 | 18 | pub struct XargsCommand; 19 | 20 | impl ShellCommand for XargsCommand { 21 | fn execute( 22 | &self, 23 | mut context: ShellCommandContext, 24 | ) -> LocalBoxFuture<'static, ExecuteResult> { 25 | async move { 26 | match xargs_collect_args(context.args, context.stdin.clone()) { 27 | Ok(args) => { 28 | // don't select on cancellation here as that will occur at a lower level 29 | (context.execute_command_args)(ExecuteCommandArgsContext { 30 | args, 31 | state: context.state, 32 | stdin: context.stdin, 33 | stdout: context.stdout, 34 | stderr: context.stderr, 35 | }) 36 | .await 37 | } 38 | Err(err) => { 39 | let _ = context.stderr.write_line(&format!("xargs: {err}")); 40 | ExecuteResult::from_exit_code(1) 41 | } 42 | } 43 | } 44 | .boxed_local() 45 | } 46 | } 47 | 48 | fn xargs_collect_args( 49 | cli_args: Vec, 50 | stdin: ShellPipeReader, 51 | ) -> Result> { 52 | let flags = parse_args(cli_args)?; 53 | let mut buf = Vec::new(); 54 | stdin.pipe_to(&mut buf)?; 55 | let text = String::from_utf8(buf).into_diagnostic()?; 56 | let mut args = flags.initial_args; 57 | 58 | if args.is_empty() { 59 | // defaults to echo 60 | args.push("echo".to_string()); 61 | } 62 | 63 | if let Some(delim) = &flags.delimiter { 64 | // strip a single trailing newline (xargs seems to do this) 65 | let text = if *delim == '\n' { 66 | if let Some(text) = text.strip_suffix(&delim.to_string()) { 67 | text 68 | } else { 69 | &text 70 | } 71 | } else { 72 | &text 73 | }; 74 | 75 | args.extend(text.split(*delim).map(|t| t.to_string())); 76 | } else if flags.is_null_delimited { 77 | args.extend(text.split('\0').map(|t| t.to_string())); 78 | } else { 79 | args.extend(delimit_blanks(&text)?); 80 | } 81 | 82 | Ok(args) 83 | } 84 | 85 | fn delimit_blanks(text: &str) -> Result> { 86 | let mut chars = text.chars().peekable(); 87 | let mut result = Vec::new(); 88 | while chars.peek().is_some() { 89 | let mut current = String::new(); 90 | while let Some(c) = chars.next() { 91 | match c { 92 | '\n' | '\t' | ' ' => break, 93 | '"' | '\'' => { 94 | const UNMATCHED_MESSAGE: &str = "unmatched quote; by default quotes are special to xargs unless you use the -0 option"; 95 | let original_quote_char = c; 96 | while let Some(c) = chars.next() { 97 | if c == original_quote_char { 98 | break; 99 | } 100 | match c { 101 | '\n' => bail!("{}", UNMATCHED_MESSAGE), 102 | _ => current.push(c), 103 | } 104 | if chars.peek().is_none() { 105 | bail!("{}", UNMATCHED_MESSAGE) 106 | } 107 | } 108 | } 109 | '\\' => { 110 | if matches!( 111 | chars.peek(), 112 | Some('\n' | '\t' | ' ' | '"' | '\'') 113 | ) { 114 | current.push(chars.next().unwrap()); 115 | } else { 116 | current.push(c); 117 | } 118 | } 119 | _ => current.push(c), 120 | } 121 | } 122 | 123 | if !current.is_empty() { 124 | result.push(current); 125 | } 126 | } 127 | Ok(result) 128 | } 129 | 130 | #[derive(Debug, PartialEq)] 131 | struct XargsFlags { 132 | initial_args: Vec, 133 | delimiter: Option, 134 | is_null_delimited: bool, 135 | } 136 | 137 | fn parse_args(args: Vec) -> Result { 138 | fn parse_delimiter(arg: &str) -> Result { 139 | let mut chars = arg.chars(); 140 | if let Some(first_char) = chars.next() { 141 | let mut delimiter = first_char; 142 | if first_char == '\\' { 143 | delimiter = match chars.next() { 144 | // todo(dsherret): support more 145 | Some('n') => '\n', 146 | Some('r') => '\r', 147 | Some('t') => '\t', 148 | Some('\\') => '\\', 149 | Some('0') => '\0', 150 | None => bail!("expected character following escape"), 151 | _ => bail!("unsupported/not implemented escape character"), 152 | }; 153 | } 154 | 155 | if chars.next().is_some() { 156 | bail!("expected a single byte char delimiter. Found: {}", arg); 157 | } 158 | 159 | Ok(delimiter) 160 | } else { 161 | bail!("expected non-empty delimiter"); 162 | } 163 | } 164 | 165 | let mut initial_args = Vec::new(); 166 | let mut delimiter = None; 167 | let mut iterator = parse_arg_kinds(&args).into_iter(); 168 | let mut is_null_delimited = false; 169 | while let Some(arg) = iterator.next() { 170 | match arg { 171 | ArgKind::Arg(arg) => { 172 | if arg == "-0" { 173 | is_null_delimited = true; 174 | } else { 175 | initial_args.push(arg.to_string()); 176 | // parse the remainder as arguments 177 | for arg in iterator.by_ref() { 178 | match arg { 179 | ArgKind::Arg(arg) => { 180 | initial_args.push(arg.to_string()); 181 | } 182 | ArgKind::ShortFlag(f) => { 183 | initial_args.push(format!("-{f}")) 184 | } 185 | ArgKind::LongFlag(f) => { 186 | initial_args.push(format!("--{f}")) 187 | } 188 | _ => continue, 189 | } 190 | } 191 | } 192 | } 193 | ArgKind::LongFlag("null") => { 194 | is_null_delimited = true; 195 | } 196 | ArgKind::ShortFlag('d') => match iterator.next() { 197 | Some(ArgKind::Arg(arg)) => { 198 | delimiter = Some(parse_delimiter(arg)?); 199 | } 200 | _ => bail!("expected delimiter argument following -d"), 201 | }, 202 | ArgKind::LongFlag(flag) => { 203 | if let Some(arg) = flag.strip_prefix("delimiter=") { 204 | delimiter = Some(parse_delimiter(arg)?); 205 | } else { 206 | arg.bail_unsupported()? 207 | } 208 | } 209 | _ => arg.bail_unsupported()?, 210 | } 211 | } 212 | 213 | if is_null_delimited && delimiter.is_some() { 214 | bail!("cannot specify both null and delimiter flag") 215 | } 216 | 217 | Ok(XargsFlags { 218 | initial_args, 219 | delimiter, 220 | is_null_delimited, 221 | }) 222 | } 223 | 224 | #[cfg(test)] 225 | mod test { 226 | use super::*; 227 | use pretty_assertions::assert_eq; 228 | 229 | #[test] 230 | fn parses_args() { 231 | assert_eq!( 232 | parse_args(vec![]).unwrap(), 233 | XargsFlags { 234 | initial_args: Vec::new(), 235 | delimiter: None, 236 | is_null_delimited: false, 237 | } 238 | ); 239 | assert_eq!( 240 | parse_args(vec![ 241 | "-0".to_string(), 242 | "echo".to_string(), 243 | "2".to_string(), 244 | "-d".to_string(), 245 | "--test=3".to_string() 246 | ]) 247 | .unwrap(), 248 | XargsFlags { 249 | initial_args: vec![ 250 | "echo".to_string(), 251 | "2".to_string(), 252 | "-d".to_string(), 253 | "--test=3".to_string() 254 | ], 255 | delimiter: None, 256 | is_null_delimited: true, 257 | } 258 | ); 259 | assert_eq!( 260 | parse_args(vec![ 261 | "-d".to_string(), 262 | "\\n".to_string(), 263 | "echo".to_string() 264 | ]) 265 | .unwrap(), 266 | XargsFlags { 267 | initial_args: vec!["echo".to_string()], 268 | delimiter: Some('\n'), 269 | is_null_delimited: false, 270 | } 271 | ); 272 | assert_eq!( 273 | parse_args(vec![ 274 | "--delimiter=5".to_string(), 275 | "echo".to_string(), 276 | "-d".to_string() 277 | ]) 278 | .unwrap(), 279 | XargsFlags { 280 | initial_args: vec!["echo".to_string(), "-d".to_string()], 281 | delimiter: Some('5'), 282 | is_null_delimited: false, 283 | } 284 | ); 285 | assert_eq!( 286 | parse_args(vec![ 287 | "-d".to_string(), 288 | "5".to_string(), 289 | "-t".to_string() 290 | ]) 291 | .err() 292 | .unwrap() 293 | .to_string(), 294 | "unsupported flag: -t", 295 | ); 296 | assert_eq!( 297 | parse_args(vec!["-d".to_string(), "-t".to_string()]) 298 | .err() 299 | .unwrap() 300 | .to_string(), 301 | "expected delimiter argument following -d", 302 | ); 303 | assert_eq!( 304 | parse_args(vec!["--delimiter=5".to_string(), "--null".to_string()]) 305 | .err() 306 | .unwrap() 307 | .to_string(), 308 | "cannot specify both null and delimiter flag", 309 | ); 310 | } 311 | 312 | #[test] 313 | fn should_delimit_blanks() { 314 | assert_eq!( 315 | delimit_blanks("testing this\tout\nhere\n \n\t\t test").unwrap(), 316 | vec!["testing", "this", "out", "here", "test",] 317 | ); 318 | assert_eq!( 319 | delimit_blanks("testing 'this\tout here ' \"now double\"") 320 | .unwrap(), 321 | vec!["testing", "this\tout here ", "now double"] 322 | ); 323 | assert_eq!( 324 | delimit_blanks("testing 'this\nout here '").err().unwrap().to_string(), 325 | "unmatched quote; by default quotes are special to xargs unless you use the -0 option", 326 | ); 327 | } 328 | } 329 | -------------------------------------------------------------------------------- /crates/deno_task_shell/src/shell/fs_util.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2024 the Deno authors. MIT license. 2 | 3 | use std::path::Path; 4 | use std::path::PathBuf; 5 | 6 | use miette::IntoDiagnostic; 7 | use miette::Result; 8 | 9 | /// Similar to `std::fs::canonicalize()` but strips UNC prefixes on Windows. 10 | pub fn canonicalize_path(path: &Path) -> Result { 11 | let path = path.canonicalize().into_diagnostic()?; 12 | #[cfg(windows)] 13 | return Ok(strip_unc_prefix(path)); 14 | #[cfg(not(windows))] 15 | return Ok(path); 16 | } 17 | 18 | // todo(dsherret): This function was copy and pasted from deno 19 | // so maybe we could extract it out to a separate crate in order 20 | // to share the code. 21 | 22 | #[cfg(windows)] 23 | fn strip_unc_prefix(path: PathBuf) -> PathBuf { 24 | use std::path::Component; 25 | use std::path::Prefix; 26 | 27 | let mut components = path.components(); 28 | match components.next() { 29 | Some(Component::Prefix(prefix)) => { 30 | match prefix.kind() { 31 | // \\?\device 32 | Prefix::Verbatim(device) => { 33 | let mut path = PathBuf::new(); 34 | path.push(format!(r"\\{}\", device.to_string_lossy())); 35 | path.extend( 36 | components.filter(|c| !matches!(c, Component::RootDir)), 37 | ); 38 | path 39 | } 40 | // \\?\c:\path 41 | Prefix::VerbatimDisk(_) => { 42 | let mut path = PathBuf::new(); 43 | path.push( 44 | prefix 45 | .as_os_str() 46 | .to_string_lossy() 47 | .replace(r"\\?\", ""), 48 | ); 49 | path.extend(components); 50 | path 51 | } 52 | // \\?\UNC\hostname\share_name\path 53 | Prefix::VerbatimUNC(hostname, share_name) => { 54 | let mut path = PathBuf::new(); 55 | path.push(format!( 56 | r"\\{}\{}\", 57 | hostname.to_string_lossy(), 58 | share_name.to_string_lossy() 59 | )); 60 | path.extend( 61 | components.filter(|c| !matches!(c, Component::RootDir)), 62 | ); 63 | path 64 | } 65 | _ => path, 66 | } 67 | } 68 | _ => path, 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /crates/deno_task_shell/src/shell/mod.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2024 the Deno authors. MIT license. 2 | 3 | pub use command::ResolveCommandPathError; 4 | pub use commands::ExecutableCommand; 5 | pub use commands::ExecuteCommandArgsContext; 6 | pub use commands::ShellCommand; 7 | pub use commands::ShellCommandContext; 8 | pub use execute::execute; 9 | pub use execute::{ 10 | execute_sequential_list, execute_with_pipes, AsyncCommandBehavior, 11 | }; 12 | pub use types::pipe; 13 | pub use types::EnvChange; 14 | pub use types::ExecuteResult; 15 | pub use types::FutureExecuteResult; 16 | pub use types::ShellOptions; 17 | pub use types::ShellPipeReader; 18 | pub use types::ShellPipeWriter; 19 | pub use types::ShellState; 20 | 21 | pub use commands::builtin_commands; 22 | pub use commands::parse_arg_kinds; 23 | pub use commands::ArgKind; 24 | 25 | pub mod fs_util; 26 | 27 | mod command; 28 | mod commands; 29 | mod execute; 30 | mod types; 31 | -------------------------------------------------------------------------------- /crates/shell/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "shell" 3 | version = "0.1.0" 4 | edition.workspace = true 5 | authors = ["The prefix-dev/shell team "] 6 | description = "A cross-platform, bash compatible shell" 7 | categories.workspace = true 8 | homepage.workspace = true 9 | repository.workspace = true 10 | license.workspace = true 11 | readme.workspace = true 12 | default-run = "shell" 13 | publish = false 14 | 15 | [lib] 16 | name = "shell" 17 | path = "src/lib.rs" 18 | 19 | [[bin]] 20 | name = "shell" 21 | path = "src/main.rs" 22 | 23 | [features] 24 | 25 | [dependencies] 26 | clap = { version = "4.5.28", features = ["derive"] } 27 | deno_task_shell = { path = "../deno_task_shell", features = ["shell"] } 28 | futures = "0.3.31" 29 | rustyline = { version = "15.0.0", features = ["derive"] } 30 | tokio = "1.43.0" 31 | uu_ls = "0.0.29" 32 | dirs = "6.0.0" 33 | which = "7.0.2" 34 | uu_uname = "0.0.29" 35 | uu_touch = "0.0.29" 36 | uu_date = "0.0.29" 37 | miette = { version = "7.5.0", features = ["fancy"] } 38 | filetime = "0.2.25" 39 | chrono = "0.4.39" 40 | parse_datetime = "0.8.0" 41 | dtparse = "2.0.1" 42 | windows-sys = { version = "0.59.0", features = ["Win32_Foundation", "Win32_System_Threading"] } 43 | ctrlc = "3.4.5" 44 | libc = "0.2.170" 45 | 46 | [package.metadata.release] 47 | # Dont publish the binary 48 | release = false 49 | -------------------------------------------------------------------------------- /crates/shell/src/commands/date.rs: -------------------------------------------------------------------------------- 1 | use std::ffi::OsString; 2 | 3 | use deno_task_shell::{ExecuteResult, ShellCommand, ShellCommandContext}; 4 | use futures::future::LocalBoxFuture; 5 | use uu_date::uumain as uu_date; 6 | 7 | pub struct DateCommand; 8 | 9 | impl ShellCommand for DateCommand { 10 | fn execute(&self, mut context: ShellCommandContext) -> LocalBoxFuture<'static, ExecuteResult> { 11 | Box::pin(futures::future::ready(match execute_date(&mut context) { 12 | Ok(_) => ExecuteResult::from_exit_code(0), 13 | Err(exit_code) => ExecuteResult::from_exit_code(exit_code), 14 | })) 15 | } 16 | } 17 | 18 | fn execute_date(context: &mut ShellCommandContext) -> Result<(), i32> { 19 | let mut args: Vec = vec![OsString::from("date")]; 20 | 21 | context 22 | .args 23 | .iter() 24 | .for_each(|arg| args.push(OsString::from(arg))); 25 | 26 | let exit_code = uu_date(args.into_iter()); 27 | if exit_code != 0 { 28 | return Err(exit_code); 29 | } 30 | Ok(()) 31 | } 32 | -------------------------------------------------------------------------------- /crates/shell/src/commands/mod.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, ffi::OsString, fs, rc::Rc}; 2 | 3 | use deno_task_shell::{EnvChange, ExecuteResult, ShellCommand, ShellCommandContext}; 4 | use futures::{future::LocalBoxFuture, FutureExt}; 5 | 6 | use uu_ls::uumain as uu_ls; 7 | 8 | use crate::execute; 9 | 10 | pub mod date; 11 | pub mod printenv; 12 | pub mod set; 13 | pub mod time; 14 | pub mod touch; 15 | pub mod uname; 16 | pub mod which; 17 | 18 | pub use date::DateCommand; 19 | pub use printenv::PrintEnvCommand; 20 | pub use set::SetCommand; 21 | pub use time::TimeCommand; 22 | pub use touch::TouchCommand; 23 | pub use uname::UnameCommand; 24 | pub use which::WhichCommand; 25 | 26 | pub struct LsCommand; 27 | 28 | pub struct AliasCommand; 29 | 30 | pub struct UnAliasCommand; 31 | 32 | pub struct SourceCommand; 33 | 34 | pub fn get_commands() -> HashMap> { 35 | HashMap::from([ 36 | ("ls".to_string(), Rc::new(LsCommand) as Rc), 37 | ( 38 | "alias".to_string(), 39 | Rc::new(AliasCommand) as Rc, 40 | ), 41 | ( 42 | "unalias".to_string(), 43 | Rc::new(UnAliasCommand) as Rc, 44 | ), 45 | ( 46 | ".".to_string(), 47 | Rc::new(SourceCommand) as Rc, 48 | ), 49 | ( 50 | "source".to_string(), 51 | Rc::new(SourceCommand) as Rc, 52 | ), 53 | ( 54 | "which".to_string(), 55 | Rc::new(WhichCommand) as Rc, 56 | ), 57 | ( 58 | "uname".to_string(), 59 | Rc::new(UnameCommand) as Rc, 60 | ), 61 | ( 62 | "touch".to_string(), 63 | Rc::new(TouchCommand) as Rc, 64 | ), 65 | ( 66 | "date".to_string(), 67 | Rc::new(DateCommand) as Rc, 68 | ), 69 | ( 70 | "set".to_string(), 71 | Rc::new(SetCommand) as Rc, 72 | ), 73 | ( 74 | "printenv".to_string(), 75 | Rc::new(PrintEnvCommand) as Rc, 76 | ), 77 | ( 78 | "clear".to_string(), 79 | Rc::new(ClearCommand) as Rc, 80 | ), 81 | ( 82 | "time".to_string(), 83 | Rc::new(TimeCommand) as Rc, 84 | ), 85 | ]) 86 | } 87 | 88 | impl ShellCommand for AliasCommand { 89 | fn execute(&self, context: ShellCommandContext) -> LocalBoxFuture<'static, ExecuteResult> { 90 | if context.args.len() != 1 { 91 | return Box::pin(futures::future::ready(ExecuteResult::from_exit_code(1))); 92 | } 93 | 94 | // parse the args 95 | let env_change = if let Some((alias, cmd)) = context.args[0].split_once('=') { 96 | vec![EnvChange::AliasCommand(alias.into(), cmd.into())] 97 | } else { 98 | return Box::pin(futures::future::ready(ExecuteResult::from_exit_code(1))); 99 | }; 100 | 101 | let result = ExecuteResult::Continue(0, env_change, Vec::default()); 102 | Box::pin(futures::future::ready(result)) 103 | } 104 | } 105 | 106 | impl ShellCommand for UnAliasCommand { 107 | fn execute(&self, context: ShellCommandContext) -> LocalBoxFuture<'static, ExecuteResult> { 108 | if context.args.len() != 1 { 109 | return Box::pin(futures::future::ready(ExecuteResult::from_exit_code(1))); 110 | } 111 | 112 | let result = ExecuteResult::Continue( 113 | 0, 114 | vec![EnvChange::UnAliasCommand(context.args[0].clone())], 115 | Vec::default(), 116 | ); 117 | Box::pin(futures::future::ready(result)) 118 | } 119 | } 120 | 121 | impl ShellCommand for LsCommand { 122 | fn execute(&self, context: ShellCommandContext) -> LocalBoxFuture<'static, ExecuteResult> { 123 | let result = execute_ls(context); 124 | Box::pin(futures::future::ready(result)) 125 | } 126 | } 127 | 128 | fn execute_ls(context: ShellCommandContext) -> ExecuteResult { 129 | let mut args: Vec = vec![OsString::from("ls"), OsString::from("--color=auto")]; 130 | 131 | context 132 | .args 133 | .iter() 134 | .for_each(|arg| args.push(OsString::from(arg))); 135 | 136 | let exit_code = uu_ls(args.into_iter()); 137 | ExecuteResult::from_exit_code(exit_code) 138 | } 139 | 140 | impl ShellCommand for SourceCommand { 141 | fn execute(&self, context: ShellCommandContext) -> LocalBoxFuture<'static, ExecuteResult> { 142 | if context.args.len() != 1 { 143 | return Box::pin(futures::future::ready(ExecuteResult::from_exit_code(1))); 144 | } 145 | 146 | let script = context.args[0].clone(); 147 | let script_file = context.state.cwd().join(script); 148 | match fs::read_to_string(&script_file) { 149 | Ok(content) => { 150 | let state = context.state.clone(); 151 | async move { 152 | execute::execute_inner(&content, Some(script_file.display().to_string()), state) 153 | .await 154 | .unwrap_or_else(|e| { 155 | eprintln!("Could not source script: {:?}", script_file); 156 | eprintln!("Error: {}", e); 157 | ExecuteResult::from_exit_code(1) 158 | }) 159 | } 160 | .boxed_local() 161 | } 162 | Err(e) => { 163 | eprintln!("Could not read file: {:?} ({})", script_file, e); 164 | Box::pin(futures::future::ready(ExecuteResult::from_exit_code(1))) 165 | } 166 | } 167 | } 168 | } 169 | 170 | pub struct ClearCommand; 171 | 172 | impl ShellCommand for ClearCommand { 173 | fn execute(&self, _context: ShellCommandContext) -> LocalBoxFuture<'static, ExecuteResult> { 174 | Box::pin(async move { 175 | // ANSI escape sequence to clear screen and move cursor to top 176 | print!("\x1B[2J\x1B[1;1H"); 177 | // Ensure output is flushed 178 | std::io::Write::flush(&mut std::io::stdout()).unwrap(); 179 | ExecuteResult::Continue(0, vec![], vec![]) 180 | }) 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /crates/shell/src/commands/printenv.rs: -------------------------------------------------------------------------------- 1 | use deno_task_shell::{ExecuteResult, ShellCommand, ShellCommandContext}; 2 | use futures::future::LocalBoxFuture; 3 | 4 | pub struct PrintEnvCommand; 5 | 6 | impl ShellCommand for PrintEnvCommand { 7 | fn execute(&self, mut context: ShellCommandContext) -> LocalBoxFuture<'static, ExecuteResult> { 8 | Box::pin(futures::future::ready( 9 | match execute_printenv(&mut context) { 10 | Ok(_) => ExecuteResult::from_exit_code(0), 11 | Err(exit_code) => ExecuteResult::from_exit_code(exit_code), 12 | }, 13 | )) 14 | } 15 | } 16 | 17 | fn execute_printenv(context: &mut ShellCommandContext) -> Result<(), i32> { 18 | let args = context.args.clone(); 19 | 20 | let env_vars = context.state.env_vars(); 21 | 22 | if args.is_empty() { 23 | // Print all environment variables 24 | let mut vars: Vec<_> = env_vars.iter().collect(); 25 | vars.sort_by(|(a, _), (b, _)| a.cmp(b)); 26 | 27 | for (key, value) in vars { 28 | context 29 | .stdout 30 | .write_line(&format!("{}={}", key, value)) 31 | .map_err(|_| 1)?; 32 | } 33 | Ok(()) 34 | } else { 35 | // Print specified variables 36 | for name in args { 37 | match env_vars.get(&name) { 38 | Some(value) => { 39 | context.stdout.write_line(value).map_err(|_| 1)?; 40 | } 41 | None => return Err(1), 42 | } 43 | } 44 | Ok(()) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /crates/shell/src/commands/set.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2024 the Shell authors. MIT license. 2 | 3 | use futures::future::LocalBoxFuture; 4 | use miette::bail; 5 | use miette::Result; 6 | 7 | use deno_task_shell::{ 8 | parse_arg_kinds, ArgKind, EnvChange, ExecuteResult, ShellCommand, ShellCommandContext, 9 | ShellOptions, 10 | }; 11 | 12 | pub struct SetCommand; 13 | 14 | impl ShellCommand for SetCommand { 15 | fn execute(&self, mut context: ShellCommandContext) -> LocalBoxFuture<'static, ExecuteResult> { 16 | let result = match execute_set(context.args) { 17 | Ok((code, env_changes)) => ExecuteResult::Continue(code, env_changes, Vec::new()), 18 | Err(err) => { 19 | context.stderr.write_line(&format!("set: {err}")).unwrap(); 20 | ExecuteResult::Exit(2, Vec::new(), Vec::new()) 21 | } 22 | }; 23 | Box::pin(futures::future::ready(result)) 24 | } 25 | } 26 | 27 | fn execute_set(args: Vec) -> Result<(i32, Vec)> { 28 | let args = parse_arg_kinds(&args); 29 | let mut env_changes = Vec::new(); 30 | for arg in args { 31 | match arg { 32 | ArgKind::ShortFlag('e') => { 33 | env_changes.push(EnvChange::SetShellOptions(ShellOptions::ExitOnError, true)); 34 | } 35 | ArgKind::PlusFlag('e') => { 36 | env_changes.push(EnvChange::SetShellOptions(ShellOptions::ExitOnError, false)); 37 | } 38 | ArgKind::ShortFlag('x') => { 39 | env_changes.push(EnvChange::SetShellOptions(ShellOptions::PrintTrace, true)); 40 | } 41 | ArgKind::PlusFlag('x') => { 42 | env_changes.push(EnvChange::SetShellOptions(ShellOptions::PrintTrace, false)); 43 | } 44 | _ => bail!(format!("Unsupported argument: {:?}", arg)), 45 | } 46 | } 47 | Ok((0, env_changes)) 48 | } 49 | 50 | #[tokio::test] 51 | async fn test_exit_on_error() { 52 | assert_eq!( 53 | execute_set(vec!["-e".to_string()]).unwrap(), 54 | ( 55 | 0, 56 | vec![EnvChange::SetShellOptions(ShellOptions::ExitOnError, true)] 57 | ) 58 | ); 59 | 60 | assert_eq!( 61 | execute_set(vec!["+e".to_string()]).unwrap(), 62 | ( 63 | 0, 64 | vec![EnvChange::SetShellOptions(ShellOptions::ExitOnError, false)] 65 | ) 66 | ); 67 | 68 | assert_eq!( 69 | execute_set(vec!["-x".to_string()]).unwrap(), 70 | ( 71 | 0, 72 | vec![EnvChange::SetShellOptions(ShellOptions::PrintTrace, true)] 73 | ) 74 | ); 75 | 76 | assert_eq!( 77 | execute_set(vec!["+x".to_string()]).unwrap(), 78 | ( 79 | 0, 80 | vec![EnvChange::SetShellOptions(ShellOptions::PrintTrace, false)] 81 | ) 82 | ); 83 | 84 | assert!(execute_set(vec!["-t".to_string()]).is_err()); 85 | } 86 | -------------------------------------------------------------------------------- /crates/shell/src/commands/time.rs: -------------------------------------------------------------------------------- 1 | use std::time::Instant; 2 | 3 | use deno_task_shell::{ExecuteResult, ShellCommand, ShellCommandContext}; 4 | use futures::future::LocalBoxFuture; 5 | 6 | #[cfg(unix)] 7 | use libc::{rusage, timeval, RUSAGE_CHILDREN}; 8 | 9 | #[cfg(windows)] 10 | use windows_sys::Win32::Foundation::{FILETIME, HANDLE}; 11 | #[cfg(windows)] 12 | use windows_sys::Win32::System::Threading::GetProcessTimes; 13 | 14 | pub struct TimeCommand; 15 | 16 | impl ShellCommand for TimeCommand { 17 | fn execute(&self, mut context: ShellCommandContext) -> LocalBoxFuture<'static, ExecuteResult> { 18 | Box::pin(async move { 19 | match execute_time(&mut context).await { 20 | Ok(_) => ExecuteResult::from_exit_code(0), 21 | Err(exit_code) => ExecuteResult::from_exit_code(exit_code), 22 | } 23 | }) 24 | } 25 | } 26 | 27 | #[cfg(unix)] 28 | fn timeval_to_seconds(tv: timeval) -> f64 { 29 | tv.tv_sec as f64 + (tv.tv_usec as f64 / 1_000_000.0) 30 | } 31 | 32 | #[cfg(unix)] 33 | fn get_resource_usage() -> rusage { 34 | let mut usage = unsafe { std::mem::zeroed::() }; 35 | unsafe { 36 | libc::getrusage(RUSAGE_CHILDREN, &mut usage); 37 | } 38 | usage 39 | } 40 | 41 | #[cfg(windows)] 42 | fn filetime_to_seconds(ft: FILETIME) -> f64 { 43 | // Convert FILETIME to 100-nanosecond intervals, then to seconds 44 | let time_value = ((ft.dwHighDateTime as u64) << 32) | (ft.dwLowDateTime as u64); 45 | time_value as f64 / 10_000_000.0 46 | } 47 | 48 | #[cfg(windows)] 49 | fn get_process_times(handle: HANDLE) -> (f64, f64) { 50 | // Initialize FILETIME structures 51 | let mut creation_time = FILETIME { 52 | dwLowDateTime: 0, 53 | dwHighDateTime: 0, 54 | }; 55 | let mut exit_time = FILETIME { 56 | dwLowDateTime: 0, 57 | dwHighDateTime: 0, 58 | }; 59 | let mut kernel_time = FILETIME { 60 | dwLowDateTime: 0, 61 | dwHighDateTime: 0, 62 | }; 63 | let mut user_time = FILETIME { 64 | dwLowDateTime: 0, 65 | dwHighDateTime: 0, 66 | }; 67 | 68 | unsafe { 69 | GetProcessTimes( 70 | handle, 71 | &mut creation_time, 72 | &mut exit_time, 73 | &mut kernel_time, 74 | &mut user_time, 75 | ); 76 | } 77 | 78 | // Convert to seconds 79 | let kernel_seconds = filetime_to_seconds(kernel_time); 80 | let user_seconds = filetime_to_seconds(user_time); 81 | 82 | (user_seconds, kernel_seconds) 83 | } 84 | 85 | #[cfg(windows)] 86 | fn get_current_process_handle() -> HANDLE { 87 | use windows_sys::Win32::System::Threading::GetCurrentProcess; 88 | unsafe { GetCurrentProcess() } 89 | } 90 | 91 | async fn execute_time(context: &mut ShellCommandContext) -> Result<(), i32> { 92 | if context.args.is_empty() { 93 | context 94 | .stderr 95 | .write_line("Usage: time COMMAND [ARGS...]") 96 | .ok(); 97 | return Err(1); 98 | } 99 | 100 | let command_line = context.args.join(" "); 101 | 102 | #[cfg(unix)] 103 | let before_usage = get_resource_usage(); 104 | 105 | #[cfg(windows)] 106 | let process_handle = get_current_process_handle(); 107 | #[cfg(windows)] 108 | let (before_user, before_kernel) = get_process_times(process_handle); 109 | 110 | let start = Instant::now(); 111 | 112 | let result = crate::execute::execute(&command_line, None, &mut context.state).await; 113 | 114 | let duration = start.elapsed(); 115 | 116 | #[cfg(unix)] 117 | let after_usage = get_resource_usage(); 118 | 119 | #[cfg(windows)] 120 | let (after_user, after_kernel) = get_process_times(process_handle); 121 | 122 | #[cfg(unix)] 123 | let user_time = 124 | timeval_to_seconds(after_usage.ru_utime) - timeval_to_seconds(before_usage.ru_utime); 125 | #[cfg(unix)] 126 | let sys_time = 127 | timeval_to_seconds(after_usage.ru_stime) - timeval_to_seconds(before_usage.ru_stime); 128 | 129 | #[cfg(windows)] 130 | let user_time = after_user - before_user; 131 | #[cfg(windows)] 132 | let sys_time = after_kernel - before_kernel; 133 | 134 | #[cfg(not(any(unix, windows)))] 135 | let user_time = 0.0; 136 | #[cfg(not(any(unix, windows)))] 137 | let sys_time = 0.0; 138 | 139 | let real_time = duration.as_secs_f64(); 140 | let cpu_time = user_time + sys_time; 141 | let cpu_usage = if real_time > 0.0 { 142 | (cpu_time / real_time) * 100.0 143 | } else { 144 | 0.0 145 | }; 146 | 147 | context 148 | .stderr 149 | .write_line(&format!("\nreal\t{:.3}s", real_time)) 150 | .ok(); 151 | context 152 | .stderr 153 | .write_line(&format!("user\t{:.3}s", user_time)) 154 | .ok(); 155 | context 156 | .stderr 157 | .write_line(&format!("sys\t{:.3}s", sys_time)) 158 | .ok(); 159 | context 160 | .stderr 161 | .write_line(&format!("cpu\t{:.1}%", cpu_usage)) 162 | .ok(); 163 | 164 | match result { 165 | Ok(execute_result) => match execute_result.exit_code() { 166 | 0 => Ok(()), 167 | code => Err(code), 168 | }, 169 | Err(err) => { 170 | context.stderr.write_line(&format!("Error: {}", err)).ok(); 171 | Err(1) 172 | } 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /crates/shell/src/commands/touch.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | ffi::OsString, 3 | fs::{self, OpenOptions}, 4 | io, 5 | path::{Path, PathBuf}, 6 | }; 7 | 8 | use chrono::{DateTime, Duration, Local, NaiveDateTime, TimeZone, Timelike}; 9 | use deno_task_shell::{ExecuteResult, ShellCommand, ShellCommandContext}; 10 | use filetime::{set_file_times, set_symlink_file_times, FileTime}; 11 | use futures::future::LocalBoxFuture; 12 | use miette::{miette, IntoDiagnostic, Result}; 13 | use uu_touch::{options, uu_app as uu_touch}; 14 | 15 | static ARG_FILES: &str = "files"; 16 | 17 | pub struct TouchCommand; 18 | 19 | impl ShellCommand for TouchCommand { 20 | fn execute(&self, mut context: ShellCommandContext) -> LocalBoxFuture<'static, ExecuteResult> { 21 | Box::pin(futures::future::ready(match execute_touch(&mut context) { 22 | Ok(_) => ExecuteResult::from_exit_code(0), 23 | Err(e) => { 24 | let _ = context.stderr.write_all(format!("{:?}", e).as_bytes()); 25 | ExecuteResult::from_exit_code(1) 26 | } 27 | })) 28 | } 29 | } 30 | 31 | fn execute_touch(context: &mut ShellCommandContext) -> Result<()> { 32 | let matches = uu_touch() 33 | .override_usage("touch [OPTION]...") 34 | .no_binary_name(true) 35 | .try_get_matches_from(&context.args) 36 | .into_diagnostic()?; 37 | 38 | let files = match matches.get_many::(ARG_FILES) { 39 | Some(files) => files.map(|file| { 40 | let path = PathBuf::from(file); 41 | if path.is_absolute() { 42 | path 43 | } else { 44 | context.state.cwd().join(path) 45 | } 46 | }), 47 | None => { 48 | return Err(miette!( 49 | "missing file operand\nTry 'touch --help' for more information." 50 | )) 51 | } 52 | }; 53 | 54 | let (mut atime, mut mtime) = match ( 55 | matches.get_one::(options::sources::REFERENCE), 56 | matches.get_one::(options::sources::DATE), 57 | ) { 58 | (Some(reference), Some(date)) => { 59 | let reference_path = PathBuf::from(reference); 60 | let reference_path = if reference_path.is_absolute() { 61 | reference_path 62 | } else { 63 | context.state.cwd().join(reference_path) 64 | }; 65 | let (atime, mtime) = stat(&reference_path, !matches.get_flag(options::NO_DEREF))?; 66 | let atime = filetime_to_datetime(&atime) 67 | .ok_or_else(|| miette!("Could not process the reference access time"))?; 68 | let mtime = filetime_to_datetime(&mtime) 69 | .ok_or_else(|| miette!("Could not process the reference modification time"))?; 70 | Ok((parse_date(atime, date)?, parse_date(mtime, date)?)) 71 | } 72 | (Some(reference), None) => { 73 | let reference_path = PathBuf::from(reference); 74 | let reference_path = if reference_path.is_absolute() { 75 | reference_path 76 | } else { 77 | context.state.cwd().join(reference_path) 78 | }; 79 | stat(&reference_path, !matches.get_flag(options::NO_DEREF)) 80 | } 81 | (None, Some(date)) => { 82 | let timestamp = parse_date(Local::now(), date)?; 83 | Ok((timestamp, timestamp)) 84 | } 85 | (None, None) => { 86 | let timestamp = if let Some(ts) = matches.get_one::(options::sources::TIMESTAMP) 87 | { 88 | parse_timestamp(ts)? 89 | } else { 90 | datetime_to_filetime(&Local::now()) 91 | }; 92 | Ok((timestamp, timestamp)) 93 | } 94 | } 95 | .map_err(|e| miette!("{}", e))?; 96 | 97 | for filename in files { 98 | let pathbuf = if filename.to_str() == Some("-") { 99 | pathbuf_from_stdout()? 100 | } else { 101 | filename 102 | }; 103 | 104 | let path = pathbuf.as_path(); 105 | 106 | let metadata_result = if matches.get_flag(options::NO_DEREF) { 107 | path.symlink_metadata() 108 | } else { 109 | path.metadata() 110 | }; 111 | 112 | if let Err(e) = metadata_result { 113 | if e.kind() != std::io::ErrorKind::NotFound { 114 | return Err(miette!("setting times of {}: {}", path.display(), e)); 115 | } 116 | 117 | if matches.get_flag(options::NO_CREATE) { 118 | continue; 119 | } 120 | 121 | if matches.get_flag(options::NO_DEREF) { 122 | let _ = context.stderr.write_all( 123 | format!( 124 | "setting times of {:?}: No such file or directory", 125 | path.display() 126 | ) 127 | .as_bytes(), 128 | ); 129 | continue; 130 | } 131 | 132 | OpenOptions::new() 133 | .create(true) 134 | .truncate(false) 135 | .write(true) 136 | .open(path) 137 | .map_err(|e| match e.kind() { 138 | io::ErrorKind::NotFound => { 139 | miette!( 140 | "cannot touch {}: {}", 141 | path.display(), 142 | "No such file or directory".to_string() 143 | ) 144 | } 145 | _ => miette!("cannot touch {}: {}", path.display(), e), 146 | })?; 147 | 148 | // Minor optimization: if no reference time was specified, we're done. 149 | if !matches.contains_id(options::SOURCES) { 150 | continue; 151 | } 152 | } 153 | 154 | if matches.get_flag(options::ACCESS) 155 | || matches.get_flag(options::MODIFICATION) 156 | || matches.contains_id(options::TIME) 157 | { 158 | let st = stat(path, !matches.get_flag(options::NO_DEREF))?; 159 | let time = matches 160 | .get_one::(options::TIME) 161 | .map(|s| s.as_str()) 162 | .unwrap_or(""); 163 | 164 | if !(matches.get_flag(options::ACCESS) 165 | || time.contains(&"access".to_owned()) 166 | || time.contains(&"atime".to_owned()) 167 | || time.contains(&"use".to_owned())) 168 | { 169 | atime = st.0; 170 | } 171 | 172 | if !(matches.get_flag(options::MODIFICATION) 173 | || time.contains(&"modify".to_owned()) 174 | || time.contains(&"mtime".to_owned())) 175 | { 176 | mtime = st.1; 177 | } 178 | } 179 | 180 | // sets the file access and modification times for a file or a symbolic link. 181 | // The filename, access time (atime), and modification time (mtime) are provided as inputs. 182 | 183 | // If the filename is not "-", indicating a special case for touch -h -, 184 | // the code checks if the NO_DEREF flag is set, which means the user wants to 185 | // set the times for a symbolic link itself, rather than the file it points to. 186 | if path.to_string_lossy() == "-" { 187 | set_file_times(path, atime, mtime) 188 | } else if matches.get_flag(options::NO_DEREF) { 189 | set_symlink_file_times(path, atime, mtime) 190 | } else { 191 | set_file_times(path, atime, mtime) 192 | } 193 | .map_err(|e| miette!("setting times of {}: {}", path.display(), e))?; 194 | } 195 | 196 | Ok(()) 197 | } 198 | 199 | fn stat(path: &Path, follow: bool) -> Result<(FileTime, FileTime)> { 200 | let metadata = if follow { 201 | fs::metadata(path).or_else(|_| fs::symlink_metadata(path)) 202 | } else { 203 | fs::symlink_metadata(path) 204 | } 205 | .map_err(|e| miette!("failed to get attributes of {}: {}", path.display(), e))?; 206 | 207 | Ok(( 208 | FileTime::from_last_access_time(&metadata), 209 | FileTime::from_last_modification_time(&metadata), 210 | )) 211 | } 212 | 213 | fn filetime_to_datetime(ft: &FileTime) -> Option> { 214 | Some(DateTime::from_timestamp(ft.unix_seconds(), ft.nanoseconds())?.into()) 215 | } 216 | 217 | fn parse_timestamp(s: &str) -> Result { 218 | let now = Local::now(); 219 | let parsed = if s.len() == 15 && s.contains('.') { 220 | // Handle the specific format "202401010000.00" 221 | NaiveDateTime::parse_from_str(s, "%Y%m%d%H%M.%S") 222 | .map_err(|_| miette!("invalid date format '{}'", s))? 223 | } else { 224 | dtparse::parse(s) 225 | .map(|(dt, _)| dt) 226 | .map_err(|_| miette!("invalid date format '{}'", s))? 227 | }; 228 | 229 | let local = now 230 | .timezone() 231 | .from_local_datetime(&parsed) 232 | .single() 233 | .ok_or_else(|| miette!("invalid date '{}'", s))?; 234 | 235 | // Handle leap seconds 236 | let local = if parsed.second() == 59 && s.ends_with(".60") { 237 | local + Duration::seconds(1) 238 | } else { 239 | local 240 | }; 241 | 242 | // Check for daylight saving time issues 243 | if (local + Duration::hours(1) - Duration::hours(1)).hour() != local.hour() { 244 | return Err(miette!("invalid date '{}'", s)); 245 | } 246 | 247 | Ok(datetime_to_filetime(&local)) 248 | } 249 | 250 | // TODO: this may be a good candidate to put in fsext.rs 251 | /// Returns a PathBuf to stdout. 252 | /// 253 | /// On Windows, uses GetFinalPathNameByHandleW to attempt to get the path 254 | /// from the stdout handle. 255 | fn pathbuf_from_stdout() -> Result { 256 | #[cfg(all(unix, not(target_os = "android")))] 257 | { 258 | Ok(PathBuf::from("/dev/stdout")) 259 | } 260 | #[cfg(target_os = "android")] 261 | { 262 | Ok(PathBuf::from("/proc/self/fd/1")) 263 | } 264 | #[cfg(windows)] 265 | { 266 | use std::os::windows::prelude::AsRawHandle; 267 | use windows_sys::Win32::Foundation::{ 268 | GetLastError, ERROR_INVALID_PARAMETER, ERROR_NOT_ENOUGH_MEMORY, ERROR_PATH_NOT_FOUND, 269 | HANDLE, MAX_PATH, 270 | }; 271 | use windows_sys::Win32::Storage::FileSystem::{ 272 | GetFinalPathNameByHandleW, FILE_NAME_OPENED, 273 | }; 274 | 275 | let handle = std::io::stdout().lock().as_raw_handle() as HANDLE; 276 | let mut file_path_buffer: [u16; MAX_PATH as usize] = [0; MAX_PATH as usize]; 277 | 278 | // https://docs.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-getfinalpathnamebyhandlea#examples 279 | // SAFETY: We transmute the handle to be able to cast *mut c_void into a 280 | // HANDLE (i32) so rustc will let us call GetFinalPathNameByHandleW. The 281 | // reference example code for GetFinalPathNameByHandleW implies that 282 | // it is safe for us to leave lpszfilepath uninitialized, so long as 283 | // the buffer size is correct. We know the buffer size (MAX_PATH) at 284 | // compile time. MAX_PATH is a small number (260) so we can cast it 285 | // to a u32. 286 | let ret = unsafe { 287 | GetFinalPathNameByHandleW( 288 | handle, 289 | file_path_buffer.as_mut_ptr(), 290 | file_path_buffer.len() as u32, 291 | FILE_NAME_OPENED, 292 | ) 293 | }; 294 | 295 | let buffer_size = match ret { 296 | ERROR_PATH_NOT_FOUND | ERROR_NOT_ENOUGH_MEMORY | ERROR_INVALID_PARAMETER => { 297 | return Err(miette!("GetFinalPathNameByHandleW failed with code {ret}")) 298 | } 299 | 0 => { 300 | return Err(miette!( 301 | "GetFinalPathNameByHandleW failed with code {}", 302 | // SAFETY: GetLastError is thread-safe and has no documented memory unsafety. 303 | unsafe { GetLastError() } 304 | )); 305 | } 306 | e => e as usize, 307 | }; 308 | 309 | // Don't include the null terminator 310 | Ok(String::from_utf16(&file_path_buffer[0..buffer_size]) 311 | .map_err(|e| miette!("Generated path is not valid UTF-16: {e}"))? 312 | .into()) 313 | } 314 | } 315 | 316 | fn parse_date(ref_time: DateTime, s: &str) -> Result { 317 | // Using the dtparse crate for more robust date parsing 318 | 319 | match dtparse::parse(s) { 320 | Ok((naive_dt, offset)) => { 321 | let dt = offset.map_or_else( 322 | || Local.from_local_datetime(&naive_dt).unwrap(), 323 | |off| DateTime::::from_naive_utc_and_offset(naive_dt, off), 324 | ); 325 | Ok(datetime_to_filetime(&dt)) 326 | } 327 | Err(_) => { 328 | // Fallback to parsing Unix timestamp if dtparse fails 329 | if let Some(stripped) = s.strip_prefix('@') { 330 | stripped 331 | .parse::() 332 | .map(|ts| FileTime::from_unix_time(ts, 0)) 333 | .map_err(|_| miette!("Unable to parse date: {s}")) 334 | } else { 335 | // Use ref_time as a base for relative date parsing 336 | parse_datetime::parse_datetime_at_date(ref_time, s) 337 | .map(|dt| datetime_to_filetime(&dt)) 338 | .map_err(|_| miette!("Unable to parse date: {s}")) 339 | } 340 | } 341 | } 342 | } 343 | 344 | fn datetime_to_filetime(dt: &DateTime) -> FileTime { 345 | FileTime::from_unix_time(dt.timestamp(), dt.timestamp_subsec_nanos()) 346 | } 347 | -------------------------------------------------------------------------------- /crates/shell/src/commands/uname.rs: -------------------------------------------------------------------------------- 1 | use deno_task_shell::{ExecuteResult, ShellCommand, ShellCommandContext}; 2 | use futures::future::LocalBoxFuture; 3 | use uu_uname::{options, UNameOutput}; 4 | pub struct UnameCommand; 5 | 6 | fn display(uname: &UNameOutput) -> String { 7 | let mut output = String::new(); 8 | for name in [ 9 | uname.kernel_name.as_ref(), 10 | uname.nodename.as_ref(), 11 | uname.kernel_release.as_ref(), 12 | uname.kernel_version.as_ref(), 13 | uname.machine.as_ref(), 14 | uname.os.as_ref(), 15 | uname.processor.as_ref(), 16 | uname.hardware_platform.as_ref(), 17 | ] 18 | .into_iter() 19 | .flatten() 20 | { 21 | output.push_str(name); 22 | output.push(' '); 23 | } 24 | output 25 | } 26 | 27 | impl ShellCommand for UnameCommand { 28 | fn execute(&self, mut context: ShellCommandContext) -> LocalBoxFuture<'static, ExecuteResult> { 29 | Box::pin(async move { 30 | match execute_uname(&mut context) { 31 | Ok(_) => ExecuteResult::from_exit_code(0), 32 | Err(e) => { 33 | context.stderr.write_line(&e).ok(); 34 | ExecuteResult::from_exit_code(1) 35 | } 36 | } 37 | }) 38 | } 39 | } 40 | 41 | fn execute_uname(context: &mut ShellCommandContext) -> Result<(), String> { 42 | let matches = uu_uname::uu_app() 43 | .override_usage("uname [OPTION]...") 44 | .no_binary_name(true) 45 | .try_get_matches_from(&context.args) 46 | .map_err(|e| e.to_string())?; 47 | 48 | let options = uu_uname::Options { 49 | all: matches.get_flag(options::ALL), 50 | kernel_name: matches.get_flag(options::KERNEL_NAME), 51 | nodename: matches.get_flag(options::NODENAME), 52 | kernel_release: matches.get_flag(options::KERNEL_RELEASE), 53 | kernel_version: matches.get_flag(options::KERNEL_VERSION), 54 | machine: matches.get_flag(options::MACHINE), 55 | processor: matches.get_flag(options::PROCESSOR), 56 | hardware_platform: matches.get_flag(options::HARDWARE_PLATFORM), 57 | os: matches.get_flag(options::OS), 58 | }; 59 | 60 | let uname = UNameOutput::new(&options).unwrap(); 61 | context 62 | .stdout 63 | .write_line(display(&uname).trim_end()) 64 | .map_err(|e| e.to_string())?; 65 | 66 | Ok(()) 67 | } 68 | -------------------------------------------------------------------------------- /crates/shell/src/commands/which.rs: -------------------------------------------------------------------------------- 1 | use deno_task_shell::{ExecuteResult, ShellCommand, ShellCommandContext}; 2 | use futures::future::LocalBoxFuture; 3 | 4 | pub struct WhichCommand; 5 | 6 | impl ShellCommand for WhichCommand { 7 | fn execute(&self, mut context: ShellCommandContext) -> LocalBoxFuture<'static, ExecuteResult> { 8 | Box::pin(futures::future::ready(match execute_which(&mut context) { 9 | Ok(_) => ExecuteResult::from_exit_code(0), 10 | Err(exit_code) => ExecuteResult::from_exit_code(exit_code), 11 | })) 12 | } 13 | } 14 | 15 | fn execute_which(context: &mut ShellCommandContext) -> Result<(), i32> { 16 | if context.args.len() != 1 { 17 | context.stderr.write_line("Expected one argument").ok(); 18 | return Err(1); 19 | } 20 | 21 | let arg = &context.args[0]; 22 | 23 | if let Some(alias) = context.state.alias_map().get(arg) { 24 | context 25 | .stdout 26 | .write_line(&format!("alias: \"{}\"", alias.join(" "))) 27 | .ok(); 28 | return Ok(()); 29 | } 30 | 31 | if context.state.resolve_custom_command(arg).is_some() { 32 | context.stdout.write_line("").ok(); 33 | return Ok(()); 34 | } 35 | 36 | if let Some(path) = context.state.env_vars().get("PATH") { 37 | let path = std::ffi::OsString::from(path); 38 | let which_result = which::which_in_global(arg, Some(path)) 39 | .and_then(|mut i| i.next().ok_or(which::Error::CannotFindBinaryPath)); 40 | 41 | if let Ok(p) = which_result { 42 | context.stdout.write_line(&p.to_string_lossy()).ok(); 43 | return Ok(()); 44 | } 45 | } 46 | 47 | context 48 | .stderr 49 | .write_line(&format!("{} not found", arg)) 50 | .ok(); 51 | 52 | Err(1) 53 | } 54 | -------------------------------------------------------------------------------- /crates/shell/src/completion.rs: -------------------------------------------------------------------------------- 1 | use rustyline::completion::{Completer, Pair}; 2 | use rustyline::error::ReadlineError; 3 | use rustyline::highlight::Highlighter; 4 | use rustyline::hint::Hinter; 5 | use rustyline::validate::Validator; 6 | use rustyline::{Context, Helper}; 7 | use std::borrow::Cow::{self, Owned}; 8 | use std::collections::HashSet; 9 | use std::env; 10 | use std::fs; 11 | use std::path::{Path, PathBuf}; 12 | 13 | pub struct ShellCompleter { 14 | builtins: HashSet, 15 | } 16 | 17 | impl ShellCompleter { 18 | pub fn new(builtins: HashSet) -> Self { 19 | Self { builtins } 20 | } 21 | } 22 | 23 | impl Completer for ShellCompleter { 24 | type Candidate = Pair; 25 | 26 | fn complete( 27 | &self, 28 | line: &str, 29 | pos: usize, 30 | _ctx: &Context<'_>, 31 | ) -> Result<(usize, Vec), ReadlineError> { 32 | let mut matches = Vec::new(); 33 | let (start, word) = extract_word(line, pos); 34 | 35 | let is_start = start == 0; 36 | // Complete filenames 37 | complete_filenames(is_start, word, &mut matches); 38 | 39 | // Complete shell commands 40 | complete_shell_commands(is_start, &self.builtins, word, &mut matches); 41 | 42 | // Complete executables in PATH 43 | complete_executables_in_path(is_start, word, &mut matches); 44 | 45 | matches.sort_by(|a, b| a.display.cmp(&b.display)); 46 | matches.dedup_by(|a, b| a.display == b.display); 47 | 48 | Ok((start, matches)) 49 | } 50 | } 51 | 52 | fn extract_word(line: &str, pos: usize) -> (usize, &str) { 53 | if line.ends_with(' ') { 54 | return (pos, ""); 55 | } 56 | let words: Vec<_> = line[..pos].split_whitespace().collect(); 57 | let word_start = words.last().map_or(0, |w| line.rfind(w).unwrap()); 58 | (word_start, &line[word_start..pos]) 59 | } 60 | 61 | fn escape_for_shell(s: &str) -> String { 62 | let special_chars = [ 63 | ' ', '\'', '"', '(', ')', '[', ']', '|', '&', ';', '<', '>', '$', '`', '\\', '\t', '\n', 64 | '*', '?', '{', '}', '!', 65 | ]; 66 | 67 | let mut result = String::with_capacity(s.len() * 2); 68 | for c in s.chars() { 69 | if special_chars.contains(&c) { 70 | result.push('\\'); 71 | } 72 | result.push(c); 73 | } 74 | result 75 | } 76 | 77 | #[derive(Debug)] 78 | struct FileMatch { 79 | name: String, 80 | #[allow(dead_code)] 81 | path: PathBuf, 82 | is_dir: bool, 83 | is_executable: bool, 84 | is_symlink: bool, 85 | } 86 | 87 | impl FileMatch { 88 | fn from_entry(entry: fs::DirEntry, base_path: &Path) -> Option { 89 | let metadata = match entry.metadata() { 90 | Ok(m) => m, 91 | Err(_) => return None, 92 | }; 93 | 94 | let name = entry.file_name().into_string().ok()?; 95 | 96 | // Skip hidden files 97 | if name.starts_with('.') { 98 | return None; 99 | } 100 | 101 | Some(Self { 102 | name, 103 | path: base_path.join(entry.file_name()), 104 | is_dir: metadata.is_dir(), 105 | is_executable: is_executable(&entry), 106 | is_symlink: metadata.file_type().is_symlink(), 107 | }) 108 | } 109 | 110 | fn replacement(&self, base: &str) -> String { 111 | let escaped = escape_for_shell(&self.name); 112 | if self.is_dir { 113 | format!("{}{}/", base, escaped) 114 | } else { 115 | format!("{}{}", base, escaped) 116 | } 117 | } 118 | 119 | fn display_name(&self) -> String { 120 | let mut name = self.name.clone(); 121 | if self.is_dir { 122 | name.push('/'); 123 | } else if self.is_executable { 124 | name.push('*'); 125 | } 126 | if self.is_symlink { 127 | name.push('@'); 128 | } 129 | name 130 | } 131 | } 132 | 133 | fn is_executable(entry: &fs::DirEntry) -> bool { 134 | #[cfg(unix)] 135 | { 136 | use std::os::unix::fs::PermissionsExt; 137 | 138 | let Ok(metadata) = entry.metadata() else { 139 | return false; 140 | }; 141 | 142 | metadata.permissions().mode() & 0o111 != 0 143 | } 144 | #[cfg(windows)] 145 | { 146 | entry 147 | .path() 148 | .extension() 149 | .and_then(|ext| ext.to_str()) 150 | .map(|ext| { 151 | let ext = ext.to_lowercase(); 152 | matches!(ext.as_str(), "exe" | "bat" | "cmd") 153 | }) 154 | .unwrap_or(false) 155 | } 156 | } 157 | 158 | fn resolve_dir_path(dir_path: &str) -> PathBuf { 159 | if dir_path.starts_with('/') { 160 | PathBuf::from(dir_path) 161 | } else if let Some(stripped) = dir_path.strip_prefix('~') { 162 | dirs::home_dir() 163 | .map(|h| h.join(stripped.strip_prefix('/').unwrap_or(stripped))) 164 | .unwrap_or_else(|| PathBuf::from(dir_path)) 165 | } else { 166 | PathBuf::from(".").join(dir_path) 167 | } 168 | } 169 | 170 | fn complete_filenames(is_start: bool, word: &str, matches: &mut Vec) { 171 | let (dir_path, partial_name) = match word.rfind('/') { 172 | Some(last_slash) => (&word[..=last_slash], &word[last_slash + 1..]), 173 | None => ("", word), 174 | }; 175 | 176 | let search_dir = resolve_dir_path(dir_path); 177 | let only_executable = (word.starts_with("./") || word.starts_with('/')) && is_start; 178 | 179 | let files: Vec = fs::read_dir(&search_dir) 180 | .into_iter() 181 | .flatten() 182 | .flatten() 183 | .filter_map(|entry| FileMatch::from_entry(entry, &search_dir)) 184 | .filter(|f| f.name.starts_with(partial_name)) 185 | .filter(|f| !only_executable || f.is_executable || f.is_dir) 186 | .collect(); 187 | 188 | matches.extend(files.into_iter().map(|f| Pair { 189 | display: f.display_name(), 190 | replacement: f.replacement(dir_path), 191 | })); 192 | } 193 | 194 | fn complete_shell_commands( 195 | is_start: bool, 196 | builtin_commands: &HashSet, 197 | word: &str, 198 | matches: &mut Vec, 199 | ) { 200 | if !is_start { 201 | return; 202 | } 203 | 204 | for cmd in builtin_commands { 205 | if cmd.starts_with(word) { 206 | matches.push(Pair { 207 | display: cmd.to_string(), 208 | replacement: cmd.to_string(), 209 | }); 210 | } 211 | } 212 | } 213 | 214 | fn complete_executables_in_path(is_start: bool, word: &str, matches: &mut Vec) { 215 | if !is_start { 216 | return; 217 | } 218 | let mut found = HashSet::new(); 219 | if let Ok(paths) = env::var("PATH") { 220 | for path in env::split_paths(&paths) { 221 | if let Ok(entries) = fs::read_dir(path) { 222 | for entry in entries.flatten() { 223 | if let Ok(name) = entry.file_name().into_string() { 224 | if name.starts_with(word) 225 | && entry.path().is_file() 226 | && found.insert(name.clone()) 227 | { 228 | matches.push(Pair { 229 | display: name.clone(), 230 | replacement: name, 231 | }); 232 | } 233 | } 234 | } 235 | } 236 | } 237 | } 238 | } 239 | 240 | impl Hinter for ShellCompleter { 241 | type Hint = String; 242 | } 243 | 244 | impl Highlighter for ShellCompleter { 245 | fn highlight_hint<'h>(&self, hint: &'h str) -> Cow<'h, str> { 246 | Owned("\x1b[1m".to_owned() + hint + "\x1b[m") 247 | } 248 | } 249 | 250 | impl Validator for ShellCompleter {} 251 | 252 | impl Helper for ShellCompleter {} 253 | -------------------------------------------------------------------------------- /crates/shell/src/execute.rs: -------------------------------------------------------------------------------- 1 | use deno_task_shell::{ 2 | execute_sequential_list, AsyncCommandBehavior, ExecuteResult, ShellPipeReader, ShellPipeWriter, 3 | ShellState, 4 | }; 5 | use miette::{Context, IntoDiagnostic}; 6 | 7 | pub async fn execute_inner( 8 | text: &str, 9 | filename: Option, 10 | state: ShellState, 11 | ) -> miette::Result { 12 | let list = deno_task_shell::parser::parse(text); 13 | 14 | let mut stderr = ShellPipeWriter::stderr(); 15 | let stdout = ShellPipeWriter::stdout(); 16 | let stdin = ShellPipeReader::stdin(); 17 | 18 | if let Err(e) = list { 19 | if let Some(filename) = &filename { 20 | stderr.write_all(format!("Filename: {:?}\n", filename).as_bytes())?; 21 | } 22 | stderr.write_all(format!("Syntax error: {:?}", e).as_bytes())?; 23 | return Ok(ExecuteResult::Exit(1, vec![], vec![])); 24 | } 25 | 26 | // spawn a sequential list and pipe its output to the environment 27 | let result = execute_sequential_list( 28 | list.unwrap(), 29 | state, 30 | stdin, 31 | stdout, 32 | stderr, 33 | AsyncCommandBehavior::Wait, 34 | ) 35 | .await; 36 | 37 | Ok(result) 38 | } 39 | 40 | pub async fn execute( 41 | text: &str, 42 | filename: Option, 43 | state: &mut ShellState, 44 | ) -> miette::Result { 45 | let result = execute_inner(text, filename, state.clone()).await?; 46 | 47 | let changes = match &result { 48 | ExecuteResult::Exit(_, changes, _) => changes, 49 | ExecuteResult::Continue(_, changes, _) => changes, 50 | }; 51 | // set CWD to the last command's CWD 52 | state.apply_changes(changes); 53 | std::env::set_current_dir(state.cwd()) 54 | .into_diagnostic() 55 | .context("Failed to set CWD")?; 56 | 57 | Ok(result) 58 | } 59 | -------------------------------------------------------------------------------- /crates/shell/src/helper.rs: -------------------------------------------------------------------------------- 1 | use rustyline::{ 2 | highlight::Highlighter, validate::MatchingBracketValidator, Completer, Helper, Hinter, 3 | Validator, 4 | }; 5 | 6 | use crate::completion; 7 | 8 | use std::{borrow::Cow::Borrowed, collections::HashSet}; 9 | 10 | #[derive(Helper, Completer, Hinter, Validator)] 11 | pub(crate) struct ShellPromptHelper { 12 | #[rustyline(Completer)] 13 | completer: completion::ShellCompleter, 14 | 15 | #[rustyline(Validator)] 16 | validator: MatchingBracketValidator, 17 | 18 | pub colored_prompt: String, 19 | } 20 | 21 | impl ShellPromptHelper { 22 | pub fn new(builtin_commands: HashSet) -> Self { 23 | Self { 24 | completer: completion::ShellCompleter::new(builtin_commands), 25 | validator: MatchingBracketValidator::new(), 26 | colored_prompt: String::new(), 27 | } 28 | } 29 | } 30 | 31 | impl Highlighter for ShellPromptHelper { 32 | fn highlight_prompt<'b, 's: 'b, 'p: 'b>( 33 | &'s self, 34 | prompt: &'p str, 35 | default: bool, 36 | ) -> std::borrow::Cow<'b, str> { 37 | if default { 38 | Borrowed(&self.colored_prompt) 39 | } else { 40 | Borrowed(prompt) 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /crates/shell/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod commands; 2 | pub mod execute; 3 | -------------------------------------------------------------------------------- /crates/shell/src/main.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::path::Path; 3 | use std::path::PathBuf; 4 | 5 | use clap::Parser; 6 | use deno_task_shell::parser::debug_parse; 7 | use deno_task_shell::ExecuteResult; 8 | use deno_task_shell::ShellState; 9 | use miette::Context; 10 | use miette::IntoDiagnostic; 11 | use rustyline::error::ReadlineError; 12 | use rustyline::{CompletionType, Config, Editor}; 13 | 14 | mod commands; 15 | mod completion; 16 | mod execute; 17 | mod helper; 18 | 19 | pub use execute::execute; 20 | #[derive(Parser)] 21 | struct Options { 22 | /// The path to the file that should be executed 23 | file: Option, 24 | 25 | /// Continue in interactive mode after the file has been executed 26 | #[clap(long)] 27 | interact: bool, 28 | 29 | /// Do not source ~/.shellrc on startup 30 | #[clap(long)] 31 | norc: bool, 32 | 33 | /// Execute a command 34 | #[clap(short)] 35 | command: Option, 36 | 37 | #[clap(short, long)] 38 | debug: bool, 39 | 40 | // Trailing args to forward to the script 41 | #[clap(allow_hyphen_values = true, trailing_var_arg = true)] 42 | args: Vec, 43 | } 44 | 45 | async fn init_state(norc: bool, var_args: &[String]) -> miette::Result { 46 | let mut env_vars: HashMap = std::env::vars().collect(); 47 | let default_ps1 = "{display_cwd}{git_branch}$ "; 48 | env_vars.insert("PS1".to_string(), default_ps1.to_string()); 49 | 50 | let mut shell_vars = HashMap::new(); 51 | // Set all arguments such as $0, $1, $2, etc. 52 | for (idx, arg) in var_args.iter().enumerate() { 53 | shell_vars.insert(format!("{}", idx + 1), arg.clone()); 54 | } 55 | 56 | // Set the $@ variable 57 | let args: Vec = std::env::args().collect(); 58 | shell_vars.insert("@".to_string(), args.join(" ")); 59 | shell_vars.insert("#".to_string(), args.len().to_string()); 60 | 61 | // Set the SHELL variable 62 | let current_exe = std::env::current_exe().into_diagnostic()?; 63 | env_vars.insert( 64 | "SHELL".to_string(), 65 | current_exe.to_string_lossy().to_string(), 66 | ); 67 | 68 | let cwd = std::env::current_dir().unwrap(); 69 | let mut state = 70 | ShellState::new(env_vars, &cwd, commands::get_commands()).with_shell_vars(shell_vars); 71 | 72 | // Load ~/.shellrc 73 | if let Some(home_dir) = dirs::home_dir() { 74 | let shellrc_file = home_dir.join(".shellrc"); 75 | if !norc && shellrc_file.exists() { 76 | let line = format!("source '{}'", shellrc_file.to_string_lossy()); 77 | let result = execute( 78 | &line, 79 | Some(shellrc_file.as_path().display().to_string()), 80 | &mut state, 81 | ) 82 | .await 83 | .context("Failed to source ~/.shellrc")?; 84 | state.set_last_command_exit_code(result.exit_code()); 85 | } 86 | } 87 | 88 | Ok(state) 89 | } 90 | 91 | async fn interactive(state: Option, norc: bool, args: &[String]) -> miette::Result<()> { 92 | let config = Config::builder() 93 | .history_ignore_space(true) 94 | .completion_type(CompletionType::List) 95 | .build(); 96 | 97 | ctrlc::set_handler(move || { 98 | println!("Received Ctrl+C"); 99 | }) 100 | .expect("Error setting Ctrl-C handler"); 101 | 102 | let mut rl = Editor::with_config(config).into_diagnostic()?; 103 | let builtins = deno_task_shell::builtin_commands() 104 | .keys() 105 | .chain(commands::get_commands().keys()) 106 | .map(|s| s.to_string()) 107 | .collect(); 108 | 109 | let helper = helper::ShellPromptHelper::new(builtins); 110 | rl.set_helper(Some(helper)); 111 | 112 | let mut state = match state { 113 | Some(state) => state, 114 | None => init_state(norc, args).await?, 115 | }; 116 | 117 | let home = dirs::home_dir().ok_or(miette::miette!("Couldn't get home directory"))?; 118 | 119 | // Load .shell_history 120 | let history_file: PathBuf = [home.as_path(), Path::new(".shell_history")] 121 | .iter() 122 | .collect(); 123 | if Path::new(history_file.as_path()).exists() { 124 | rl.load_history(history_file.as_path()) 125 | .into_diagnostic() 126 | .context("Failed to read the command history")?; 127 | } 128 | 129 | let mut _prev_exit_code = 0; 130 | loop { 131 | // Reset cancellation flag 132 | state.reset_cancellation_token(); 133 | 134 | // Display the prompt and read a line 135 | let readline = { 136 | let cwd = state.cwd().to_string_lossy().to_string(); 137 | let home_str = home.to_str().ok_or(miette::miette!( 138 | "Couldn't convert home directory path to UTF-8 string" 139 | ))?; 140 | if !state.last_command_cd() { 141 | state.update_git_branch(); 142 | } 143 | 144 | let mut git_branch: String = "".to_string(); 145 | if state.git_repository() { 146 | git_branch = match state.git_branch().strip_prefix("ref: refs/heads/") { 147 | Some(stripped) => stripped.to_string(), 148 | None => { 149 | let mut hash = state.git_branch().to_string(); 150 | if hash.len() > 7 { 151 | hash = hash[0..7].to_string() + "..."; 152 | } 153 | hash 154 | } 155 | }; 156 | git_branch = "(".to_owned() + &git_branch + ")"; 157 | } 158 | 159 | let mut display_cwd = if let Some(stripped) = cwd.strip_prefix(home_str) { 160 | format!("~{}", stripped.replace('\\', "/")) 161 | } else { 162 | cwd.to_string() 163 | }; 164 | 165 | // Read the PS1 environment variable 166 | let ps1 = state.env_vars().get("PS1").map_or("", |v| v); 167 | 168 | fn replace_placeholders(ps1: &str, display_cwd: &str, git_branch: &str) -> String { 169 | ps1.replace(&format!("{{{}}}", "display_cwd"), display_cwd) 170 | .replace(&format!("{{{}}}", "git_branch"), git_branch) 171 | } 172 | 173 | let prompt = replace_placeholders(ps1, &display_cwd, &git_branch); 174 | display_cwd = format!("\x1b[34m{display_cwd}\x1b[0m"); 175 | git_branch = format!("\x1b[32m{git_branch}\x1b[0m"); 176 | let color_prompt = replace_placeholders(ps1, &display_cwd, &git_branch); 177 | rl.helper_mut().unwrap().colored_prompt = color_prompt; 178 | rl.readline(&prompt) 179 | }; 180 | 181 | match readline { 182 | Ok(line) => { 183 | // Add the line to history 184 | rl.add_history_entry(line.as_str()).into_diagnostic()?; 185 | 186 | // Process the input (here we just echo it back) 187 | let result = execute(&line, None, &mut state) 188 | .await 189 | .context("Failed to execute")?; 190 | state.set_last_command_exit_code(result.exit_code()); 191 | 192 | if let ExecuteResult::Exit(exit_code, _, _) = result { 193 | std::process::exit(exit_code); 194 | } 195 | } 196 | Err(ReadlineError::Interrupted) => { 197 | // We start a new prompt on Ctrl-C, like Bash does 198 | println!("CTRL-C"); 199 | } 200 | Err(ReadlineError::Eof) => { 201 | // We exit the shell on Ctrl-D, like Bash does 202 | println!("CTRL-D"); 203 | break; 204 | } 205 | Err(err) => { 206 | println!("Error: {:?}", err); 207 | break; 208 | } 209 | } 210 | } 211 | rl.save_history(history_file.as_path()) 212 | .into_diagnostic() 213 | .context("Failed to write the command history")?; 214 | 215 | Ok(()) 216 | } 217 | 218 | #[tokio::main] 219 | async fn main() -> miette::Result<()> { 220 | let options = Options::parse(); 221 | let mut state = init_state(options.norc, &options.args).await?; 222 | 223 | match (options.file, options.command) { 224 | (None, None) => { 225 | // Interactive mode only 226 | interactive(None, options.norc, &options.args).await 227 | } 228 | (file, command) => { 229 | // Handle script file or command 230 | let (script_text, filename) = get_script_content(file, command)?; 231 | 232 | if options.debug { 233 | debug_parse(&script_text); 234 | return Ok(()); 235 | } 236 | 237 | let result = execute(&script_text, filename, &mut state).await?; 238 | 239 | if options.interact { 240 | interactive(Some(state), options.norc, &options.args).await?; 241 | } 242 | 243 | std::process::exit(result.exit_code()); 244 | } 245 | } 246 | } 247 | 248 | fn get_script_content( 249 | file: Option, 250 | command: Option, 251 | ) -> miette::Result<(String, Option)> { 252 | match (file, command) { 253 | (Some(path), _) => { 254 | let content = std::fs::read_to_string(&path) 255 | .into_diagnostic() 256 | .context("Failed to read script file")?; 257 | Ok((content, Some(path.display().to_string()))) 258 | } 259 | (_, Some(cmd)) => Ok((cmd, None)), 260 | (None, None) => unreachable!(), 261 | } 262 | } 263 | -------------------------------------------------------------------------------- /crates/tests/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tests" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | deno_task_shell = { path = "../deno_task_shell", features = ["shell"] } 8 | shell = { path = "../shell" } 9 | futures = "0.3.31" 10 | tokio = { version = "1.43.0", features = ["full"] } 11 | dirs = "6.0.0" 12 | miette = "7.5.0" 13 | 14 | [dev-dependencies] 15 | pretty_assertions = "1.4.1" 16 | tempfile = "3.16.0" 17 | -------------------------------------------------------------------------------- /crates/tests/src/test_builder.rs: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2024 the Deno authors. MIT license. 2 | use futures::future::LocalBoxFuture; 3 | use miette::IntoDiagnostic; 4 | use pretty_assertions::assert_eq; 5 | use std::collections::HashMap; 6 | use std::fs; 7 | use std::path::PathBuf; 8 | use std::rc::Rc; 9 | use tokio::task::JoinHandle; 10 | 11 | use deno_task_shell::execute_with_pipes; 12 | use deno_task_shell::fs_util; 13 | use deno_task_shell::parser::parse; 14 | use deno_task_shell::pipe; 15 | use deno_task_shell::ExecuteResult; 16 | use deno_task_shell::ShellCommand; 17 | use deno_task_shell::ShellCommandContext; 18 | use deno_task_shell::ShellPipeWriter; 19 | use deno_task_shell::ShellState; 20 | 21 | type FnShellCommandExecute = 22 | Box LocalBoxFuture<'static, ExecuteResult>>; 23 | 24 | struct FnShellCommand(FnShellCommandExecute); 25 | 26 | impl ShellCommand for FnShellCommand { 27 | fn execute(&self, context: ShellCommandContext) -> LocalBoxFuture<'static, ExecuteResult> { 28 | (self.0)(context) 29 | } 30 | } 31 | 32 | // Clippy is complaining about them all having `File` prefixes, 33 | // but there might be non-file variants in the future. 34 | #[allow(clippy::enum_variant_names)] 35 | enum TestAssertion { 36 | FileExists(String), 37 | FileNotExists(String), 38 | FileTextEquals(String, String), 39 | } 40 | 41 | struct TempDir { 42 | // hold to keep it alive until drop 43 | _inner: tempfile::TempDir, 44 | cwd: PathBuf, 45 | } 46 | 47 | impl TempDir { 48 | pub fn new() -> Self { 49 | let temp_dir = tempfile::tempdir().unwrap(); 50 | let cwd = fs_util::canonicalize_path(temp_dir.path()).unwrap(); 51 | Self { 52 | _inner: temp_dir, 53 | cwd, 54 | } 55 | } 56 | } 57 | 58 | pub struct TestBuilder { 59 | // it is much much faster to lazily create this 60 | temp_dir: Option, 61 | env_vars: HashMap, 62 | custom_commands: HashMap>, 63 | command: String, 64 | stdin: Vec, 65 | expected_exit_code: i32, 66 | expected_stderr: String, 67 | expected_stdout: String, 68 | expected_stderr_contains: String, 69 | assertions: Vec, 70 | assert_stdout: bool, 71 | assert_stderr: bool, 72 | } 73 | 74 | impl Default for TestBuilder { 75 | fn default() -> Self { 76 | Self::new() 77 | } 78 | } 79 | 80 | impl TestBuilder { 81 | pub fn new() -> Self { 82 | let env_vars = std::env::vars() 83 | .map(|(key, value)| { 84 | // For some very strange reason, key will sometimes be cased as "Path" 85 | // or other times "PATH" on Windows. Since keys are case-insensitive on 86 | // Windows, normalize the keys to be upper case. 87 | if cfg!(windows) { 88 | // need to normalize on windows 89 | (key.to_uppercase(), value) 90 | } else { 91 | (key, value) 92 | } 93 | }) 94 | .collect(); 95 | 96 | Self { 97 | temp_dir: None, 98 | env_vars, 99 | custom_commands: shell::commands::get_commands(), 100 | command: Default::default(), 101 | stdin: Default::default(), 102 | expected_exit_code: 0, 103 | expected_stderr: Default::default(), 104 | expected_stdout: Default::default(), 105 | expected_stderr_contains: Default::default(), 106 | assertions: Default::default(), 107 | assert_stdout: true, 108 | assert_stderr: false, 109 | } 110 | } 111 | 112 | pub fn ensure_temp_dir(&mut self) -> &mut Self { 113 | self.get_temp_dir(); 114 | self 115 | } 116 | 117 | fn get_temp_dir(&mut self) -> &mut TempDir { 118 | if self.temp_dir.is_none() { 119 | self.temp_dir = Some(TempDir::new()); 120 | } 121 | self.temp_dir.as_mut().unwrap() 122 | } 123 | 124 | pub fn temp_dir_path(&mut self) -> PathBuf { 125 | self.get_temp_dir().cwd.clone() 126 | } 127 | 128 | pub fn command(&mut self, command: &str) -> &mut Self { 129 | self.command = command.to_string(); 130 | self 131 | } 132 | 133 | pub fn script_file(&mut self, path: &str) -> &mut Self { 134 | self.command(fs::read_to_string(path).unwrap().as_str()); 135 | self 136 | } 137 | 138 | pub fn stdin(&mut self, stdin: &str) -> &mut Self { 139 | self.stdin = stdin.as_bytes().to_vec(); 140 | self 141 | } 142 | 143 | pub fn directory(&mut self, path: &str) -> &mut Self { 144 | let temp_dir = self.get_temp_dir(); 145 | fs::create_dir_all(temp_dir.cwd.join(path)).unwrap(); 146 | self 147 | } 148 | 149 | pub fn env_var(&mut self, name: &str, value: &str) -> &mut Self { 150 | self.env_vars.insert(name.to_string(), value.to_string()); 151 | self 152 | } 153 | 154 | pub fn custom_command(&mut self, name: &str, execute: FnShellCommandExecute) -> &mut Self { 155 | self.custom_commands 156 | .insert(name.to_string(), Rc::new(FnShellCommand(execute))); 157 | self 158 | } 159 | 160 | pub fn file(&mut self, path: &str, text: &str) -> &mut Self { 161 | let temp_dir = self.get_temp_dir(); 162 | fs::write(temp_dir.cwd.join(path), text).unwrap(); 163 | self 164 | } 165 | 166 | pub fn assert_exit_code(&mut self, code: i32) -> &mut Self { 167 | self.expected_exit_code = code; 168 | self 169 | } 170 | 171 | pub fn assert_stderr(&mut self, output: &str) -> &mut Self { 172 | self.expected_stderr.push_str(output); 173 | self.assert_stderr = true; 174 | self.expected_stderr_contains.clear(); 175 | self 176 | } 177 | 178 | pub fn assert_stderr_contains(&mut self, output: &str) -> &mut Self { 179 | self.expected_stderr_contains.push_str(output); 180 | self.assert_stderr = false; 181 | self.expected_stderr.clear(); 182 | self 183 | } 184 | 185 | pub fn assert_stdout(&mut self, output: &str) -> &mut Self { 186 | self.expected_stdout.push_str(output); 187 | self 188 | } 189 | 190 | pub fn check_stdout(&mut self, check_stdout: bool) -> &mut Self { 191 | self.assert_stdout = check_stdout; 192 | self 193 | } 194 | 195 | pub fn assert_exists(&mut self, path: &str) -> &mut Self { 196 | self.ensure_temp_dir(); 197 | let temp_dir = if let Some(temp_dir) = &self.temp_dir { 198 | temp_dir.cwd.display().to_string() 199 | } else { 200 | "NO_TEMP_DIR".to_string() 201 | }; 202 | self.assertions.push(TestAssertion::FileExists( 203 | path.to_string().replace("$TEMP_DIR", &temp_dir), 204 | )); 205 | self 206 | } 207 | 208 | pub fn assert_not_exists(&mut self, path: &str) -> &mut Self { 209 | self.ensure_temp_dir(); 210 | self.assertions 211 | .push(TestAssertion::FileNotExists(path.to_string())); 212 | self 213 | } 214 | 215 | pub fn assert_file_equals(&mut self, path: &str, file_text: &str) -> &mut Self { 216 | self.ensure_temp_dir(); 217 | self.assertions.push(TestAssertion::FileTextEquals( 218 | path.to_string(), 219 | file_text.to_string(), 220 | )); 221 | self 222 | } 223 | 224 | pub async fn run(&mut self) { 225 | std::env::set_var("NO_GRAPHICS", "1"); 226 | 227 | let list = parse(&self.command).unwrap(); 228 | let cwd = if let Some(temp_dir) = &self.temp_dir { 229 | temp_dir.cwd.clone() 230 | } else { 231 | std::env::temp_dir() 232 | }; 233 | let (stdin, mut stdin_writer) = pipe(); 234 | stdin_writer.write_all(&self.stdin).unwrap(); 235 | drop(stdin_writer); // prevent a deadlock by dropping the writer 236 | let (stdout, stdout_handle) = get_output_writer_and_handle(); 237 | let (stderr, stderr_handle) = get_output_writer_and_handle(); 238 | 239 | let local_set = tokio::task::LocalSet::new(); 240 | self.env_var("TEMP_DIR", &cwd.display().to_string()); 241 | let state = ShellState::new( 242 | self.env_vars.clone(), 243 | &cwd, 244 | self.custom_commands.drain().collect(), 245 | ); 246 | let exit_code = local_set 247 | .run_until(execute_with_pipes(list, state, stdin, stdout, stderr)) 248 | .await; 249 | let temp_dir = if let Some(temp_dir) = &self.temp_dir { 250 | temp_dir.cwd.display().to_string() 251 | } else { 252 | "NO_TEMP_DIR".to_string() 253 | }; 254 | let stderr_output = stderr_handle.await.unwrap(); 255 | if self.assert_stderr { 256 | assert_eq!( 257 | stderr_output, 258 | self.expected_stderr.replace("$TEMP_DIR", &temp_dir), 259 | "\n\nFailed for: {}", 260 | self.command 261 | ); 262 | } else if !self.expected_stderr_contains.is_empty() { 263 | assert!( 264 | stderr_output.contains( 265 | &self 266 | .expected_stderr_contains 267 | .replace("$TEMP_DIR", &temp_dir) 268 | ), 269 | "\n\nFailed for: {}\nExpected stderr to contain: {}", 270 | self.command, 271 | self.expected_stderr_contains 272 | ); 273 | } 274 | if self.assert_stdout { 275 | assert_eq!( 276 | stdout_handle.await.unwrap(), 277 | self.expected_stdout.replace("$TEMP_DIR", &temp_dir), 278 | "\n\nFailed for: {}", 279 | self.command 280 | ); 281 | } 282 | assert_eq!( 283 | exit_code, self.expected_exit_code, 284 | "\n\nFailed for: {}", 285 | self.command 286 | ); 287 | 288 | for assertion in &self.assertions { 289 | match assertion { 290 | TestAssertion::FileExists(path) => { 291 | let path_to_check = cwd.join(path); 292 | 293 | assert!( 294 | path_to_check.exists(), 295 | "\n\nFailed for: {}\nExpected '{}' to exist.", 296 | self.command, 297 | path, 298 | ) 299 | } 300 | TestAssertion::FileNotExists(path) => { 301 | assert!( 302 | !cwd.join(path).exists(), 303 | "\n\nFailed for: {}\nExpected '{}' to not exist.", 304 | self.command, 305 | path, 306 | ) 307 | } 308 | TestAssertion::FileTextEquals(path, text) => { 309 | let actual_text = std::fs::read_to_string(cwd.join(path)) 310 | .into_diagnostic() 311 | .unwrap(); 312 | assert_eq!( 313 | &actual_text, text, 314 | "\n\nFailed for: {}\nPath: {}", 315 | self.command, path, 316 | ) 317 | } 318 | } 319 | } 320 | } 321 | } 322 | 323 | fn get_output_writer_and_handle() -> (ShellPipeWriter, JoinHandle) { 324 | let (reader, writer) = pipe(); 325 | let handle = reader.pipe_to_string_handle(); 326 | (writer, handle) 327 | } 328 | -------------------------------------------------------------------------------- /crates/tests/src/test_runner.rs: -------------------------------------------------------------------------------- 1 | use miette::IntoDiagnostic; 2 | use miette::{SourceOffset, SourceSpan}; 3 | use std::path::Path; 4 | 5 | use crate::test_builder::TestBuilder; 6 | 7 | pub struct TestElement { 8 | pub test: String, 9 | pub expected_output: String, 10 | #[allow(dead_code)] 11 | pub span: SourceSpan, 12 | } 13 | 14 | pub struct Tests { 15 | tests: Vec, 16 | } 17 | 18 | impl Tests { 19 | pub fn load_from_file(path: &Path) -> miette::Result { 20 | let content = std::fs::read_to_string(path).into_diagnostic()?; 21 | let mut tests = Vec::new(); 22 | 23 | let mut current_test = String::new(); 24 | let mut current_output = String::new(); 25 | let mut start_line = 0; 26 | let mut current_line = 0; 27 | let mut source_offset = SourceOffset::from_location(&content, 0, 0); 28 | 29 | for line in content.lines() { 30 | source_offset = SourceOffset::from_location(&content, current_line, 0); 31 | 32 | current_line += 1; 33 | 34 | if line.starts_with('#') || line.trim().is_empty() { 35 | continue; 36 | } 37 | 38 | if line.starts_with('>') { 39 | if !current_test.is_empty() && !current_output.is_empty() { 40 | // Empty output is signified by a single % character 41 | if current_output == "%empty" { 42 | current_output = String::new(); 43 | } 44 | tests.push(TestElement { 45 | test: std::mem::take(&mut current_test), 46 | expected_output: std::mem::take(&mut current_output), 47 | span: SourceSpan::new(source_offset, current_line - start_line), 48 | }); 49 | } 50 | if current_test.is_empty() { 51 | start_line = current_line; 52 | } 53 | if !current_test.is_empty() { 54 | current_test.push('\n'); 55 | } 56 | current_test.push_str(line.trim_start_matches('>').trim()); 57 | } else if !current_test.is_empty() { 58 | if !current_output.is_empty() { 59 | current_output.push('\n'); 60 | } 61 | current_output.push_str(line); 62 | } 63 | } 64 | 65 | // Add final test if exists 66 | if !current_test.is_empty() && !current_output.is_empty() { 67 | tests.push(TestElement { 68 | test: current_test, 69 | expected_output: current_output, 70 | span: SourceSpan::new(source_offset, current_line - start_line), 71 | }); 72 | } 73 | 74 | Ok(Self { tests }) 75 | } 76 | 77 | pub async fn execute(&self) -> miette::Result<()> { 78 | for test in &self.tests { 79 | let expected = format!("{}\n", test.expected_output.clone()); 80 | 81 | TestBuilder::new() 82 | .command(&test.test) 83 | .assert_stdout(&expected) 84 | .run() 85 | .await; 86 | } 87 | 88 | Ok(()) 89 | } 90 | } 91 | 92 | #[tokio::test] 93 | async fn tests_from_files() { 94 | let test_folder = Path::new(env!("CARGO_MANIFEST_DIR")).join("test-data"); 95 | 96 | // read all files from the test folder 97 | let files = std::fs::read_dir(&test_folder).unwrap(); 98 | for file in files { 99 | let file = file.unwrap(); 100 | let path = file.path(); 101 | 102 | let tests = Tests::load_from_file(&path).unwrap(); 103 | tests.execute().await.unwrap(); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /crates/tests/test-data/arithmetic.sh: -------------------------------------------------------------------------------- 1 | # Test basic arithmetic 2 | > echo $((2 + 5)) 3 | 7 4 | 5 | > echo $((10 - 3)) 6 | 7 7 | 8 | > echo $((3 * 4)) 9 | 12 10 | 11 | > echo $((15 / 3)) 12 | 5 13 | 14 | # Test operator precedence 15 | > echo $((2 + 3 * 4)) 16 | 14 17 | 18 | > echo $(((2 + 3) * 4)) 19 | 20 20 | 21 | > echo $((4 * (2 + 3))) 22 | 20 23 | 24 | # Test with variables 25 | > export NUM=5 26 | > echo $((NUM + 3)) 27 | 8 28 | 29 | > export A=2 30 | > export B=3 31 | > echo $((A * B + 1)) 32 | 7 33 | 34 | # # Test numeric equality 35 | # > echo $((1 == 2)) 36 | # 0 37 | 38 | # > echo $((2 == 2)) 39 | # 1 40 | 41 | # > echo $((3 != 4)) 42 | # 1 43 | 44 | # # Test increment/decrement NOT IMPLEMENTED YET! 45 | # > export COUNT=1 46 | # > echo $((COUNT++)) 47 | # 1 48 | # > echo $COUNT 49 | # 2 50 | 51 | # > export X=5 52 | # > echo $((--X)) 53 | # 4 54 | 55 | # Test complex expressions 56 | > export BASE=2 57 | > echo $((BASE ** 3 + 1)) 58 | 9 59 | 60 | # Test division and modulo 61 | > echo $((10 / 3)) 62 | 3 63 | > echo $((10 % 3)) 64 | 1 -------------------------------------------------------------------------------- /crates/tests/test-data/case.sh: -------------------------------------------------------------------------------- 1 | > fruit="apple" 2 | > case "$fruit" in 3 | > "apple") 4 | > echo "You chose Apple!" 5 | > ;; 6 | > "banana") 7 | > echo "You chose Banana!" 8 | > ;; 9 | > "orange") 10 | > echo "You chose Orange!" 11 | > ;; 12 | > *) 13 | > echo "Unknown fruit!" 14 | > ;; 15 | > esac 16 | You chose Apple! 17 | 18 | 19 | > number=3 20 | > case "$number" in 21 | > 1) 22 | > echo "Number is one." 23 | > ;; 24 | > 2|3|4) 25 | > echo "Number is between two and four." 26 | > ;; 27 | > *) 28 | > echo "Number is something else." 29 | > ;; 30 | > esac 31 | Number is between two and four. 32 | 33 | 34 | > number=5 35 | > case "$number" in 36 | > 1) 37 | > echo "Number is one." 38 | > ;; 39 | > 2|3|4) 40 | > echo "Number is between two and four." 41 | > ;; 42 | > *) 43 | > echo "Number is something else." 44 | > ;; 45 | > esac 46 | Number is something else. 47 | 48 | 49 | > shape="circle" 50 | > case "$shape" in 51 | > (circle) 52 | > echo "It's a circle!" 53 | > ;; 54 | > (square) 55 | > echo "It's a square!" 56 | > ;; 57 | > *) 58 | > echo "Unknown shape!" 59 | > ;; 60 | > esac 61 | It's a circle! 62 | 63 | > filename="document.png" 64 | > case "$filename" in 65 | > (*.txt) 66 | > echo "This is a text file." 67 | > ;; 68 | > (*.jpg|*.png) 69 | > echo "This is an image file." 70 | > ;; 71 | > (*) 72 | > echo "Unknown file type." 73 | > ;; 74 | > esac 75 | This is an image file. 76 | 77 | 78 | > tempname="document.txt" 79 | > filename="tempname" 80 | > case "$filename" in 81 | > (tempname) 82 | > echo "This is a tempname." 83 | > ;; 84 | > (*.jpg|*.png) 85 | > echo "This is an image file." 86 | > ;; 87 | > (*) 88 | > echo "Unknown file type." 89 | > ;; 90 | > esac 91 | This is a tempname. 92 | 93 | > letter="c" 94 | > case "$letter" in 95 | > ([a-c]) 96 | > echo "Letter is between A and C." 97 | > ;; 98 | > ([d-f]) 99 | > echo "Letter is between D and F." 100 | > ;; 101 | > (*) 102 | > echo "Unknown letter." 103 | > ;; 104 | > esac 105 | Letter is between A and C. 106 | -------------------------------------------------------------------------------- /crates/tests/test-data/conditions.sh: -------------------------------------------------------------------------------- 1 | # Test string equality 2 | > if [[ "hello" == "hello"]]; then echo true; else echo false; fi 3 | true 4 | 5 | > if [[ "hello" == "world"]]; then echo true; else echo false; fi 6 | false 7 | 8 | # Test string starts/ends with (does not work yet) 9 | > if [[ "hello world" == hello* ]]; then echo true; else echo false; fi 10 | true 11 | 12 | > if [[ "hello world" == *world ]]; then echo true; else echo false; fi 13 | true 14 | 15 | # should not match because glob is quoted 16 | > if [[ "hello world" == "*world" ]]; then echo true; else echo false; fi 17 | false 18 | 19 | > if [[ "*world" == "*world" ]]; then echo true; else echo false; fi 20 | true 21 | 22 | # Test more complex glob patterns 23 | > if [[ "hello.txt" == *.txt ]]; then echo true; else echo false; fi 24 | true 25 | 26 | > if [[ "hello.txt" == h*.txt ]]; then echo true; else echo false; fi 27 | true 28 | 29 | > if [[ "hello.txt" == h??lo.txt ]]; then echo true; else echo false; fi 30 | true 31 | 32 | # Test multiple wildcards 33 | > if [[ "hello world test" == h*d* ]]; then echo true; else echo false; fi 34 | true 35 | 36 | > if [[ "abc123xyz" == *123* ]]; then echo true; else echo false; fi 37 | true 38 | 39 | # Test pattern at start/middle/end 40 | > if [[ "testing123" == test* ]]; then echo true; else echo false; fi 41 | true 42 | 43 | > if [[ "testing123" == *ing* ]]; then echo true; else echo false; fi 44 | true 45 | 46 | > if [[ "testing123" == *123 ]]; then echo true; else echo false; fi 47 | true 48 | 49 | # Test exact matches with wildcards present 50 | > if [[ "*star" == "*star" ]]; then echo true; else echo false; fi 51 | true 52 | 53 | > if [[ "star*" == "star*" ]]; then echo true; else echo false; fi 54 | true 55 | 56 | # Test empty strings with patterns 57 | > if [[ "" == * ]]; then echo true; else echo false; fi 58 | true 59 | 60 | > if [[ "" == ** ]]; then echo true; else echo false; fi 61 | true 62 | 63 | # Test quoted vs unquoted patterns 64 | > if [[ "star*star" == "star*star" ]]; then echo true; else echo false; fi 65 | true 66 | 67 | > if [[ "star*star" == star*star ]]; then echo true; else echo false; fi 68 | true 69 | 70 | # Test mixed literal and pattern matching 71 | > if [[ "hello.txt.old" == hello.*old ]]; then echo true; else echo false; fi 72 | true 73 | 74 | > if [[ "config.2024.json" == config.*.json ]]; then echo true; else echo false; fi 75 | true 76 | 77 | # Test case sensitivity 78 | > if [[ "HELLO.txt" == hello.* ]]; then echo true; else echo false; fi 79 | false 80 | 81 | > if [[ "Hello.TXT" == *.txt ]]; then echo true; else echo false; fi 82 | false 83 | 84 | # Test numeric comparisons 85 | > if [[ 5 -gt 3 ]]; then echo true; else echo false; fi 86 | true 87 | 88 | > if [[ 2 -lt 1 ]]; then echo true; else echo false; fi 89 | false 90 | 91 | # Test empty strings 92 | > if [[ -z "" ]]; then echo true; else echo false; fi 93 | true 94 | 95 | > if [[ -n "hello" ]]; then echo true; else echo false; fi 96 | true 97 | 98 | # Test file existence (works locally) 99 | # > touch testfile 100 | # > if [[ -f testfile ]]; then echo true; else echo false; fi 101 | # true 102 | 103 | # works, but only on Unix systems 104 | # > if [[ -d /tmp ]]; then echo true; else echo false; fi 105 | # true 106 | 107 | # Test variable existence 108 | > if [[ -v PATH ]]; then echo true; else echo false; fi 109 | true 110 | 111 | > if [[ -v NONEXISTENT ]]; then echo true; else echo false; fi 112 | false 113 | 114 | # # Test AND/OR conditions 115 | # > if [[ 1 == 1 && 2 == 2 ]]; then echo true; else echo false; fi 116 | # true 117 | 118 | # > if [[ 1 -eq 2 || 2 -eq 2 ]]; then echo true; else echo false; fi 119 | # true -------------------------------------------------------------------------------- /crates/tests/test-data/echo.sh: -------------------------------------------------------------------------------- 1 | # Test echoing 2 | > echo "foobar" 3 | foobar 4 | 5 | > echo "foobar" "bazbar" 6 | foobar bazbar 7 | 8 | > echo "foobar" bazbar 9 | foobar bazbar 10 | 11 | > export FOOBAR="foobar" 12 | > echo "${FOOBAR:-}" 13 | foobar 14 | 15 | > export FOOBAR="foobar" 16 | > echo "${FOOBAR:-}" "${OTHER:-defaultbar}" 17 | foobar defaultbar 18 | 19 | > FOOBAR="foobar" 20 | > echo "${FOOBAR}" 21 | > echo $FOOBAR 22 | foobar 23 | foobar -------------------------------------------------------------------------------- /crates/tests/test-data/loop.sh: -------------------------------------------------------------------------------- 1 | > for x in 1 2 3; do 2 | > echo $x 3 | > done 4 | 1 5 | 2 6 | 3 7 | 8 | > for item in apple banana orange; do 9 | > echo "Current fruit: $item" 10 | > done 11 | Current fruit: apple 12 | Current fruit: banana 13 | Current fruit: orange 14 | 15 | > for item in "apple banana orange"; do 16 | > echo "Current fruit: $item" 17 | > done 18 | Current fruit: apple banana orange 19 | 20 | # test single line for loop 21 | > for item in "apple" "banana" "orange"; do echo "Current fruit: $item"; done 22 | Current fruit: apple 23 | Current fruit: banana 24 | Current fruit: orange 25 | 26 | > for item in a b c 27 | > do 28 | > echo "Current letter: $item" 29 | > done 30 | Current letter: a 31 | Current letter: b 32 | Current letter: c 33 | 34 | > i=0; while [[ $i -lt 5 ]]; do echo "Number: $i"; i=$((i+1)); done 35 | Number: 0 36 | Number: 1 37 | Number: 2 38 | Number: 3 39 | Number: 4 40 | 41 | > i=0; until [[ $i -gt 5 ]]; do echo "Number: $i"; i=$((i+1)); done 42 | Number: 0 43 | Number: 1 44 | Number: 2 45 | Number: 3 46 | Number: 4 47 | Number: 5 48 | 49 | # > for i in {1..5}; do echo $i; done 50 | # 1 51 | # 2 52 | # 3 53 | # 4 54 | # 5 -------------------------------------------------------------------------------- /crates/tests/test-data/source.sh: -------------------------------------------------------------------------------- 1 | > export FOO='TESTVALUE' && source $CARGO_MANIFEST_DIR/../../scripts/exit.sh && echo $FOO 2 | 3 | -------------------------------------------------------------------------------- /crates/tests/test-data/variable_expansion.sh: -------------------------------------------------------------------------------- 1 | # Basic variable expansion 2 | > export FOO="hello" 3 | > echo "${FOO}" 4 | hello 5 | 6 | # Default value tests 7 | > unset UNSET_VAR 8 | > echo "${UNSET_VAR:-default}" 9 | default 10 | > echo "${EMPTY_VAR:-not empty}" 11 | not empty 12 | 13 | # Alternate value tests 14 | > export SET_VAR="value" 15 | > echo "${SET_VAR:+alternate}" 16 | alternate 17 | > echo "${UNSET_VAR:+alternate}" 18 | %empty 19 | 20 | # Assign default tests 21 | > echo "${ASSIGN_VAR:=default_value}" 22 | > echo "$ASSIGN_VAR" 23 | default_value 24 | default_value 25 | 26 | # Substring operations 27 | > export LONG="Hello World" 28 | > echo "${LONG:6}" 29 | > echo "${LONG:0:5}" 30 | World 31 | Hello 32 | 33 | # Empty vs unset 34 | # > export EMPTY="" 35 | # > echo "${EMPTY:-default}" 36 | # default 37 | 38 | # > export EMPTY="" 39 | # > echo "${EMPTY-default}" 40 | # %empty 41 | 42 | # > unset EMPTY 43 | # > echo "${EMPTY-default}" 44 | # default 45 | 46 | # > unset EMPTY 47 | # > echo "${EMPTY-default}" 48 | # default 49 | 50 | # Multiple substitutions 51 | > unset VAR1 VAR2 52 | > echo "${VAR1:-one} ${VAR2:-two}" 53 | one two 54 | 55 | # # Complex defaults 56 | # > echo "${UNDEFINED:-$(echo complex)}" 57 | # complex 58 | 59 | # Escape sequences in expansion 60 | > echo "${FOO:-a\}b}" 61 | a}b 62 | 63 | # # Error cases 64 | # > echo "${UNDEFINED?error message}" 65 | # error message 66 | 67 | > export VERSION="1.2.3" 68 | > echo "Version: ${VERSION:2}" 69 | Version: 2.3 -------------------------------------------------------------------------------- /scripts/arithmetic.sh: -------------------------------------------------------------------------------- 1 | echo $((2 ** 3)) -------------------------------------------------------------------------------- /scripts/case_1.sh: -------------------------------------------------------------------------------- 1 | fruit="apple" 2 | 3 | case "$fruit" in 4 | "apple") 5 | echo "You chose Apple!" 6 | ;; 7 | "banana") 8 | echo "You chose Banana!" 9 | ;; 10 | "orange") 11 | echo "You chose Orange!" 12 | ;; 13 | *) 14 | echo "Unknown fruit!" 15 | ;; 16 | esac 17 | -------------------------------------------------------------------------------- /scripts/exit.sh: -------------------------------------------------------------------------------- 1 | echo "Hello World" 2 | exit -------------------------------------------------------------------------------- /scripts/exit_code.sh: -------------------------------------------------------------------------------- 1 | echo $? -------------------------------------------------------------------------------- /scripts/for_loop.sh: -------------------------------------------------------------------------------- 1 | # for i in {1..10}; do 2 | # echo $i 3 | # done 4 | 5 | 6 | for i in $(seq 1 2 20); do 7 | echo $i 8 | done 9 | 10 | 11 | # for i in {1..10..2}; do 12 | # echo $i 13 | # done 14 | 15 | 16 | # for i in $(1,2,3,4,5); do 17 | # echo $i 18 | # done 19 | 20 | 21 | for i in $(ls); do 22 | printf "%s\n" "$i" 23 | done -------------------------------------------------------------------------------- /scripts/hello_world.sh: -------------------------------------------------------------------------------- 1 | echo "Hello World!" -------------------------------------------------------------------------------- /scripts/if_else.sh: -------------------------------------------------------------------------------- 1 | FOO=2 2 | if [[ $FOO -eq 1 ]]; 3 | then 4 | echo "FOO is 1"; 5 | elif [[ $FOO -eq 2 ]]; 6 | then 7 | echo "FOO is 2"; 8 | else 9 | echo "FOO is not 1 or 2"; 10 | fi 11 | 12 | FOO=2 13 | if [[ $FOO -eq 1 ]]; then 14 | echo "FOO is 1" 15 | elif [[ $FOO -eq 2 ]]; then 16 | echo "FOO is 2" 17 | else 18 | echo "FOO is not 1 or 2" 19 | fi 20 | 21 | FOO=2 22 | if [[ $FOO -eq 1 ]]; 23 | then 24 | echo "FOO is 1"; 25 | elif [[ $FOO -eq 2 ]]; 26 | then 27 | echo "FOO is 2"; 28 | else 29 | echo "FOO is not 1 or 2"; 30 | fi 31 | 32 | FOO=2 33 | if [[ $FOO -eq 1 ]] 34 | then 35 | echo "FOO is 1"; 36 | elif [[ $FOO -eq 2 ]] 37 | then 38 | echo "FOO is 2"; 39 | else 40 | echo "FOO is not 1 or 2"; 41 | fi 42 | 43 | 44 | if test -n "${xml_catalog_files_libxml2:-}"; then 45 | export XML_CATALOG_FILES="${xml_catalog_files_libxml2}" 46 | else 47 | unset XML_CATALOG_FILES 48 | fi 49 | unset xml_catalog_files_libxml2 -------------------------------------------------------------------------------- /scripts/script_1.sh: -------------------------------------------------------------------------------- 1 | echo "Hello World" -------------------------------------------------------------------------------- /scripts/script_2.sh: -------------------------------------------------------------------------------- 1 | echo "Hello, world" | grep "Hello" > output.txt && cat output.txt -------------------------------------------------------------------------------- /scripts/script_3.sh: -------------------------------------------------------------------------------- 1 | echo $PATH 2 | echo "Hello, world" -------------------------------------------------------------------------------- /scripts/script_4.sh: -------------------------------------------------------------------------------- 1 | ls; 2 | cat Cargo.toml -------------------------------------------------------------------------------- /scripts/script_5.sh: -------------------------------------------------------------------------------- 1 | TEST=1 2 | echo $TEST 3 | -------------------------------------------------------------------------------- /scripts/script_6.sh: -------------------------------------------------------------------------------- 1 | echo "foo" > out.txt -------------------------------------------------------------------------------- /scripts/source.sh: -------------------------------------------------------------------------------- 1 | echo $PATH > ~/output.txt 2 | -------------------------------------------------------------------------------- /scripts/tilde_expansion.sh: -------------------------------------------------------------------------------- 1 | ls ~/Desktop 2 | 3 | echo ~$var 4 | 5 | echo ~\/bin 6 | 7 | echo ~parsabahraminejad/Desktop 8 | -------------------------------------------------------------------------------- /scripts/while_loop.sh: -------------------------------------------------------------------------------- 1 | COUNTER=-5 2 | while [[ $COUNTER -lt 5 ]]; do 3 | echo The counter is $COUNTER 4 | let COUNTER=COUNTER+1 5 | done -------------------------------------------------------------------------------- /typos.toml: -------------------------------------------------------------------------------- 1 | [files] 2 | extend-exclude = [ 3 | ".git/", 4 | ".pixi/", 5 | "**/*.snap", 6 | ] 7 | ignore-hidden = false 8 | --------------------------------------------------------------------------------