├── .github ├── dependabot.yml └── workflows │ └── test.yaml ├── .gitignore ├── CODE_STYLE_GUIDE.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── rust-toolchain ├── src ├── cli.rs ├── error.rs ├── lib.rs ├── main.rs ├── passed_through.rs ├── shell_quoted.rs ├── utils.rs └── workspace.rs └── tests ├── fixtures ├── append-script-args │ ├── list-args.sh │ ├── package.json │ └── stdout.txt └── list-script-names │ ├── package.json │ └── stdout.txt └── test_main.rs /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | open-pull-requests-limit: 10 8 | labels: 9 | - dependabot 10 | - github-actions 11 | - package-ecosystem: cargo 12 | directory: "/" 13 | schedule: 14 | interval: weekly 15 | open-pull-requests-limit: 10 16 | labels: 17 | - dependencies 18 | - dependabot 19 | - rust 20 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | test: 9 | name: Test 10 | 11 | runs-on: ${{ matrix.os }} 12 | 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | os: 17 | - ubuntu-latest 18 | - windows-latest 19 | - macos-latest 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | 24 | - name: Cache 25 | uses: actions/cache@v3.3.2 26 | timeout-minutes: 1 27 | continue-on-error: true 28 | if: matrix.os != 'macos-latest' # Cache causes errors on macOS 29 | with: 30 | path: | 31 | ~/.cargo/registry 32 | ~/.cargo/git 33 | target 34 | key: ${{ github.job }}-${{ runner.os }}-${{ hashFiles('rust-toolchain') }}-${{ hashFiles('**/Cargo.lock') }} 35 | restore-keys: | 36 | ${{ github.job }}-${{ runner.os }}-${{ hashFiles('rust-toolchain') }}-${{ hashFiles('**/Cargo.lock') }} 37 | ${{ github.job }}-${{ runner.os }}-${{ hashFiles('rust-toolchain') }}- 38 | 39 | - uses: actions-rs/toolchain@v1 40 | with: 41 | profile: minimal 42 | override: 'true' 43 | default: 'true' 44 | 45 | - name: Build 46 | run: cargo build --locked 47 | 48 | - name: Test 49 | run: cargo test 50 | 51 | clippy_check: 52 | name: Clippy 53 | 54 | runs-on: ${{ matrix.os }} 55 | 56 | strategy: 57 | matrix: 58 | os: 59 | - ubuntu-latest 60 | 61 | steps: 62 | - uses: actions/checkout@v4 63 | 64 | - name: Cache 65 | uses: actions/cache@v3.3.2 66 | timeout-minutes: 1 67 | continue-on-error: true 68 | with: 69 | path: | 70 | ~/.cargo/registry 71 | ~/.cargo/git 72 | target 73 | key: ${{ github.job }}-${{ runner.os }}-${{ hashFiles('rust-toolchain') }}-${{ hashFiles('**/Cargo.lock') }} 74 | restore-keys: | 75 | ${{ github.job }}-${{ runner.os }}-${{ hashFiles('rust-toolchain') }}-${{ hashFiles('**/Cargo.lock') }} 76 | ${{ github.job }}-${{ runner.os }}-${{ hashFiles('rust-toolchain') }}- 77 | 78 | - uses: actions-rs/toolchain@v1.0.6 79 | with: 80 | profile: minimal 81 | components: clippy 82 | override: 'true' 83 | default: 'true' 84 | 85 | - name: Use clippy to lint code 86 | run: cargo clippy -- -D warnings 87 | 88 | fmt_check: 89 | name: Fmt 90 | 91 | runs-on: ${{ matrix.os }} 92 | 93 | strategy: 94 | matrix: 95 | os: 96 | - ubuntu-latest 97 | 98 | steps: 99 | - uses: actions/checkout@v4 100 | 101 | - uses: actions-rs/toolchain@v1.0.6 102 | with: 103 | profile: minimal 104 | components: rustfmt 105 | override: 'true' 106 | default: 'true' 107 | 108 | - name: Check code formatting 109 | run: cargo fmt -- --check 110 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | -------------------------------------------------------------------------------- /CODE_STYLE_GUIDE.md: -------------------------------------------------------------------------------- 1 | # Code Style Guide 2 | 3 | ## Introduction 4 | 5 | Clippy cannot yet detect all suboptimal code. This guide supplements that. 6 | 7 | This guide is incomplete. More may be added as more pull requests are going to be reviewed. 8 | 9 | This is a guide, not a rule. Contributors may break them if they have a good reason to do so. 10 | 11 | ## Terminology 12 | 13 | [owned]: #owned-type 14 | [borrowed]: #borrowed-type 15 | [copying]: #copying 16 | 17 | ### Owned type 18 | 19 | Doesn't have a lifetime, neither implicit nor explicit. 20 | 21 | *Examples:* `String`, `OsString`, `PathBuf`, `Vec`, etc. 22 | 23 | ### Borrowed type 24 | 25 | Has a lifetime, either implicit or explicit. 26 | 27 | *Examples:* `&str`, `&OsStr`, `&Path`, `&[T]`, etc. 28 | 29 | ### Copying 30 | 31 | The act of cloning or creating an owned data from another owned/borrowed data. 32 | 33 | *Examples:* 34 | * `owned_data.clone()` 35 | * `borrowed_data.to_owned()` 36 | * `OwnedType::from(borrowed_data)` 37 | * `path.to_path_buf()` 38 | * `str.to_string()` 39 | * etc. 40 | 41 | ## Guides 42 | 43 | ### When to use [owned] parameter? When to use [borrowed] parameter? 44 | 45 | This is a trade-off between API flexibility and performance. 46 | 47 | If using an [owned] signature would reduce [copying], one should use an [owned] signature. 48 | 49 | Otherwise, use a [borrowed] signature to widen the API surface. 50 | 51 | **Example 1:** Preferring [owned] signature. 52 | 53 | ```rust 54 | fn push_path(list: &mut Vec, item: &Path) { 55 | list.push(item.to_path_buf()); 56 | } 57 | 58 | push_path(my_list, &my_path_buf); 59 | push_path(my_list, my_path_ref); 60 | ``` 61 | 62 | The above code is suboptimal because it forces the [copying] of `my_path_buf` even though the type of `my_path_buf` is already `PathBuf`. 63 | 64 | Changing the signature of `item` to `PathBuf` would help remove `.to_path_buf()` inside the `push_back` function, eliminate the cloning of `my_path_buf` (the ownership of `my_path_buf` is transferred to `push_path`). 65 | 66 | ```rust 67 | fn push_path(list: &mut Vec, item: PathBuf) { 68 | list.push(item); 69 | } 70 | 71 | push_path(my_list, my_path_buf); 72 | push_path(my_list, my_path_ref.to_path_buf()); 73 | ``` 74 | 75 | It does force `my_path_ref` to be explicitly copied, but since `item` is not copied, the total number of copying remains the same for `my_path_ref`. 76 | 77 | **Example 2:** Preferring [borrowed] signature. 78 | 79 | ```rust 80 | fn show_path(path: PathBuf) { 81 | println!("The path is {path:?}"); 82 | } 83 | 84 | show_path(my_path_buf); 85 | show_path(my_path_ref.to_path_buf()); 86 | ``` 87 | 88 | The above code is suboptimal because it forces the [copying] of `my_path_ref` even though a `&Path` is already compatible with the code inside the function. 89 | 90 | Changing the signature of `path` to `&Path` would help remove `.to_path_buf()`, eliminating the unnecessary copying: 91 | 92 | ```rust 93 | fn show_path(path: &Path) { 94 | println!("The path is {path:?}"); 95 | } 96 | 97 | show_path(my_path_buf); 98 | show_path(my_path_ref); 99 | ``` 100 | 101 | ### Use the most encompassing type for function parameters 102 | 103 | The goal is to allow the function to accept more types of parameters, reducing type conversion. 104 | 105 | **Example 1:** 106 | 107 | ```rust 108 | fn node_bin_dir(workspace: &PathBuf) -> PathBuf { 109 | workspace.join("node_modules").join(".bin") 110 | } 111 | 112 | let a = node_bin_dir(&my_path_buf); 113 | let b = node_bin_dir(&my_path_ref.to_path_buf()); 114 | ``` 115 | 116 | The above code is suboptimal because it forces the [copying] of `my_path_ref` only to be used as a reference. 117 | 118 | Changing the signature of `workspace` to `&Path` would help remove `.to_path_buf()`, eliminating the unnecessary copying: 119 | 120 | ```rust 121 | fn node_bin_dir(workspace: &Path) -> PathBuf { 122 | workspace.join("node_modules").join(".bin") 123 | } 124 | 125 | let a = node_bin_dir(&my_path_buf); 126 | let b = node_bin_dir(my_path_ref); 127 | ``` 128 | 129 | ### When or when not to log during tests? What to log? How to log? 130 | 131 | The goal is to enable the programmer to quickly inspect the test subject should a test fails. 132 | 133 | Logging is almost always necessary when the assertion is not `assert_eq!`. For example: `assert!`, `assert_ne!`, etc. 134 | 135 | Logging is sometimes necessary when the assertion is `assert_eq!`. 136 | 137 | If the values being compared with `assert_eq!` are simple scalar or single line strings, logging is almost never necessary. It is because `assert_eq!` should already show both values when assertion fails. 138 | 139 | If the values being compared with `assert_eq!` are strings that may have many lines, they should be logged with `eprintln!` and `{}` format. 140 | 141 | If the values being compared with `assert_eq!` have complex structures (such as a struct or an array), they should be logged with `dbg!`. 142 | 143 | **Example 1:** Logging before assertion is necessary 144 | 145 | ```rust 146 | let message = my_func().unwrap_err().to_string(); 147 | eprintln!("MESSAGE:\n{message}\n"); 148 | assert!(message.contains("expected segment")); 149 | ``` 150 | 151 | ```rust 152 | let output = execute_my_command(); 153 | let received = output.stdout.to_string_lossy(); // could have multiple lines 154 | eprintln!("STDOUT:\n{received}\n"); 155 | assert_eq!(received, expected) 156 | ``` 157 | 158 | ```rust 159 | let hash_map = create_map(my_argument); 160 | dbg!(&hash_map); 161 | assert!(hash_map.contains_key("foo")); 162 | assert!(hash_map.contains_key("bar")); 163 | ``` 164 | 165 | **Example 2:** Logging is unnecessary 166 | 167 | ```rust 168 | let received = add(2, 3); 169 | assert_eq!(received, 5); 170 | ``` 171 | 172 | If the assertion fails, the value of `received` will appear alongside the error message. 173 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "anstream" 7 | version = "0.6.15" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "64e15c1ab1f89faffbf04a634d5e1962e9074f2741eef6d97f3c4e322426d526" 10 | dependencies = [ 11 | "anstyle", 12 | "anstyle-parse", 13 | "anstyle-query", 14 | "anstyle-wincon", 15 | "colorchoice", 16 | "is_terminal_polyfill", 17 | "utf8parse", 18 | ] 19 | 20 | [[package]] 21 | name = "anstyle" 22 | version = "1.0.8" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "1bec1de6f59aedf83baf9ff929c98f2ad654b97c9510f4e70cf6f661d49fd5b1" 25 | 26 | [[package]] 27 | name = "anstyle-parse" 28 | version = "0.2.5" 29 | source = "registry+https://github.com/rust-lang/crates.io-index" 30 | checksum = "eb47de1e80c2b463c735db5b217a0ddc39d612e7ac9e2e96a5aed1f57616c1cb" 31 | dependencies = [ 32 | "utf8parse", 33 | ] 34 | 35 | [[package]] 36 | name = "anstyle-query" 37 | version = "1.1.1" 38 | source = "registry+https://github.com/rust-lang/crates.io-index" 39 | checksum = "6d36fc52c7f6c869915e99412912f22093507da8d9e942ceaf66fe4b7c14422a" 40 | dependencies = [ 41 | "windows-sys 0.52.0", 42 | ] 43 | 44 | [[package]] 45 | name = "anstyle-wincon" 46 | version = "3.0.4" 47 | source = "registry+https://github.com/rust-lang/crates.io-index" 48 | checksum = "5bf74e1b6e971609db8ca7a9ce79fd5768ab6ae46441c572e46cf596f59e57f8" 49 | dependencies = [ 50 | "anstyle", 51 | "windows-sys 0.52.0", 52 | ] 53 | 54 | [[package]] 55 | name = "assert_cmd" 56 | version = "2.0.16" 57 | source = "registry+https://github.com/rust-lang/crates.io-index" 58 | checksum = "dc1835b7f27878de8525dc71410b5a31cdcc5f230aed5ba5df968e09c201b23d" 59 | dependencies = [ 60 | "anstyle", 61 | "bstr", 62 | "doc-comment", 63 | "libc", 64 | "predicates", 65 | "predicates-core", 66 | "predicates-tree", 67 | "wait-timeout", 68 | ] 69 | 70 | [[package]] 71 | name = "autocfg" 72 | version = "1.3.0" 73 | source = "registry+https://github.com/rust-lang/crates.io-index" 74 | checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" 75 | 76 | [[package]] 77 | name = "bitflags" 78 | version = "2.6.0" 79 | source = "registry+https://github.com/rust-lang/crates.io-index" 80 | checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" 81 | 82 | [[package]] 83 | name = "bstr" 84 | version = "1.10.0" 85 | source = "registry+https://github.com/rust-lang/crates.io-index" 86 | checksum = "40723b8fb387abc38f4f4a37c09073622e41dd12327033091ef8950659e6dc0c" 87 | dependencies = [ 88 | "memchr", 89 | "regex-automata", 90 | "serde", 91 | ] 92 | 93 | [[package]] 94 | name = "build-fs-tree" 95 | version = "0.7.1" 96 | source = "registry+https://github.com/rust-lang/crates.io-index" 97 | checksum = "790713a24ee86ed258a0894a564f5ffd729b9d191c4960309926561173da7cf6" 98 | dependencies = [ 99 | "derive_more", 100 | "pipe-trait", 101 | "serde", 102 | "serde_yaml", 103 | "text-block-macros", 104 | ] 105 | 106 | [[package]] 107 | name = "cfg-if" 108 | version = "1.0.0" 109 | source = "registry+https://github.com/rust-lang/crates.io-index" 110 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 111 | 112 | [[package]] 113 | name = "clap" 114 | version = "4.5.16" 115 | source = "registry+https://github.com/rust-lang/crates.io-index" 116 | checksum = "ed6719fffa43d0d87e5fd8caeab59be1554fb028cd30edc88fc4369b17971019" 117 | dependencies = [ 118 | "clap_builder", 119 | "clap_derive", 120 | ] 121 | 122 | [[package]] 123 | name = "clap_builder" 124 | version = "4.5.15" 125 | source = "registry+https://github.com/rust-lang/crates.io-index" 126 | checksum = "216aec2b177652e3846684cbfe25c9964d18ec45234f0f5da5157b207ed1aab6" 127 | dependencies = [ 128 | "anstream", 129 | "anstyle", 130 | "clap_lex", 131 | "strsim", 132 | ] 133 | 134 | [[package]] 135 | name = "clap_derive" 136 | version = "4.5.13" 137 | source = "registry+https://github.com/rust-lang/crates.io-index" 138 | checksum = "501d359d5f3dcaf6ecdeee48833ae73ec6e42723a1e52419c79abf9507eec0a0" 139 | dependencies = [ 140 | "heck", 141 | "proc-macro2", 142 | "quote", 143 | "syn", 144 | ] 145 | 146 | [[package]] 147 | name = "clap_lex" 148 | version = "0.7.2" 149 | source = "registry+https://github.com/rust-lang/crates.io-index" 150 | checksum = "1462739cb27611015575c0c11df5df7601141071f07518d56fcc1be504cbec97" 151 | 152 | [[package]] 153 | name = "colorchoice" 154 | version = "1.0.2" 155 | source = "registry+https://github.com/rust-lang/crates.io-index" 156 | checksum = "d3fd119d74b830634cea2a0f58bbd0d54540518a14397557951e79340abc28c0" 157 | 158 | [[package]] 159 | name = "derive_more" 160 | version = "1.0.0" 161 | source = "registry+https://github.com/rust-lang/crates.io-index" 162 | checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" 163 | dependencies = [ 164 | "derive_more-impl", 165 | ] 166 | 167 | [[package]] 168 | name = "derive_more-impl" 169 | version = "1.0.0" 170 | source = "registry+https://github.com/rust-lang/crates.io-index" 171 | checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" 172 | dependencies = [ 173 | "proc-macro2", 174 | "quote", 175 | "syn", 176 | "unicode-xid", 177 | ] 178 | 179 | [[package]] 180 | name = "diff" 181 | version = "0.1.13" 182 | source = "registry+https://github.com/rust-lang/crates.io-index" 183 | checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" 184 | 185 | [[package]] 186 | name = "difflib" 187 | version = "0.4.0" 188 | source = "registry+https://github.com/rust-lang/crates.io-index" 189 | checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" 190 | 191 | [[package]] 192 | name = "doc-comment" 193 | version = "0.3.3" 194 | source = "registry+https://github.com/rust-lang/crates.io-index" 195 | checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" 196 | 197 | [[package]] 198 | name = "dunce" 199 | version = "1.0.5" 200 | source = "registry+https://github.com/rust-lang/crates.io-index" 201 | checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" 202 | 203 | [[package]] 204 | name = "equivalent" 205 | version = "1.0.1" 206 | source = "registry+https://github.com/rust-lang/crates.io-index" 207 | checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" 208 | 209 | [[package]] 210 | name = "errno" 211 | version = "0.3.9" 212 | source = "registry+https://github.com/rust-lang/crates.io-index" 213 | checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" 214 | dependencies = [ 215 | "libc", 216 | "windows-sys 0.52.0", 217 | ] 218 | 219 | [[package]] 220 | name = "fastrand" 221 | version = "2.1.0" 222 | source = "registry+https://github.com/rust-lang/crates.io-index" 223 | checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a" 224 | 225 | [[package]] 226 | name = "hashbrown" 227 | version = "0.12.3" 228 | source = "registry+https://github.com/rust-lang/crates.io-index" 229 | checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" 230 | 231 | [[package]] 232 | name = "hashbrown" 233 | version = "0.14.5" 234 | source = "registry+https://github.com/rust-lang/crates.io-index" 235 | checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" 236 | 237 | [[package]] 238 | name = "heck" 239 | version = "0.5.0" 240 | source = "registry+https://github.com/rust-lang/crates.io-index" 241 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 242 | 243 | [[package]] 244 | name = "indexmap" 245 | version = "1.9.3" 246 | source = "registry+https://github.com/rust-lang/crates.io-index" 247 | checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" 248 | dependencies = [ 249 | "autocfg", 250 | "hashbrown 0.12.3", 251 | "serde", 252 | ] 253 | 254 | [[package]] 255 | name = "indexmap" 256 | version = "2.4.0" 257 | source = "registry+https://github.com/rust-lang/crates.io-index" 258 | checksum = "93ead53efc7ea8ed3cfb0c79fc8023fbb782a5432b52830b6518941cebe6505c" 259 | dependencies = [ 260 | "equivalent", 261 | "hashbrown 0.14.5", 262 | ] 263 | 264 | [[package]] 265 | name = "is_terminal_polyfill" 266 | version = "1.70.1" 267 | source = "registry+https://github.com/rust-lang/crates.io-index" 268 | checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" 269 | 270 | [[package]] 271 | name = "itoa" 272 | version = "1.0.11" 273 | source = "registry+https://github.com/rust-lang/crates.io-index" 274 | checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" 275 | 276 | [[package]] 277 | name = "lets_find_up" 278 | version = "0.0.3" 279 | source = "registry+https://github.com/rust-lang/crates.io-index" 280 | checksum = "b91a14fb0b4300e025486cc8bc096c7173c2c615ce8f9c6da7829a4af3f5afbd" 281 | 282 | [[package]] 283 | name = "libc" 284 | version = "0.2.158" 285 | source = "registry+https://github.com/rust-lang/crates.io-index" 286 | checksum = "d8adc4bb1803a324070e64a98ae98f38934d91957a99cfb3a43dcbc01bc56439" 287 | 288 | [[package]] 289 | name = "linux-raw-sys" 290 | version = "0.4.14" 291 | source = "registry+https://github.com/rust-lang/crates.io-index" 292 | checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" 293 | 294 | [[package]] 295 | name = "memchr" 296 | version = "2.7.4" 297 | source = "registry+https://github.com/rust-lang/crates.io-index" 298 | checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" 299 | 300 | [[package]] 301 | name = "once_cell" 302 | version = "1.19.0" 303 | source = "registry+https://github.com/rust-lang/crates.io-index" 304 | checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" 305 | 306 | [[package]] 307 | name = "os_display" 308 | version = "0.1.3" 309 | source = "registry+https://github.com/rust-lang/crates.io-index" 310 | checksum = "7a6229bad892b46b0dcfaaeb18ad0d2e56400f5aaea05b768bde96e73676cf75" 311 | dependencies = [ 312 | "unicode-width", 313 | ] 314 | 315 | [[package]] 316 | name = "phf" 317 | version = "0.11.2" 318 | source = "registry+https://github.com/rust-lang/crates.io-index" 319 | checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc" 320 | dependencies = [ 321 | "phf_macros", 322 | "phf_shared", 323 | ] 324 | 325 | [[package]] 326 | name = "phf_generator" 327 | version = "0.11.2" 328 | source = "registry+https://github.com/rust-lang/crates.io-index" 329 | checksum = "48e4cc64c2ad9ebe670cb8fd69dd50ae301650392e81c05f9bfcb2d5bdbc24b0" 330 | dependencies = [ 331 | "phf_shared", 332 | "rand", 333 | ] 334 | 335 | [[package]] 336 | name = "phf_macros" 337 | version = "0.11.2" 338 | source = "registry+https://github.com/rust-lang/crates.io-index" 339 | checksum = "3444646e286606587e49f3bcf1679b8cef1dc2c5ecc29ddacaffc305180d464b" 340 | dependencies = [ 341 | "phf_generator", 342 | "phf_shared", 343 | "proc-macro2", 344 | "quote", 345 | "syn", 346 | ] 347 | 348 | [[package]] 349 | name = "phf_shared" 350 | version = "0.11.2" 351 | source = "registry+https://github.com/rust-lang/crates.io-index" 352 | checksum = "90fcb95eef784c2ac79119d1dd819e162b5da872ce6f3c3abe1e8ca1c082f72b" 353 | dependencies = [ 354 | "siphasher", 355 | ] 356 | 357 | [[package]] 358 | name = "pipe-trait" 359 | version = "0.4.0" 360 | source = "registry+https://github.com/rust-lang/crates.io-index" 361 | checksum = "c1be1ec9e59f0360aefe84efa6f699198b685ab0d5718081e9f72aa2344289e2" 362 | 363 | [[package]] 364 | name = "pn" 365 | version = "0.0.0" 366 | dependencies = [ 367 | "assert_cmd", 368 | "build-fs-tree", 369 | "clap", 370 | "derive_more", 371 | "dunce", 372 | "indexmap 1.9.3", 373 | "lets_find_up", 374 | "os_display", 375 | "phf", 376 | "pipe-trait", 377 | "pretty_assertions", 378 | "serde", 379 | "serde_json", 380 | "tempfile", 381 | "yansi", 382 | ] 383 | 384 | [[package]] 385 | name = "predicates" 386 | version = "3.1.2" 387 | source = "registry+https://github.com/rust-lang/crates.io-index" 388 | checksum = "7e9086cc7640c29a356d1a29fd134380bee9d8f79a17410aa76e7ad295f42c97" 389 | dependencies = [ 390 | "anstyle", 391 | "difflib", 392 | "predicates-core", 393 | ] 394 | 395 | [[package]] 396 | name = "predicates-core" 397 | version = "1.0.8" 398 | source = "registry+https://github.com/rust-lang/crates.io-index" 399 | checksum = "ae8177bee8e75d6846599c6b9ff679ed51e882816914eec639944d7c9aa11931" 400 | 401 | [[package]] 402 | name = "predicates-tree" 403 | version = "1.0.11" 404 | source = "registry+https://github.com/rust-lang/crates.io-index" 405 | checksum = "41b740d195ed3166cd147c8047ec98db0e22ec019eb8eeb76d343b795304fb13" 406 | dependencies = [ 407 | "predicates-core", 408 | "termtree", 409 | ] 410 | 411 | [[package]] 412 | name = "pretty_assertions" 413 | version = "1.4.0" 414 | source = "registry+https://github.com/rust-lang/crates.io-index" 415 | checksum = "af7cee1a6c8a5b9208b3cb1061f10c0cb689087b3d8ce85fb9d2dd7a29b6ba66" 416 | dependencies = [ 417 | "diff", 418 | "yansi", 419 | ] 420 | 421 | [[package]] 422 | name = "proc-macro2" 423 | version = "1.0.86" 424 | source = "registry+https://github.com/rust-lang/crates.io-index" 425 | checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" 426 | dependencies = [ 427 | "unicode-ident", 428 | ] 429 | 430 | [[package]] 431 | name = "quote" 432 | version = "1.0.36" 433 | source = "registry+https://github.com/rust-lang/crates.io-index" 434 | checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" 435 | dependencies = [ 436 | "proc-macro2", 437 | ] 438 | 439 | [[package]] 440 | name = "rand" 441 | version = "0.8.5" 442 | source = "registry+https://github.com/rust-lang/crates.io-index" 443 | checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" 444 | dependencies = [ 445 | "rand_core", 446 | ] 447 | 448 | [[package]] 449 | name = "rand_core" 450 | version = "0.6.4" 451 | source = "registry+https://github.com/rust-lang/crates.io-index" 452 | checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" 453 | 454 | [[package]] 455 | name = "regex-automata" 456 | version = "0.4.7" 457 | source = "registry+https://github.com/rust-lang/crates.io-index" 458 | checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df" 459 | 460 | [[package]] 461 | name = "rustix" 462 | version = "0.38.34" 463 | source = "registry+https://github.com/rust-lang/crates.io-index" 464 | checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" 465 | dependencies = [ 466 | "bitflags", 467 | "errno", 468 | "libc", 469 | "linux-raw-sys", 470 | "windows-sys 0.52.0", 471 | ] 472 | 473 | [[package]] 474 | name = "ryu" 475 | version = "1.0.18" 476 | source = "registry+https://github.com/rust-lang/crates.io-index" 477 | checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" 478 | 479 | [[package]] 480 | name = "serde" 481 | version = "1.0.208" 482 | source = "registry+https://github.com/rust-lang/crates.io-index" 483 | checksum = "cff085d2cb684faa248efb494c39b68e522822ac0de72ccf08109abde717cfb2" 484 | dependencies = [ 485 | "serde_derive", 486 | ] 487 | 488 | [[package]] 489 | name = "serde_derive" 490 | version = "1.0.208" 491 | source = "registry+https://github.com/rust-lang/crates.io-index" 492 | checksum = "24008e81ff7613ed8e5ba0cfaf24e2c2f1e5b8a0495711e44fcd4882fca62bcf" 493 | dependencies = [ 494 | "proc-macro2", 495 | "quote", 496 | "syn", 497 | ] 498 | 499 | [[package]] 500 | name = "serde_json" 501 | version = "1.0.125" 502 | source = "registry+https://github.com/rust-lang/crates.io-index" 503 | checksum = "83c8e735a073ccf5be70aa8066aa984eaf2fa000db6c8d0100ae605b366d31ed" 504 | dependencies = [ 505 | "itoa", 506 | "memchr", 507 | "ryu", 508 | "serde", 509 | ] 510 | 511 | [[package]] 512 | name = "serde_yaml" 513 | version = "0.9.34+deprecated" 514 | source = "registry+https://github.com/rust-lang/crates.io-index" 515 | checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" 516 | dependencies = [ 517 | "indexmap 2.4.0", 518 | "itoa", 519 | "ryu", 520 | "serde", 521 | "unsafe-libyaml", 522 | ] 523 | 524 | [[package]] 525 | name = "siphasher" 526 | version = "0.3.11" 527 | source = "registry+https://github.com/rust-lang/crates.io-index" 528 | checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" 529 | 530 | [[package]] 531 | name = "strsim" 532 | version = "0.11.1" 533 | source = "registry+https://github.com/rust-lang/crates.io-index" 534 | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 535 | 536 | [[package]] 537 | name = "syn" 538 | version = "2.0.75" 539 | source = "registry+https://github.com/rust-lang/crates.io-index" 540 | checksum = "f6af063034fc1935ede7be0122941bafa9bacb949334d090b77ca98b5817c7d9" 541 | dependencies = [ 542 | "proc-macro2", 543 | "quote", 544 | "unicode-ident", 545 | ] 546 | 547 | [[package]] 548 | name = "tempfile" 549 | version = "3.12.0" 550 | source = "registry+https://github.com/rust-lang/crates.io-index" 551 | checksum = "04cbcdd0c794ebb0d4cf35e88edd2f7d2c4c3e9a5a6dab322839b321c6a87a64" 552 | dependencies = [ 553 | "cfg-if", 554 | "fastrand", 555 | "once_cell", 556 | "rustix", 557 | "windows-sys 0.59.0", 558 | ] 559 | 560 | [[package]] 561 | name = "termtree" 562 | version = "0.4.1" 563 | source = "registry+https://github.com/rust-lang/crates.io-index" 564 | checksum = "3369f5ac52d5eb6ab48c6b4ffdc8efbcad6b89c765749064ba298f2c68a16a76" 565 | 566 | [[package]] 567 | name = "text-block-macros" 568 | version = "0.1.1" 569 | source = "registry+https://github.com/rust-lang/crates.io-index" 570 | checksum = "7f8b59b4da1c1717deaf1de80f0179a9d8b4ac91c986d5fd9f4a8ff177b84049" 571 | 572 | [[package]] 573 | name = "unicode-ident" 574 | version = "1.0.12" 575 | source = "registry+https://github.com/rust-lang/crates.io-index" 576 | checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" 577 | 578 | [[package]] 579 | name = "unicode-width" 580 | version = "0.1.13" 581 | source = "registry+https://github.com/rust-lang/crates.io-index" 582 | checksum = "0336d538f7abc86d282a4189614dfaa90810dfc2c6f6427eaf88e16311dd225d" 583 | 584 | [[package]] 585 | name = "unicode-xid" 586 | version = "0.2.5" 587 | source = "registry+https://github.com/rust-lang/crates.io-index" 588 | checksum = "229730647fbc343e3a80e463c1db7f78f3855d3f3739bee0dda773c9a037c90a" 589 | 590 | [[package]] 591 | name = "unsafe-libyaml" 592 | version = "0.2.11" 593 | source = "registry+https://github.com/rust-lang/crates.io-index" 594 | checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" 595 | 596 | [[package]] 597 | name = "utf8parse" 598 | version = "0.2.2" 599 | source = "registry+https://github.com/rust-lang/crates.io-index" 600 | checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 601 | 602 | [[package]] 603 | name = "wait-timeout" 604 | version = "0.2.0" 605 | source = "registry+https://github.com/rust-lang/crates.io-index" 606 | checksum = "9f200f5b12eb75f8c1ed65abd4b2db8a6e1b138a20de009dacee265a2498f3f6" 607 | dependencies = [ 608 | "libc", 609 | ] 610 | 611 | [[package]] 612 | name = "windows-sys" 613 | version = "0.52.0" 614 | source = "registry+https://github.com/rust-lang/crates.io-index" 615 | checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" 616 | dependencies = [ 617 | "windows-targets", 618 | ] 619 | 620 | [[package]] 621 | name = "windows-sys" 622 | version = "0.59.0" 623 | source = "registry+https://github.com/rust-lang/crates.io-index" 624 | checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 625 | dependencies = [ 626 | "windows-targets", 627 | ] 628 | 629 | [[package]] 630 | name = "windows-targets" 631 | version = "0.52.6" 632 | source = "registry+https://github.com/rust-lang/crates.io-index" 633 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 634 | dependencies = [ 635 | "windows_aarch64_gnullvm", 636 | "windows_aarch64_msvc", 637 | "windows_i686_gnu", 638 | "windows_i686_gnullvm", 639 | "windows_i686_msvc", 640 | "windows_x86_64_gnu", 641 | "windows_x86_64_gnullvm", 642 | "windows_x86_64_msvc", 643 | ] 644 | 645 | [[package]] 646 | name = "windows_aarch64_gnullvm" 647 | version = "0.52.6" 648 | source = "registry+https://github.com/rust-lang/crates.io-index" 649 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 650 | 651 | [[package]] 652 | name = "windows_aarch64_msvc" 653 | version = "0.52.6" 654 | source = "registry+https://github.com/rust-lang/crates.io-index" 655 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 656 | 657 | [[package]] 658 | name = "windows_i686_gnu" 659 | version = "0.52.6" 660 | source = "registry+https://github.com/rust-lang/crates.io-index" 661 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 662 | 663 | [[package]] 664 | name = "windows_i686_gnullvm" 665 | version = "0.52.6" 666 | source = "registry+https://github.com/rust-lang/crates.io-index" 667 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 668 | 669 | [[package]] 670 | name = "windows_i686_msvc" 671 | version = "0.52.6" 672 | source = "registry+https://github.com/rust-lang/crates.io-index" 673 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 674 | 675 | [[package]] 676 | name = "windows_x86_64_gnu" 677 | version = "0.52.6" 678 | source = "registry+https://github.com/rust-lang/crates.io-index" 679 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 680 | 681 | [[package]] 682 | name = "windows_x86_64_gnullvm" 683 | version = "0.52.6" 684 | source = "registry+https://github.com/rust-lang/crates.io-index" 685 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 686 | 687 | [[package]] 688 | name = "windows_x86_64_msvc" 689 | version = "0.52.6" 690 | source = "registry+https://github.com/rust-lang/crates.io-index" 691 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 692 | 693 | [[package]] 694 | name = "yansi" 695 | version = "0.5.1" 696 | source = "registry+https://github.com/rust-lang/crates.io-index" 697 | checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec" 698 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "pn" 3 | version = "0.0.0" 4 | authors = ["Zoltan Kochan "] 5 | edition = "2021" 6 | 7 | [dependencies] 8 | serde_json = "1.0" 9 | serde = { version = "1.0", features = ["derive"] } 10 | indexmap = { version = "1.9.3", features = ["serde"] } 11 | yansi = "0.5.1" 12 | derive_more = { version = "1.0", features = ["display", "from", "into"] } 13 | pipe-trait = "0.4.0" 14 | clap = { version = "4.3.2", features = ["derive"] } 15 | lets_find_up = "0.0.3" 16 | dunce = "1.0.4" 17 | phf = { version = "0.11.2", features = ["macros" ]} 18 | os_display = { version = "0.1.3", features = ["unix", "windows"] } 19 | 20 | [dev-dependencies] 21 | assert_cmd = "2.0.5" 22 | build-fs-tree = "0.7.1" 23 | tempfile = "3.5.0" 24 | pretty_assertions = "1.3.0" 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021-2023 pnpm contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, 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, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pn 2 | 3 | This is an experimental wrapper over the pnpm CLI that aims to make pnpm faster, see [related discussion](https://github.com/pnpm/pnpm/discussions/3419). 4 | 5 | ## Development 6 | 7 | Compile: 8 | 9 | ``` 10 | cargo build 11 | ``` 12 | 13 | Run the compiled bin: 14 | 15 | ``` 16 | ./target/debug/pn --help 17 | ``` 18 | 19 | ## License 20 | 21 | MIT 22 | -------------------------------------------------------------------------------- /rust-toolchain: -------------------------------------------------------------------------------- 1 | 1.80.0 2 | -------------------------------------------------------------------------------- /src/cli.rs: -------------------------------------------------------------------------------- 1 | use clap::*; 2 | 3 | #[derive(Debug, Parser)] 4 | #[clap(author, version, about, rename_all = "kebab-case")] 5 | pub struct Cli { 6 | /// Run the command on the root workspace project. 7 | #[clap(short, long)] 8 | pub workspace_root: bool, 9 | /// Command to execute. 10 | #[clap(subcommand)] 11 | pub command: Command, 12 | } 13 | 14 | #[derive(Debug, Subcommand)] 15 | #[clap(rename_all = "kebab-case")] 16 | pub enum Command { 17 | /// Runs a defined package script. 18 | #[clap(alias = "run-script")] 19 | Run(RunArgs), 20 | /// Execute a shell command in scope of a project. 21 | #[clap(external_subcommand)] 22 | Other(Vec), 23 | } 24 | 25 | /// Runs a defined package script. 26 | #[derive(Debug, Args)] 27 | #[clap(rename_all = "kebab-case")] 28 | pub struct RunArgs { 29 | /// Name of the package script to run. 30 | pub script: Option, // Not OsString because it would be compared against package.json#scripts 31 | 32 | /// Arguments to pass to the package script. 33 | pub args: Vec, 34 | } 35 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | use crate::shell_quoted::ShellQuoted; 2 | use derive_more::{Display, From}; 3 | use std::{env::JoinPathsError, io, num::NonZeroI32, path::PathBuf}; 4 | 5 | /// Error types emitted by `pn` itself. 6 | #[derive(Debug, Display)] 7 | pub enum PnError { 8 | /// Script not found when running `pn run`. 9 | #[display("Missing script: {name}")] 10 | MissingScript { name: String }, 11 | 12 | /// Script ran by `pn run` exits with non-zero status code. 13 | #[display("Command failed with exit code {status}")] 14 | ScriptError { name: String, status: NonZeroI32 }, 15 | 16 | /// Subprocess finishes but without a status code. 17 | #[display("Command ended unexpectedly: {command}")] 18 | UnexpectedTermination { command: ShellQuoted }, 19 | 20 | /// Fail to spawn a subprocess. 21 | #[display("Failed to spawn process: {_0}")] 22 | SpawnProcessError(io::Error), 23 | 24 | /// Fail to wait for the subprocess to finish. 25 | #[display("Failed to wait for the process: {_0}")] 26 | WaitProcessError(io::Error), 27 | 28 | /// The program receives --workspace-root outside a workspace. 29 | #[display("--workspace-root may only be used in a workspace")] 30 | NotInWorkspace, 31 | 32 | /// No package manifest. 33 | #[display("File not found: {file:?}")] 34 | NoPkgManifest { file: PathBuf }, 35 | 36 | /// Error related to filesystem operation. 37 | #[display("{path:?}: {error}")] 38 | FsError { path: PathBuf, error: io::Error }, 39 | 40 | /// Error emitted by [`lets_find_up`]'s functions. 41 | #[display("Failed to find {file_name:?} from {start_dir:?} upward: {error}")] 42 | FindUpError { 43 | start_dir: PathBuf, 44 | file_name: &'static str, 45 | error: io::Error, 46 | }, 47 | 48 | /// An error is encountered when write to stdout. 49 | #[display("Failed to write to stdout: {_0}")] 50 | WriteStdoutError(io::Error), 51 | 52 | /// Parse JSON error. 53 | #[display("Failed to parse {file:?}: {message}")] 54 | ParseJsonError { file: PathBuf, message: String }, 55 | 56 | /// Failed to prepend `node_modules/.bin` to `PATH`. 57 | #[display("Cannot add `node_modules/.bin` to PATH: {_0}")] 58 | NodeBinPathError(JoinPathsError), 59 | } 60 | 61 | /// The main error type. 62 | #[derive(Debug, Display, From)] 63 | pub enum MainError { 64 | /// Errors emitted by `pn` itself. 65 | Pn(PnError), 66 | 67 | /// The subprocess that takes control exits with non-zero status code. 68 | #[from(ignore)] 69 | Sub(NonZeroI32), 70 | } 71 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! pnpm wrapper library. 2 | 3 | use indexmap::IndexMap; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | pub mod error; 7 | pub mod passed_through; 8 | pub mod shell_quoted; 9 | pub mod utils; 10 | pub mod workspace; 11 | 12 | /// Structure of `package.json`. 13 | #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] 14 | #[serde(rename_all = "kebab-case")] 15 | pub struct NodeManifest { 16 | #[serde(default)] 17 | pub name: String, 18 | 19 | #[serde(default)] 20 | pub version: String, 21 | 22 | #[serde(default)] 23 | pub scripts: IndexMap, 24 | } 25 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use clap::Parser; 2 | use cli::Cli; 3 | use error::{MainError, PnError}; 4 | use pipe_trait::Pipe; 5 | use shell_quoted::ShellQuoted; 6 | use std::{ 7 | env, 8 | io::{self, Write}, 9 | path::Path, 10 | process::exit, 11 | }; 12 | use yansi::Color::{Black, Red}; 13 | 14 | mod cli; 15 | 16 | use pn::error; 17 | use pn::passed_through; 18 | use pn::shell_quoted; 19 | use pn::utils::*; 20 | use pn::workspace; 21 | use pn::NodeManifest; 22 | 23 | fn main() { 24 | match run() { 25 | Ok(()) => {} 26 | Err(MainError::Sub(status)) => exit(status.get()), 27 | Err(MainError::Pn(error)) => { 28 | eprintln!( 29 | "{prefix} {error}", 30 | prefix = Black.paint("\u{2009}ERROR\u{2009}").bg(Red), 31 | error = Red.paint(error), 32 | ); 33 | exit(1); 34 | } 35 | } 36 | } 37 | 38 | fn run() -> Result<(), MainError> { 39 | let cli = Cli::parse(); 40 | let cwd_and_manifest = || -> Result<_, MainError> { 41 | let mut cwd = env::current_dir().expect("Couldn't find the current working directory"); 42 | if cli.workspace_root { 43 | cwd = workspace::find_workspace_root(&cwd)?; 44 | } 45 | let manifest_path = cwd.join("package.json"); 46 | let manifest = read_package_manifest(&manifest_path)?; 47 | Ok((cwd, manifest)) 48 | }; 49 | let print_and_run_script = 50 | |manifest: &NodeManifest, name: &str, command: ShellQuoted, cwd: &Path| { 51 | eprintln!( 52 | "\n> {name}@{version} {cwd}", 53 | name = &manifest.name, 54 | version = &manifest.version, 55 | cwd = dunce::canonicalize(cwd) 56 | .unwrap_or_else(|_| cwd.to_path_buf()) 57 | .display(), 58 | ); 59 | eprintln!("> {command}\n"); 60 | run_script(name, command, cwd) 61 | }; 62 | match cli.command { 63 | cli::Command::Run(args) => { 64 | let (cwd, manifest) = cwd_and_manifest()?; 65 | if let Some(name) = args.script { 66 | if let Some(command) = manifest.scripts.get(&name) { 67 | let command = ShellQuoted::from_command_and_args(command.into(), &args.args); 68 | print_and_run_script(&manifest, &name, command, &cwd) 69 | } else { 70 | PnError::MissingScript { name } 71 | .pipe(MainError::Pn) 72 | .pipe(Err) 73 | } 74 | } else if manifest.scripts.is_empty() { 75 | println!("There are no scripts in package.json"); 76 | Ok(()) 77 | } else { 78 | list_scripts(io::stdout(), manifest.scripts) 79 | .map_err(PnError::WriteStdoutError) 80 | .map_err(MainError::from) 81 | } 82 | } 83 | cli::Command::Other(args) => { 84 | let (cwd, manifest) = cwd_and_manifest()?; 85 | if let Some(name) = args.first() { 86 | let name = name.as_str(); 87 | if passed_through::PASSED_THROUGH_COMMANDS.contains(name) { 88 | return pass_to_pnpm(&args); // args already contain name, no need to prepend 89 | } 90 | if let Some(command) = manifest.scripts.get(name) { 91 | let command = ShellQuoted::from_command_and_args(command.into(), &args[1..]); 92 | return print_and_run_script(&manifest, name, command, &cwd); 93 | } 94 | } 95 | pass_to_sub(ShellQuoted::from_args(args)) 96 | } 97 | } 98 | } 99 | 100 | fn list_scripts( 101 | mut stdout: impl Write, 102 | script_map: impl IntoIterator, 103 | ) -> io::Result<()> { 104 | writeln!(stdout, "Commands available via `pn run`:")?; 105 | for (name, command) in script_map { 106 | writeln!(stdout, " {name}")?; 107 | writeln!(stdout, " {command}")?; 108 | } 109 | Ok(()) 110 | } 111 | 112 | #[cfg(test)] 113 | mod tests { 114 | use super::*; 115 | use pretty_assertions::assert_eq; 116 | use serde_json::json; 117 | 118 | #[test] 119 | fn test_list_scripts() { 120 | let script_map = [ 121 | ("hello", "echo hello"), 122 | ("world", "echo world"), 123 | ("foo", "echo foo"), 124 | ("bar", "echo bar"), 125 | ("abc", "echo abc"), 126 | ("def", "echo def"), 127 | ] 128 | .map(|(k, v)| (k.to_string(), v.to_string())); 129 | let mut buf = Vec::::new(); 130 | list_scripts(&mut buf, script_map).unwrap(); 131 | let received = String::from_utf8_lossy(&buf); 132 | let expected = [ 133 | "Commands available via `pn run`:", 134 | " hello", 135 | " echo hello", 136 | " world", 137 | " echo world", 138 | " foo", 139 | " echo foo", 140 | " bar", 141 | " echo bar", 142 | " abc", 143 | " echo abc", 144 | " def", 145 | " echo def", 146 | ] 147 | .join("\n"); 148 | assert_eq!(received.trim(), expected.trim()); 149 | } 150 | 151 | #[test] 152 | fn test_read_package_manifest_ok() { 153 | use std::fs; 154 | use tempfile::tempdir; 155 | 156 | let temp_dir = tempdir().unwrap(); 157 | let package_json_path = temp_dir.path().join("package.json"); 158 | fs::write( 159 | &package_json_path, 160 | r#"{"scripts": {"test": "echo hello world"}}"#, 161 | ) 162 | .unwrap(); 163 | 164 | let received = read_package_manifest(&package_json_path).unwrap(); 165 | 166 | let expected: NodeManifest = json!({ 167 | "scripts": { 168 | "test": "echo hello world" 169 | } 170 | }) 171 | .pipe(serde_json::from_value) 172 | .unwrap(); 173 | 174 | assert_eq!(received, expected); 175 | } 176 | 177 | #[test] 178 | fn test_read_package_manifest_error() { 179 | use std::fs; 180 | use tempfile::tempdir; 181 | 182 | let temp_dir = tempdir().unwrap(); 183 | let package_json_path = temp_dir.path().join("package.json"); 184 | fs::write( 185 | &package_json_path, 186 | r#"{"scripts": {"test": "echo hello world",}}"#, 187 | ) 188 | .unwrap(); 189 | 190 | let received_error = read_package_manifest(&package_json_path).unwrap_err(); 191 | dbg!(&received_error); 192 | assert!(matches!( 193 | received_error, 194 | MainError::Pn(PnError::ParseJsonError { .. }), 195 | )); 196 | 197 | let received_message = received_error.to_string(); 198 | eprintln!("MESSAGE:\n{received_message}\n"); 199 | let expected_message = 200 | format!("Failed to parse {package_json_path:?}: trailing comma at line 1 column 41",); 201 | assert_eq!(received_message, expected_message); 202 | } 203 | 204 | #[test] 205 | fn test_create_path_env() { 206 | let bin_path = Path::new("node_modules").join(".bin"); 207 | let path_env = create_path_env().expect("prepend 'node_modules/.bin' to PATH"); 208 | 209 | let first_path = env::split_paths(&path_env).next(); 210 | assert_eq!(first_path, Some(bin_path)); 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /src/passed_through.rs: -------------------------------------------------------------------------------- 1 | /// Commands that need to be passed to pnpm. 2 | pub const PASSED_THROUGH_COMMANDS: phf::Set<&str> = phf::phf_set! { 3 | // commands that pnpm passes to npm 4 | "access", 5 | "adduser", 6 | "bugs", 7 | "deprecate", 8 | "dist-tag", 9 | "docs", 10 | "edit", 11 | "info", 12 | "login", 13 | "logout", 14 | "owner", 15 | "ping", 16 | "prefix", 17 | "profile", 18 | "pkg", 19 | "repo", 20 | "s", 21 | "se", 22 | "search", 23 | "set-script", 24 | "show", 25 | "star", 26 | "stars", 27 | "team", 28 | "token", 29 | "unpublish", 30 | "unstar", 31 | "v", 32 | "version", 33 | "view", 34 | "whoami", 35 | "xmas", 36 | 37 | // completion commands 38 | "install-completion", 39 | "uninstall-completion", 40 | 41 | // pnpm commands 42 | 43 | // manage deps 44 | "add", 45 | "i", 46 | "install", 47 | "up", 48 | "update", 49 | "remove", 50 | "link", 51 | "unlink", 52 | "import", 53 | "rebuild", 54 | "prune", 55 | "fetch", 56 | "install-test", 57 | "dedupe", 58 | 59 | // patch deps 60 | "patch", 61 | "patch-commit", 62 | "patch-remove", 63 | 64 | // review deps 65 | "audit", 66 | "list", 67 | "outdated", 68 | "why", 69 | "licenses", 70 | 71 | // run scripts 72 | "dlx", 73 | "create", 74 | 75 | // manage environments 76 | "env", 77 | 78 | // misc 79 | "publish", 80 | "pack", 81 | "server", 82 | "store", 83 | "root", 84 | "bin", 85 | "setup", 86 | "init", 87 | "deploy", 88 | "doctor", 89 | "config", 90 | }; 91 | -------------------------------------------------------------------------------- /src/shell_quoted.rs: -------------------------------------------------------------------------------- 1 | use derive_more::{Display, Into}; 2 | use os_display::Quoted; 3 | use std::ffi::OsStr; 4 | 5 | #[derive(Debug, Display, Into)] 6 | pub struct ShellQuoted(String); 7 | 8 | impl AsRef for ShellQuoted { 9 | fn as_ref(&self) -> &OsStr { 10 | self.0.as_ref() 11 | } 12 | } 13 | 14 | impl ShellQuoted { 15 | /// `command` will not be quoted 16 | pub fn from_command(command: String) -> Self { 17 | Self(command) 18 | } 19 | 20 | pub fn push_arg>(&mut self, arg: S) { 21 | use std::fmt::Write; 22 | if !self.0.is_empty() { 23 | self.0.push(' '); 24 | } 25 | let quoted = Quoted::unix(arg.as_ref()); // because pn uses `sh -c` even on Windows 26 | write!(self.0, "{quoted}").expect("string write doesn't panic"); 27 | } 28 | 29 | pub fn from_command_and_args(command: String, args: Args) -> Self 30 | where 31 | Args: IntoIterator, 32 | Args::Item: AsRef, 33 | { 34 | let mut cmd = Self::from_command(command); 35 | for arg in args { 36 | cmd.push_arg(arg); 37 | } 38 | cmd 39 | } 40 | 41 | pub fn from_args(args: Args) -> Self 42 | where 43 | Args: IntoIterator, 44 | Args::Item: AsRef, 45 | { 46 | Self::from_command_and_args(String::default(), args) 47 | } 48 | } 49 | 50 | #[cfg(test)] 51 | mod tests { 52 | use super::*; 53 | use pretty_assertions::assert_eq; 54 | 55 | #[test] 56 | fn test_from_command_and_args() { 57 | let command = ShellQuoted::from_command_and_args( 58 | "echo hello world".into(), 59 | ["abc", ";ls /etc", "ghi jkl", "\"", "'"], 60 | ); 61 | assert_eq!( 62 | command.to_string(), 63 | r#"echo hello world 'abc' ';ls /etc' 'ghi jkl' '"' "'""# 64 | ); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/utils.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | error::{MainError, PnError}, 3 | shell_quoted::ShellQuoted, 4 | NodeManifest, 5 | }; 6 | use pipe_trait::Pipe; 7 | use std::{ 8 | env, 9 | ffi::OsString, 10 | fs::File, 11 | io::ErrorKind, 12 | num::NonZeroI32, 13 | path::Path, 14 | process::{Command, Stdio}, 15 | }; 16 | 17 | pub fn run_script(name: &str, command: ShellQuoted, cwd: &Path) -> Result<(), MainError> { 18 | let path_env = create_path_env()?; 19 | let status = Command::new("sh") 20 | .current_dir(cwd) 21 | .env("PATH", path_env) 22 | .arg("-c") 23 | .arg(&command) 24 | .stdin(Stdio::inherit()) 25 | .stdout(Stdio::inherit()) 26 | .stderr(Stdio::inherit()) 27 | .spawn() 28 | .map_err(PnError::SpawnProcessError)? 29 | .wait() 30 | .map_err(PnError::WaitProcessError)? 31 | .code() 32 | .map(NonZeroI32::new); 33 | match status { 34 | Some(None) => return Ok(()), 35 | Some(Some(status)) => PnError::ScriptError { 36 | name: name.to_string(), 37 | status, 38 | }, 39 | None => PnError::UnexpectedTermination { command }, 40 | } 41 | .pipe(MainError::Pn) 42 | .pipe(Err) 43 | } 44 | 45 | pub fn pass_to_pnpm(args: &[String]) -> Result<(), MainError> { 46 | let status = Command::new("pnpm") 47 | .args(args) 48 | .stdin(Stdio::inherit()) 49 | .stdout(Stdio::inherit()) 50 | .stderr(Stdio::inherit()) 51 | .spawn() 52 | .map_err(PnError::SpawnProcessError)? 53 | .wait() 54 | .map_err(PnError::WaitProcessError)? 55 | .code() 56 | .map(NonZeroI32::new); 57 | Err(match status { 58 | Some(None) => return Ok(()), 59 | Some(Some(status)) => MainError::Sub(status), 60 | None => MainError::Pn(PnError::UnexpectedTermination { 61 | command: ShellQuoted::from_command_and_args("pnpm".into(), args), 62 | }), 63 | }) 64 | } 65 | 66 | pub fn pass_to_sub(command: ShellQuoted) -> Result<(), MainError> { 67 | let path_env = create_path_env()?; 68 | let status = Command::new("sh") 69 | .env("PATH", path_env) 70 | .arg("-c") 71 | .arg(&command) 72 | .stdin(Stdio::inherit()) 73 | .stdout(Stdio::inherit()) 74 | .stderr(Stdio::inherit()) 75 | .spawn() 76 | .map_err(PnError::SpawnProcessError)? 77 | .wait() 78 | .map_err(PnError::WaitProcessError)? 79 | .code() 80 | .map(NonZeroI32::new); 81 | Err(match status { 82 | Some(None) => return Ok(()), 83 | Some(Some(status)) => MainError::Sub(status), 84 | None => MainError::Pn(PnError::UnexpectedTermination { command }), 85 | }) 86 | } 87 | 88 | pub fn read_package_manifest(manifest_path: &Path) -> Result { 89 | manifest_path 90 | .pipe(File::open) 91 | .map_err(|error| match error.kind() { 92 | ErrorKind::NotFound => PnError::NoPkgManifest { 93 | file: manifest_path.to_path_buf(), 94 | }, 95 | _ => PnError::FsError { 96 | path: manifest_path.to_path_buf(), 97 | error, 98 | }, 99 | })? 100 | .pipe(serde_json::de::from_reader::<_, NodeManifest>) 101 | .map_err(|err| { 102 | MainError::Pn(PnError::ParseJsonError { 103 | file: manifest_path.to_path_buf(), 104 | message: err.to_string(), 105 | }) 106 | }) 107 | } 108 | 109 | pub fn create_path_env() -> Result { 110 | let existing_paths = env::var_os("PATH"); 111 | let existing_paths = existing_paths.iter().flat_map(env::split_paths); 112 | Path::new("node_modules") 113 | .join(".bin") 114 | .pipe(std::iter::once) 115 | .chain(existing_paths) 116 | .pipe(env::join_paths) 117 | .map_err(PnError::NodeBinPathError) 118 | .map_err(MainError::from) 119 | } 120 | -------------------------------------------------------------------------------- /src/workspace.rs: -------------------------------------------------------------------------------- 1 | use super::error::{MainError, PnError}; 2 | use lets_find_up::*; 3 | use std::path::{Path, PathBuf}; 4 | 5 | const WORKSPACE_MANIFEST_FILENAME: &str = "pnpm-workspace.yaml"; 6 | 7 | pub fn find_workspace_root(cwd: &Path) -> Result { 8 | let options = FindUpOptions { 9 | kind: FindUpKind::File, 10 | cwd, 11 | }; 12 | find_up_with(WORKSPACE_MANIFEST_FILENAME, options) 13 | .map_err(|error| PnError::FindUpError { 14 | start_dir: cwd.to_path_buf(), 15 | file_name: WORKSPACE_MANIFEST_FILENAME, 16 | error, 17 | })? 18 | .and_then(|x| x.parent().map(Path::to_path_buf)) 19 | .ok_or(MainError::Pn(PnError::NotInWorkspace)) 20 | } 21 | -------------------------------------------------------------------------------- /tests/fixtures/append-script-args/list-args.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -o errexit -o nounset 3 | for arg in "$@"; do 4 | echo "$arg" 5 | done 6 | -------------------------------------------------------------------------------- /tests/fixtures/append-script-args/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "list-args": "sh list-args.sh" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /tests/fixtures/append-script-args/stdout.txt: -------------------------------------------------------------------------------- 1 | foo 2 | bar 3 | hello world 4 | ! !@ 5 | abc def ghi 6 | -------------------------------------------------------------------------------- /tests/fixtures/list-script-names/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "hello": "echo hello", 4 | "world": "echo world", 5 | "foo": "echo foo", 6 | "bar": "echo bar", 7 | "abc": "echo abc", 8 | "def": "echo def" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tests/fixtures/list-script-names/stdout.txt: -------------------------------------------------------------------------------- 1 | Commands available via `pn run`: 2 | hello 3 | echo hello 4 | world 5 | echo world 6 | foo 7 | echo foo 8 | bar 9 | echo bar 10 | abc 11 | echo abc 12 | def 13 | echo def 14 | -------------------------------------------------------------------------------- /tests/test_main.rs: -------------------------------------------------------------------------------- 1 | use assert_cmd::prelude::{CommandCargoExt, OutputAssertExt}; 2 | use build_fs_tree::{dir, file, Build, MergeableFileSystemTree}; 3 | use pipe_trait::Pipe; 4 | use pretty_assertions::assert_eq; 5 | use serde_json::json; 6 | use std::{fs, process::Command}; 7 | use tempfile::tempdir; 8 | 9 | #[test] 10 | fn run_script() { 11 | let temp_dir = tempdir().unwrap(); 12 | let package_json_path = temp_dir.path().join("package.json"); 13 | fs::write( 14 | package_json_path, 15 | json!({ 16 | "name": "test", 17 | "version": "1.0.0", 18 | "scripts": { 19 | "test": "echo hello world" 20 | } 21 | }) 22 | .to_string(), 23 | ) 24 | .unwrap(); 25 | 26 | Command::cargo_bin("pn") 27 | .unwrap() 28 | .current_dir(&temp_dir) 29 | .args(["run", "test"]) 30 | .assert() 31 | .success() 32 | .stdout("hello world\n") 33 | .stderr(format!( 34 | "\n> test@1.0.0 {}\n> echo hello world\n\n", 35 | temp_dir.path().pipe(dunce::canonicalize).unwrap().display(), 36 | )); 37 | 38 | Command::cargo_bin("pn") 39 | .unwrap() 40 | .current_dir(&temp_dir) 41 | .arg("test") 42 | .assert() 43 | .success() 44 | .stdout("hello world\n") 45 | .stderr(format!( 46 | "\n> test@1.0.0 {}\n> echo hello world\n\n", 47 | temp_dir.path().pipe(dunce::canonicalize).unwrap().display(), 48 | )); 49 | 50 | Command::cargo_bin("pn") 51 | .unwrap() 52 | .current_dir(&temp_dir) 53 | .args(["test", "\"me\""]) 54 | .assert() 55 | .success() 56 | .stdout("hello world \"me\"\n") 57 | .stderr(format!( 58 | "\n> test@1.0.0 {}\n> echo hello world '\"me\"'\n\n", 59 | temp_dir.path().pipe(dunce::canonicalize).unwrap().display(), 60 | )); 61 | 62 | Command::cargo_bin("pn") 63 | .unwrap() 64 | .current_dir(&temp_dir) 65 | .args(["echo", ";ls"]) 66 | .assert() 67 | .success() 68 | .stdout(";ls\n") 69 | .stderr(""); 70 | } 71 | 72 | #[test] 73 | fn append_script_args() { 74 | let temp_dir = tempdir().unwrap(); 75 | let tree = MergeableFileSystemTree::<&str, &str>::from(dir! { 76 | "package.json" => file!(include_str!("fixtures/append-script-args/package.json")), 77 | "list-args.sh" => file!(include_str!("fixtures/append-script-args/list-args.sh")), 78 | }); 79 | tree.build(&temp_dir).unwrap(); 80 | 81 | Command::cargo_bin("pn") 82 | .unwrap() 83 | .current_dir(&temp_dir) 84 | .arg("run") 85 | .arg("list-args") 86 | .arg("foo") 87 | .arg("bar") 88 | .arg("hello world") 89 | .arg("! !@") 90 | .arg("abc def ghi") 91 | .assert() 92 | .success() 93 | .stdout(include_str!("fixtures/append-script-args/stdout.txt").replace("\r\n", "\n")); 94 | } 95 | 96 | #[test] 97 | fn run_from_workspace_root() { 98 | let temp_dir = tempdir().unwrap(); 99 | let tree = MergeableFileSystemTree::<&str, &str>::from(dir! { 100 | "package.json" => file!(r#"{"scripts": {"test": "echo hello from workspace root"}}"#), 101 | "pnpm-workspace.yaml" => file!("packages: ['packages/*']"), 102 | "packages" => dir! { 103 | "foo" => dir! { 104 | "package.json" => file!(r#"{"scripts": {"test": "echo hello from foo"}}"#), 105 | }, 106 | }, 107 | }); 108 | tree.build(&temp_dir).unwrap(); 109 | 110 | Command::cargo_bin("pn") 111 | .unwrap() 112 | .current_dir(temp_dir.path().join("packages/foo")) 113 | .args(["--workspace-root", "run", "test"]) 114 | .assert() 115 | .success() 116 | .stdout("hello from workspace root\n") 117 | .stderr(format!( 118 | "\n> @ {}\n> echo hello from workspace root\n\n", 119 | temp_dir.path().pipe(dunce::canonicalize).unwrap().display(), 120 | )); 121 | } 122 | 123 | #[test] 124 | fn workspace_root_not_found_error() { 125 | let temp_dir = tempdir().unwrap(); 126 | let package_json_path = temp_dir.path().join("package.json"); 127 | fs::write( 128 | package_json_path, 129 | r#"{"scripts": {"test": "echo hello world"}}"#, 130 | ) 131 | .unwrap(); 132 | 133 | let assertion = Command::cargo_bin("pn") 134 | .unwrap() 135 | .current_dir(&temp_dir) 136 | .args(["--workspace-root", "run", "test"]) 137 | .assert() 138 | .failure(); 139 | let output = assertion.get_output(); 140 | let stderr = String::from_utf8_lossy(&output.stderr); 141 | eprintln!("STDERR:\n{stderr}\n"); 142 | assert!(stderr.contains("--workspace-root may only be used in a workspace")); 143 | } 144 | 145 | #[test] 146 | fn no_package_manifest_error() { 147 | let temp_dir = tempdir().unwrap(); 148 | let assertion = Command::cargo_bin("pn") 149 | .unwrap() 150 | .current_dir(&temp_dir) 151 | .args(["run", "test"]) 152 | .assert() 153 | .failure(); 154 | let output = assertion.get_output(); 155 | let stderr = String::from_utf8_lossy(&output.stderr); 156 | eprintln!("STDERR:\n{stderr}\n"); 157 | assert!(stderr.contains("File not found: ")); 158 | let expected_path = temp_dir 159 | .path() 160 | .join("package.json") 161 | .display() 162 | .to_string() 163 | .replace('\\', "\\\\"); 164 | dbg!(&expected_path); 165 | assert!(stderr.contains(&expected_path)); 166 | } 167 | 168 | #[test] 169 | fn list_script_names() { 170 | let temp_dir = tempdir().unwrap(); 171 | fs::write( 172 | temp_dir.path().join("package.json"), 173 | include_str!("fixtures/list-script-names/package.json"), 174 | ) 175 | .unwrap(); 176 | let assertion = Command::cargo_bin("pn") 177 | .unwrap() 178 | .current_dir(&temp_dir) 179 | .arg("run") 180 | .assert() 181 | .success(); 182 | let output = assertion.get_output(); 183 | let received = String::from_utf8_lossy(&output.stdout); 184 | eprintln!("STDOUT:\n{received}\n"); 185 | let expected = include_str!("fixtures/list-script-names/stdout.txt").replace("\r\n", "\n"); 186 | assert_eq!(received.trim(), expected.trim()); 187 | } 188 | 189 | #[test] 190 | fn list_no_script_names() { 191 | let temp_dir = tempdir().unwrap(); 192 | fs::write(temp_dir.path().join("package.json"), "{}").unwrap(); 193 | Command::cargo_bin("pn") 194 | .unwrap() 195 | .current_dir(&temp_dir) 196 | .arg("run") 197 | .assert() 198 | .success() 199 | .stdout("There are no scripts in package.json\n"); 200 | } 201 | --------------------------------------------------------------------------------