├── .envrc ├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ └── nix.yml ├── .gitignore ├── CHANGELOG.md ├── CHANGELOG.org ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── SPEC.md ├── default.nix ├── flake.lock ├── flake.nix ├── nix-script-directives ├── Cargo.toml └── src │ ├── expr.rs │ ├── lib.rs │ └── parser.rs ├── nix-script-haskell ├── Cargo.toml ├── sample-scripts │ ├── ghc-flags.hs │ ├── hello-world.hs │ ├── no-extension │ └── relude.hs ├── src │ ├── main.rs │ └── opts.rs └── tests │ └── sample_scripts.rs ├── nix-script ├── Cargo.toml ├── sample-scripts │ ├── hello-world.hs │ ├── hello-world.py │ └── jq.sh ├── src │ ├── builder.rs │ ├── clean_path.rs │ ├── derivation.rs │ ├── derivation │ │ └── inputs.rs │ ├── main.rs │ └── opts.rs └── tests │ ├── echo.sh │ ├── end_to_end.rs │ ├── exit-with-code.sh │ ├── nix-script-bash-target.sh │ ├── script-name.sh │ └── with_runtime_file │ ├── message │ └── script.sh └── shell.nix /.envrc: -------------------------------------------------------------------------------- 1 | use flake || use nix 2 | 3 | export NIX_SCRIPT_LOG=trace 4 | export NIX_SCRIPT_CACHE="$(pwd)/cache" 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "cargo" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | 8 | - package-ecosystem: "github-actions" 9 | directory: "/" 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | 6 | env: 7 | NIX_SCRIPT_LOG: trace 8 | NIX_SCRIPT_CACHE: cache 9 | 10 | jobs: 11 | build-and-test: 12 | name: Build and test 13 | strategy: 14 | matrix: 15 | os: [ubuntu-latest, macos-latest] 16 | runs-on: "${{ matrix.os }}" 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: cachix/install-nix-action@v31 20 | - uses: cachix/cachix-action@v16 21 | with: 22 | name: dschrempf-nix-script 23 | authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' 24 | - uses: Swatinem/rust-cache@v2 25 | 26 | - run: nix build --print-build-logs 27 | - run: nix develop --command bash -c 'NIX_PATH="nixpkgs=$NIX_PKGS" cargo test' 28 | 29 | rustfmt: 30 | name: Format 31 | runs-on: ubuntu-latest 32 | steps: 33 | - uses: actions/checkout@v4 34 | - uses: cachix/install-nix-action@v31 35 | - uses: cachix/cachix-action@v16 36 | with: 37 | name: nix-script 38 | skipPush: true 39 | 40 | - run: nix develop --command cargo fmt --all --check 41 | -------------------------------------------------------------------------------- /.github/workflows/nix.yml: -------------------------------------------------------------------------------- 1 | name: Update Nix 2 | 3 | on: 4 | schedule: 5 | - cron: "0 0 * * 3" 6 | workflow_dispatch: 7 | 8 | jobs: 9 | update-nix: 10 | name: Update Nix 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | with: 15 | ref: main 16 | - uses: cachix/install-nix-action@v31 17 | - run: nix flake update 18 | - name: Create PR 19 | env: 20 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 21 | run: | 22 | if test -z "$(git status --porcelain)"; then exit 0; fi 23 | 24 | git config --global user.name "Github Actions" 25 | git config --global user.email "noreply@github.com" 26 | 27 | BRANCH="nix-update-$(date -I)" 28 | git checkout -b "$BRANCH" 29 | git add flake.lock 30 | git commit --message "update Nix sources" --author "GitHub Actions " 31 | 32 | git push -u origin "$BRANCH" 33 | gh pr create \ 34 | --title "Update Nix sources, $(date -I)" \ 35 | --body "Automatically generated by GitHub actions at $(date)" \ 36 | --head "$BRANCH" 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Rust. 2 | debug/ 3 | target/ 4 | **/*.rs.bk 5 | 6 | # Tooling. 7 | /.direnv/ 8 | 9 | # Nix-script. 10 | /cache 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | # Unreleased 3 | 4 | Contains breaking changes. 5 | 6 | - Remove separate Nix derivations. Only provide `nix-script` which bundles all 7 | available interpreters. 8 | 9 | 10 | # Version 3.0.0 11 | 12 | This is the first release by myself (Dominik Schrempf) after Brian Hicks has 13 | flagged `nix-script` unmaintained. This release does not contain any breaking 14 | changes. 15 | 16 | I have 17 | 18 | - updated the Rust toolchain and dependencies; 19 | - added many small changes affecting error and log messages, as well as code 20 | comments; 21 | - renamed the internal `directives` library to `nix-script-directives`. 22 | 23 | There is more to come! 24 | 25 | 26 | # Version 2 27 | 28 | According to Brian Hicks, this was a complete rewrite of version 1. 29 | 30 | -------------------------------------------------------------------------------- /CHANGELOG.org: -------------------------------------------------------------------------------- 1 | * Unreleased 2 | :PROPERTIES: 3 | :ID: 1db59859-b93e-46e7-bf03-62b8af7df0cd 4 | :END: 5 | Contains breaking changes. 6 | 7 | - Remove separate Nix derivations. Only provide =nix-script= which bundles all 8 | available interpreters. 9 | 10 | * Version 3.0.0 11 | This is the first release by myself (Dominik Schrempf) after Brian Hicks has 12 | flagged =nix-script= unmaintained. This release does not contain any breaking 13 | changes. 14 | 15 | I have 16 | - updated the Rust toolchain and dependencies; 17 | - added many small changes affecting error and log messages, as well as code 18 | comments; 19 | - renamed the internal =directives= library to =nix-script-directives=. 20 | 21 | There is more to come! 22 | 23 | * Version 2 24 | According to Brian Hicks, this was a complete rewrite of version 1. 25 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "aho-corasick" 7 | version = "1.1.3" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" 10 | dependencies = [ 11 | "memchr", 12 | ] 13 | 14 | [[package]] 15 | name = "anstream" 16 | version = "0.6.18" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" 19 | dependencies = [ 20 | "anstyle", 21 | "anstyle-parse", 22 | "anstyle-query", 23 | "anstyle-wincon", 24 | "colorchoice", 25 | "is_terminal_polyfill", 26 | "utf8parse", 27 | ] 28 | 29 | [[package]] 30 | name = "anstyle" 31 | version = "1.0.10" 32 | source = "registry+https://github.com/rust-lang/crates.io-index" 33 | checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" 34 | 35 | [[package]] 36 | name = "anstyle-parse" 37 | version = "0.2.6" 38 | source = "registry+https://github.com/rust-lang/crates.io-index" 39 | checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" 40 | dependencies = [ 41 | "utf8parse", 42 | ] 43 | 44 | [[package]] 45 | name = "anstyle-query" 46 | version = "1.1.2" 47 | source = "registry+https://github.com/rust-lang/crates.io-index" 48 | checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" 49 | dependencies = [ 50 | "windows-sys", 51 | ] 52 | 53 | [[package]] 54 | name = "anstyle-wincon" 55 | version = "3.0.6" 56 | source = "registry+https://github.com/rust-lang/crates.io-index" 57 | checksum = "2109dbce0e72be3ec00bed26e6a7479ca384ad226efdd66db8fa2e3a38c83125" 58 | dependencies = [ 59 | "anstyle", 60 | "windows-sys", 61 | ] 62 | 63 | [[package]] 64 | name = "anyhow" 65 | version = "1.0.98" 66 | source = "registry+https://github.com/rust-lang/crates.io-index" 67 | checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" 68 | 69 | [[package]] 70 | name = "assert_cmd" 71 | version = "2.0.17" 72 | source = "registry+https://github.com/rust-lang/crates.io-index" 73 | checksum = "2bd389a4b2970a01282ee455294913c0a43724daedcd1a24c3eb0ec1c1320b66" 74 | dependencies = [ 75 | "anstyle", 76 | "bstr", 77 | "doc-comment", 78 | "libc", 79 | "predicates", 80 | "predicates-core", 81 | "predicates-tree", 82 | "wait-timeout", 83 | ] 84 | 85 | [[package]] 86 | name = "bitflags" 87 | version = "2.6.0" 88 | source = "registry+https://github.com/rust-lang/crates.io-index" 89 | checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" 90 | 91 | [[package]] 92 | name = "bstr" 93 | version = "1.11.0" 94 | source = "registry+https://github.com/rust-lang/crates.io-index" 95 | checksum = "1a68f1f47cdf0ec8ee4b941b2eee2a80cb796db73118c0dd09ac63fbe405be22" 96 | dependencies = [ 97 | "memchr", 98 | "regex-automata", 99 | "serde", 100 | ] 101 | 102 | [[package]] 103 | name = "cfg-if" 104 | version = "1.0.0" 105 | source = "registry+https://github.com/rust-lang/crates.io-index" 106 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 107 | 108 | [[package]] 109 | name = "clap" 110 | version = "4.5.38" 111 | source = "registry+https://github.com/rust-lang/crates.io-index" 112 | checksum = "ed93b9805f8ba930df42c2590f05453d5ec36cbb85d018868a5b24d31f6ac000" 113 | dependencies = [ 114 | "clap_builder", 115 | "clap_derive", 116 | ] 117 | 118 | [[package]] 119 | name = "clap_builder" 120 | version = "4.5.38" 121 | source = "registry+https://github.com/rust-lang/crates.io-index" 122 | checksum = "379026ff283facf611b0ea629334361c4211d1b12ee01024eec1591133b04120" 123 | dependencies = [ 124 | "anstream", 125 | "anstyle", 126 | "clap_lex", 127 | "strsim", 128 | ] 129 | 130 | [[package]] 131 | name = "clap_derive" 132 | version = "4.5.32" 133 | source = "registry+https://github.com/rust-lang/crates.io-index" 134 | checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" 135 | dependencies = [ 136 | "heck", 137 | "proc-macro2", 138 | "quote", 139 | "syn", 140 | ] 141 | 142 | [[package]] 143 | name = "clap_lex" 144 | version = "0.7.4" 145 | source = "registry+https://github.com/rust-lang/crates.io-index" 146 | checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" 147 | 148 | [[package]] 149 | name = "colorchoice" 150 | version = "1.0.3" 151 | source = "registry+https://github.com/rust-lang/crates.io-index" 152 | checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" 153 | 154 | [[package]] 155 | name = "countme" 156 | version = "3.0.1" 157 | source = "registry+https://github.com/rust-lang/crates.io-index" 158 | checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" 159 | 160 | [[package]] 161 | name = "difflib" 162 | version = "0.4.0" 163 | source = "registry+https://github.com/rust-lang/crates.io-index" 164 | checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" 165 | 166 | [[package]] 167 | name = "directories" 168 | version = "6.0.0" 169 | source = "registry+https://github.com/rust-lang/crates.io-index" 170 | checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" 171 | dependencies = [ 172 | "dirs-sys", 173 | ] 174 | 175 | [[package]] 176 | name = "dirs-sys" 177 | version = "0.5.0" 178 | source = "registry+https://github.com/rust-lang/crates.io-index" 179 | checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" 180 | dependencies = [ 181 | "libc", 182 | "option-ext", 183 | "redox_users", 184 | "windows-sys", 185 | ] 186 | 187 | [[package]] 188 | name = "doc-comment" 189 | version = "0.3.3" 190 | source = "registry+https://github.com/rust-lang/crates.io-index" 191 | checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" 192 | 193 | [[package]] 194 | name = "env_filter" 195 | version = "0.1.2" 196 | source = "registry+https://github.com/rust-lang/crates.io-index" 197 | checksum = "4f2c92ceda6ceec50f43169f9ee8424fe2db276791afde7b2cd8bc084cb376ab" 198 | dependencies = [ 199 | "log", 200 | "regex", 201 | ] 202 | 203 | [[package]] 204 | name = "env_logger" 205 | version = "0.11.6" 206 | source = "registry+https://github.com/rust-lang/crates.io-index" 207 | checksum = "dcaee3d8e3cfc3fd92428d477bc97fc29ec8716d180c0d74c643bb26166660e0" 208 | dependencies = [ 209 | "anstream", 210 | "anstyle", 211 | "env_filter", 212 | "humantime", 213 | "log", 214 | ] 215 | 216 | [[package]] 217 | name = "errno" 218 | version = "0.3.10" 219 | source = "registry+https://github.com/rust-lang/crates.io-index" 220 | checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" 221 | dependencies = [ 222 | "libc", 223 | "windows-sys", 224 | ] 225 | 226 | [[package]] 227 | name = "fastrand" 228 | version = "2.2.0" 229 | source = "registry+https://github.com/rust-lang/crates.io-index" 230 | checksum = "486f806e73c5707928240ddc295403b1b93c96a02038563881c4a2fd84b81ac4" 231 | 232 | [[package]] 233 | name = "fs2" 234 | version = "0.4.3" 235 | source = "registry+https://github.com/rust-lang/crates.io-index" 236 | checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" 237 | dependencies = [ 238 | "libc", 239 | "winapi", 240 | ] 241 | 242 | [[package]] 243 | name = "getrandom" 244 | version = "0.2.15" 245 | source = "registry+https://github.com/rust-lang/crates.io-index" 246 | checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" 247 | dependencies = [ 248 | "cfg-if", 249 | "libc", 250 | "wasi 0.11.0+wasi-snapshot-preview1", 251 | ] 252 | 253 | [[package]] 254 | name = "getrandom" 255 | version = "0.3.1" 256 | source = "registry+https://github.com/rust-lang/crates.io-index" 257 | checksum = "43a49c392881ce6d5c3b8cb70f98717b7c07aabbdff06687b9030dbfbe2725f8" 258 | dependencies = [ 259 | "cfg-if", 260 | "libc", 261 | "wasi 0.13.3+wasi-0.2.2", 262 | "windows-targets", 263 | ] 264 | 265 | [[package]] 266 | name = "hashbrown" 267 | version = "0.14.5" 268 | source = "registry+https://github.com/rust-lang/crates.io-index" 269 | checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" 270 | 271 | [[package]] 272 | name = "heck" 273 | version = "0.5.0" 274 | source = "registry+https://github.com/rust-lang/crates.io-index" 275 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 276 | 277 | [[package]] 278 | name = "humantime" 279 | version = "2.1.0" 280 | source = "registry+https://github.com/rust-lang/crates.io-index" 281 | checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" 282 | 283 | [[package]] 284 | name = "is_terminal_polyfill" 285 | version = "1.70.1" 286 | source = "registry+https://github.com/rust-lang/crates.io-index" 287 | checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" 288 | 289 | [[package]] 290 | name = "itoa" 291 | version = "1.0.11" 292 | source = "registry+https://github.com/rust-lang/crates.io-index" 293 | checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" 294 | 295 | [[package]] 296 | name = "lazy_static" 297 | version = "1.5.0" 298 | source = "registry+https://github.com/rust-lang/crates.io-index" 299 | checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" 300 | 301 | [[package]] 302 | name = "libc" 303 | version = "0.2.170" 304 | source = "registry+https://github.com/rust-lang/crates.io-index" 305 | checksum = "875b3680cb2f8f71bdcf9a30f38d48282f5d3c95cbf9b3fa57269bb5d5c06828" 306 | 307 | [[package]] 308 | name = "libredox" 309 | version = "0.1.3" 310 | source = "registry+https://github.com/rust-lang/crates.io-index" 311 | checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" 312 | dependencies = [ 313 | "bitflags", 314 | "libc", 315 | ] 316 | 317 | [[package]] 318 | name = "linux-raw-sys" 319 | version = "0.9.2" 320 | source = "registry+https://github.com/rust-lang/crates.io-index" 321 | checksum = "6db9c683daf087dc577b7506e9695b3d556a9f3849903fa28186283afd6809e9" 322 | 323 | [[package]] 324 | name = "log" 325 | version = "0.4.27" 326 | source = "registry+https://github.com/rust-lang/crates.io-index" 327 | checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" 328 | 329 | [[package]] 330 | name = "memchr" 331 | version = "2.7.4" 332 | source = "registry+https://github.com/rust-lang/crates.io-index" 333 | checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" 334 | 335 | [[package]] 336 | name = "nix-script" 337 | version = "3.0.0" 338 | dependencies = [ 339 | "anyhow", 340 | "assert_cmd", 341 | "clap", 342 | "directories", 343 | "env_logger", 344 | "fs2", 345 | "lazy_static", 346 | "log", 347 | "nix-script-directives", 348 | "once_cell", 349 | "path-absolutize", 350 | "rnix", 351 | "seahash", 352 | "serde_json", 353 | "tempfile", 354 | "walkdir", 355 | ] 356 | 357 | [[package]] 358 | name = "nix-script-directives" 359 | version = "3.0.0" 360 | dependencies = [ 361 | "anyhow", 362 | "log", 363 | "rnix", 364 | "rowan", 365 | "serde", 366 | ] 367 | 368 | [[package]] 369 | name = "nix-script-haskell" 370 | version = "3.0.0" 371 | dependencies = [ 372 | "anyhow", 373 | "assert_cmd", 374 | "clap", 375 | "env_logger", 376 | "log", 377 | "nix-script-directives", 378 | ] 379 | 380 | [[package]] 381 | name = "once_cell" 382 | version = "1.21.3" 383 | source = "registry+https://github.com/rust-lang/crates.io-index" 384 | checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" 385 | 386 | [[package]] 387 | name = "option-ext" 388 | version = "0.2.0" 389 | source = "registry+https://github.com/rust-lang/crates.io-index" 390 | checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" 391 | 392 | [[package]] 393 | name = "path-absolutize" 394 | version = "3.1.1" 395 | source = "registry+https://github.com/rust-lang/crates.io-index" 396 | checksum = "e4af381fe79fa195b4909485d99f73a80792331df0625188e707854f0b3383f5" 397 | dependencies = [ 398 | "path-dedot", 399 | ] 400 | 401 | [[package]] 402 | name = "path-dedot" 403 | version = "3.1.1" 404 | source = "registry+https://github.com/rust-lang/crates.io-index" 405 | checksum = "07ba0ad7e047712414213ff67533e6dd477af0a4e1d14fb52343e53d30ea9397" 406 | dependencies = [ 407 | "once_cell", 408 | ] 409 | 410 | [[package]] 411 | name = "predicates" 412 | version = "3.1.2" 413 | source = "registry+https://github.com/rust-lang/crates.io-index" 414 | checksum = "7e9086cc7640c29a356d1a29fd134380bee9d8f79a17410aa76e7ad295f42c97" 415 | dependencies = [ 416 | "anstyle", 417 | "difflib", 418 | "predicates-core", 419 | ] 420 | 421 | [[package]] 422 | name = "predicates-core" 423 | version = "1.0.8" 424 | source = "registry+https://github.com/rust-lang/crates.io-index" 425 | checksum = "ae8177bee8e75d6846599c6b9ff679ed51e882816914eec639944d7c9aa11931" 426 | 427 | [[package]] 428 | name = "predicates-tree" 429 | version = "1.0.11" 430 | source = "registry+https://github.com/rust-lang/crates.io-index" 431 | checksum = "41b740d195ed3166cd147c8047ec98db0e22ec019eb8eeb76d343b795304fb13" 432 | dependencies = [ 433 | "predicates-core", 434 | "termtree", 435 | ] 436 | 437 | [[package]] 438 | name = "proc-macro2" 439 | version = "1.0.89" 440 | source = "registry+https://github.com/rust-lang/crates.io-index" 441 | checksum = "f139b0662de085916d1fb67d2b4169d1addddda1919e696f3252b740b629986e" 442 | dependencies = [ 443 | "unicode-ident", 444 | ] 445 | 446 | [[package]] 447 | name = "quote" 448 | version = "1.0.37" 449 | source = "registry+https://github.com/rust-lang/crates.io-index" 450 | checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" 451 | dependencies = [ 452 | "proc-macro2", 453 | ] 454 | 455 | [[package]] 456 | name = "redox_users" 457 | version = "0.5.0" 458 | source = "registry+https://github.com/rust-lang/crates.io-index" 459 | checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" 460 | dependencies = [ 461 | "getrandom 0.2.15", 462 | "libredox", 463 | "thiserror", 464 | ] 465 | 466 | [[package]] 467 | name = "regex" 468 | version = "1.11.1" 469 | source = "registry+https://github.com/rust-lang/crates.io-index" 470 | checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" 471 | dependencies = [ 472 | "aho-corasick", 473 | "memchr", 474 | "regex-automata", 475 | "regex-syntax", 476 | ] 477 | 478 | [[package]] 479 | name = "regex-automata" 480 | version = "0.4.9" 481 | source = "registry+https://github.com/rust-lang/crates.io-index" 482 | checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" 483 | dependencies = [ 484 | "aho-corasick", 485 | "memchr", 486 | "regex-syntax", 487 | ] 488 | 489 | [[package]] 490 | name = "regex-syntax" 491 | version = "0.8.5" 492 | source = "registry+https://github.com/rust-lang/crates.io-index" 493 | checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" 494 | 495 | [[package]] 496 | name = "rnix" 497 | version = "0.12.0" 498 | source = "registry+https://github.com/rust-lang/crates.io-index" 499 | checksum = "6f15e00b0ab43abd70d50b6f8cd021290028f9b7fdd7cdfa6c35997173bc1ba9" 500 | dependencies = [ 501 | "rowan", 502 | ] 503 | 504 | [[package]] 505 | name = "rowan" 506 | version = "0.15.16" 507 | source = "registry+https://github.com/rust-lang/crates.io-index" 508 | checksum = "0a542b0253fa46e632d27a1dc5cf7b930de4df8659dc6e720b647fc72147ae3d" 509 | dependencies = [ 510 | "countme", 511 | "hashbrown", 512 | "rustc-hash", 513 | "text-size", 514 | ] 515 | 516 | [[package]] 517 | name = "rustc-hash" 518 | version = "1.1.0" 519 | source = "registry+https://github.com/rust-lang/crates.io-index" 520 | checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" 521 | 522 | [[package]] 523 | name = "rustix" 524 | version = "1.0.1" 525 | source = "registry+https://github.com/rust-lang/crates.io-index" 526 | checksum = "dade4812df5c384711475be5fcd8c162555352945401aed22a35bffeab61f657" 527 | dependencies = [ 528 | "bitflags", 529 | "errno", 530 | "libc", 531 | "linux-raw-sys", 532 | "windows-sys", 533 | ] 534 | 535 | [[package]] 536 | name = "ryu" 537 | version = "1.0.18" 538 | source = "registry+https://github.com/rust-lang/crates.io-index" 539 | checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" 540 | 541 | [[package]] 542 | name = "same-file" 543 | version = "1.0.6" 544 | source = "registry+https://github.com/rust-lang/crates.io-index" 545 | checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" 546 | dependencies = [ 547 | "winapi-util", 548 | ] 549 | 550 | [[package]] 551 | name = "seahash" 552 | version = "4.1.0" 553 | source = "registry+https://github.com/rust-lang/crates.io-index" 554 | checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" 555 | 556 | [[package]] 557 | name = "serde" 558 | version = "1.0.219" 559 | source = "registry+https://github.com/rust-lang/crates.io-index" 560 | checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" 561 | dependencies = [ 562 | "serde_derive", 563 | ] 564 | 565 | [[package]] 566 | name = "serde_derive" 567 | version = "1.0.219" 568 | source = "registry+https://github.com/rust-lang/crates.io-index" 569 | checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" 570 | dependencies = [ 571 | "proc-macro2", 572 | "quote", 573 | "syn", 574 | ] 575 | 576 | [[package]] 577 | name = "serde_json" 578 | version = "1.0.140" 579 | source = "registry+https://github.com/rust-lang/crates.io-index" 580 | checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" 581 | dependencies = [ 582 | "itoa", 583 | "memchr", 584 | "ryu", 585 | "serde", 586 | ] 587 | 588 | [[package]] 589 | name = "strsim" 590 | version = "0.11.1" 591 | source = "registry+https://github.com/rust-lang/crates.io-index" 592 | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 593 | 594 | [[package]] 595 | name = "syn" 596 | version = "2.0.87" 597 | source = "registry+https://github.com/rust-lang/crates.io-index" 598 | checksum = "25aa4ce346d03a6dcd68dd8b4010bcb74e54e62c90c573f394c46eae99aba32d" 599 | dependencies = [ 600 | "proc-macro2", 601 | "quote", 602 | "unicode-ident", 603 | ] 604 | 605 | [[package]] 606 | name = "tempfile" 607 | version = "3.20.0" 608 | source = "registry+https://github.com/rust-lang/crates.io-index" 609 | checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" 610 | dependencies = [ 611 | "fastrand", 612 | "getrandom 0.3.1", 613 | "once_cell", 614 | "rustix", 615 | "windows-sys", 616 | ] 617 | 618 | [[package]] 619 | name = "termtree" 620 | version = "0.4.1" 621 | source = "registry+https://github.com/rust-lang/crates.io-index" 622 | checksum = "3369f5ac52d5eb6ab48c6b4ffdc8efbcad6b89c765749064ba298f2c68a16a76" 623 | 624 | [[package]] 625 | name = "text-size" 626 | version = "1.1.1" 627 | source = "registry+https://github.com/rust-lang/crates.io-index" 628 | checksum = "f18aa187839b2bdb1ad2fa35ead8c4c2976b64e4363c386d45ac0f7ee85c9233" 629 | 630 | [[package]] 631 | name = "thiserror" 632 | version = "2.0.11" 633 | source = "registry+https://github.com/rust-lang/crates.io-index" 634 | checksum = "d452f284b73e6d76dd36758a0c8684b1d5be31f92b89d07fd5822175732206fc" 635 | dependencies = [ 636 | "thiserror-impl", 637 | ] 638 | 639 | [[package]] 640 | name = "thiserror-impl" 641 | version = "2.0.11" 642 | source = "registry+https://github.com/rust-lang/crates.io-index" 643 | checksum = "26afc1baea8a989337eeb52b6e72a039780ce45c3edfcc9c5b9d112feeb173c2" 644 | dependencies = [ 645 | "proc-macro2", 646 | "quote", 647 | "syn", 648 | ] 649 | 650 | [[package]] 651 | name = "unicode-ident" 652 | version = "1.0.13" 653 | source = "registry+https://github.com/rust-lang/crates.io-index" 654 | checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" 655 | 656 | [[package]] 657 | name = "utf8parse" 658 | version = "0.2.2" 659 | source = "registry+https://github.com/rust-lang/crates.io-index" 660 | checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 661 | 662 | [[package]] 663 | name = "wait-timeout" 664 | version = "0.2.0" 665 | source = "registry+https://github.com/rust-lang/crates.io-index" 666 | checksum = "9f200f5b12eb75f8c1ed65abd4b2db8a6e1b138a20de009dacee265a2498f3f6" 667 | dependencies = [ 668 | "libc", 669 | ] 670 | 671 | [[package]] 672 | name = "walkdir" 673 | version = "2.5.0" 674 | source = "registry+https://github.com/rust-lang/crates.io-index" 675 | checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" 676 | dependencies = [ 677 | "same-file", 678 | "winapi-util", 679 | ] 680 | 681 | [[package]] 682 | name = "wasi" 683 | version = "0.11.0+wasi-snapshot-preview1" 684 | source = "registry+https://github.com/rust-lang/crates.io-index" 685 | checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" 686 | 687 | [[package]] 688 | name = "wasi" 689 | version = "0.13.3+wasi-0.2.2" 690 | source = "registry+https://github.com/rust-lang/crates.io-index" 691 | checksum = "26816d2e1a4a36a2940b96c5296ce403917633dff8f3440e9b236ed6f6bacad2" 692 | dependencies = [ 693 | "wit-bindgen-rt", 694 | ] 695 | 696 | [[package]] 697 | name = "winapi" 698 | version = "0.3.9" 699 | source = "registry+https://github.com/rust-lang/crates.io-index" 700 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 701 | dependencies = [ 702 | "winapi-i686-pc-windows-gnu", 703 | "winapi-x86_64-pc-windows-gnu", 704 | ] 705 | 706 | [[package]] 707 | name = "winapi-i686-pc-windows-gnu" 708 | version = "0.4.0" 709 | source = "registry+https://github.com/rust-lang/crates.io-index" 710 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 711 | 712 | [[package]] 713 | name = "winapi-util" 714 | version = "0.1.9" 715 | source = "registry+https://github.com/rust-lang/crates.io-index" 716 | checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" 717 | dependencies = [ 718 | "windows-sys", 719 | ] 720 | 721 | [[package]] 722 | name = "winapi-x86_64-pc-windows-gnu" 723 | version = "0.4.0" 724 | source = "registry+https://github.com/rust-lang/crates.io-index" 725 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 726 | 727 | [[package]] 728 | name = "windows-sys" 729 | version = "0.59.0" 730 | source = "registry+https://github.com/rust-lang/crates.io-index" 731 | checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 732 | dependencies = [ 733 | "windows-targets", 734 | ] 735 | 736 | [[package]] 737 | name = "windows-targets" 738 | version = "0.52.6" 739 | source = "registry+https://github.com/rust-lang/crates.io-index" 740 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 741 | dependencies = [ 742 | "windows_aarch64_gnullvm", 743 | "windows_aarch64_msvc", 744 | "windows_i686_gnu", 745 | "windows_i686_gnullvm", 746 | "windows_i686_msvc", 747 | "windows_x86_64_gnu", 748 | "windows_x86_64_gnullvm", 749 | "windows_x86_64_msvc", 750 | ] 751 | 752 | [[package]] 753 | name = "windows_aarch64_gnullvm" 754 | version = "0.52.6" 755 | source = "registry+https://github.com/rust-lang/crates.io-index" 756 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 757 | 758 | [[package]] 759 | name = "windows_aarch64_msvc" 760 | version = "0.52.6" 761 | source = "registry+https://github.com/rust-lang/crates.io-index" 762 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 763 | 764 | [[package]] 765 | name = "windows_i686_gnu" 766 | version = "0.52.6" 767 | source = "registry+https://github.com/rust-lang/crates.io-index" 768 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 769 | 770 | [[package]] 771 | name = "windows_i686_gnullvm" 772 | version = "0.52.6" 773 | source = "registry+https://github.com/rust-lang/crates.io-index" 774 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 775 | 776 | [[package]] 777 | name = "windows_i686_msvc" 778 | version = "0.52.6" 779 | source = "registry+https://github.com/rust-lang/crates.io-index" 780 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 781 | 782 | [[package]] 783 | name = "windows_x86_64_gnu" 784 | version = "0.52.6" 785 | source = "registry+https://github.com/rust-lang/crates.io-index" 786 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 787 | 788 | [[package]] 789 | name = "windows_x86_64_gnullvm" 790 | version = "0.52.6" 791 | source = "registry+https://github.com/rust-lang/crates.io-index" 792 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 793 | 794 | [[package]] 795 | name = "windows_x86_64_msvc" 796 | version = "0.52.6" 797 | source = "registry+https://github.com/rust-lang/crates.io-index" 798 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 799 | 800 | [[package]] 801 | name = "wit-bindgen-rt" 802 | version = "0.33.0" 803 | source = "registry+https://github.com/rust-lang/crates.io-index" 804 | checksum = "3268f3d866458b787f390cf61f4bbb563b922d091359f9608842999eaee3943c" 805 | dependencies = [ 806 | "bitflags", 807 | ] 808 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | resolver = "2" 3 | members = [ 4 | # Libraries. 5 | "nix-script-directives", 6 | 7 | # Binaries. 8 | "nix-script", 9 | "nix-script-haskell", 10 | ] 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2021 Brian Hicks 2 | 3 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 4 | 5 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 6 | 7 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 8 | 9 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 10 | 11 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This version of `nix-script` is a fork of the [original version by Brian 2 | Hicks](https://github.com/BrianHicks/nix-script) which has been flagged 3 | unmaintained. I am fixing bugs and regressions caused by the Rust ecosystem 4 | moving on, but I am currently not adding new functionality. 5 | 6 | Contact me, if you want to participate and improve `nix-script`! 7 | 8 | Version 3.0.0 is the first release by myself (Dominik Schrempf). See the 9 | [Changelog](./CHANGELOG.md) for more information. 10 | 11 | Please also consider using [`Magix`](#Magix), see below. 12 | 13 | # Nix-script 14 | 15 | With `nix-script`, you can 16 | - write quick scripts in compiled languages, 17 | - transparently compile and cache them, and 18 | - pull in whatever dependencies you need from the Nix ecosystem. 19 | 20 | Please also see the [blog post by the original author Brian Hicks explaining 21 | `nix-script`](https://bytes.zone/posts/nix-script/). 22 | 23 | # Installation 24 | 25 | ## Installation to your profile 26 | 27 | ``` 28 | nix-env -if https://github.com/dschrempf/nix-script/archive/main.tar.gz 29 | ``` 30 | 31 | This project's CI also pushes Linux and macOS builds to 32 | [`dschrempf-nix-script.cachix.org`](https://app.cachix.org/cache/dschrempf-nix-script) 33 | automatically, meaning `cachix add dschrempf-nix-script` should set you up. 34 | 35 | ## Installation with Flakes 36 | 37 | Use the `nix-script` package Flake output which combines `nix-script`, 38 | `nix-script-bash`, and `nix-script-haskell`. 39 | 40 | We also provide a Nixpkgs `overlay` providing the `nix-script` package. 41 | 42 | ## Installation with Niv 43 | 44 | 1. Add `nix-script` to Niv with `niv add BrianHicks/nix-script`. 45 | 2. Then, `import sources.nix-script { };`. 46 | 3. Use `nix-script` or the overlay; you will have to explicitly reference the 47 | current system like so: `overlay."${builtins.currentSystem}"`. 48 | 49 | # Commands 50 | 51 | ## `nix-script` 52 | 53 | The normal `nix-script` invocation is controlled using shebang directives (lines 54 | starting with `#!` by default, although you can change the indicator with the 55 | `--indicator` flag). 56 | 57 | Starting your file with `#!/usr/bin/env nix-script` makes these options 58 | available: 59 | 60 | | What? | Shebang line | Notes | 61 | |---------------------------------------|-------------------|-----------------------------------------------------------------------------------| 62 | | How to compile the script to a binary | `#!build` | The command specified here must read from `$SRC` and write to `$OUT` | 63 | | Use all files in the given directory | `#!buildRoot` | Must be a parent directory of the script | 64 | | Specify build-time dependencies | `#!buildInputs` | A space-separated list of Nix expressions | 65 | | Use an alternative interpreter | `#!interpreter` | Run this script with the given binary (must be in `runtimeInputs`) | 66 | | Specify runtime dependencies | `#!runtimeInputs` | This should be a space-separated list of Nix expressions. | 67 | | Access auxillary files at runtime | `#!runtimeFiles` | Make these files available at runtime (at the path given in `RUNTIME_FILES_ROOT`) | 68 | 69 | You can also control these options with equivalent command-line flags to 70 | `nix-script` (see the `--help` output for exact names). 71 | 72 | `nix-script` also lets your compiled script know the original location by 73 | setting the `SCRIPT_FILE` environment variable to what you would have gotten in 74 | `$0` if it had been a shell script. 75 | 76 | ### Shell Mode 77 | 78 | Building a new version for every change can get tiresome while developing. If 79 | you want a quicker feedback loop, you can include `--shell` in your `nix-script` 80 | invocation (e.g. `nix-script --shell path/to/script`) to drop into a development 81 | shell with your build-time and runtime dependencies. This won't run your build 82 | command, but it will let you run it yourself, play around in REPLs, etc. 83 | 84 | If you are making a wrapper script, you may find the `--run` flag useful: it 85 | allows you to specify what command to run in the shell. If your language 86 | ecosystem has some common watcher script, it might be nice to add a special mode 87 | to your wrapper! (For example, `nix-script-haskell` has a `--ghcid` flag for 88 | this purpose). 89 | 90 | ### Exporting a script 91 | 92 | Version 2 of `nix-script` introduced two new flags: `--build-root` and 93 | `--export` to handle multiple files. In detail, if your script needs multiple 94 | files, tell `nix-script` about the project root with `#!buildRoot` (or 95 | `--build-root`), and it will include all the files in that directory during 96 | builds. 97 | 98 | You can also export (`--export`) the Nix derivation `default.nix` created by 99 | `nix-script`. If you put that file (or any `default.nix`) in your build root, 100 | `nix-script` will use that one instead of generating a new one. 101 | 102 | Once you get to the point of having a directory with a `default.nix`, you have 103 | arrived at a "real" derivation, and you may use any Nix tooling to further 104 | modify your project. 105 | 106 | ### Parsing Directives 107 | 108 | If you are making a wrapper script for a new language, you can also use 109 | `--build-root` to hold package manager files and custom `build.nix` files. We 110 | also provide a `--parse` flag which will ask `nix-script` to parse any 111 | directives in the script and give them to you as JSON on stdout. 112 | 113 | **Caution:** be aware that the format here is not stable yet. If you have any 114 | feedback on the data returned by `--parse`, please open an issue! 115 | 116 | ## `nix-script-bash` 117 | 118 | `nix-script-bash` lets you specify dependencies of Bash scripts. For example: 119 | 120 | ```bash 121 | #!/usr/bin/env nix-script-bash 122 | #!runtimeInputs jq 123 | 124 | jq --help 125 | ``` 126 | 127 | ## `nix-script-haskell` 128 | 129 | `nix-script-haskell` is a convenience wrapper for Haskell scripts. In addition 130 | to the regular `nix-script` options, `nix-script-haskell` has some 131 | Haskell-specific options: 132 | 133 | | Shebang line | Notes | Example | 134 | |---------------------|---------------------------------------------------------------------------------------------------------------|--------------------------------| 135 | | `#!haskellPackages` | Haskell dependencies (you can get a list of available packages with `nix-env -qaPA nixpkgs.haskellPackages`.) | `#!haskellPackages text aeson` | 136 | | `#!ghcFlags` | Additional compiler flags. | `#!ghcFlags -threaded` | 137 | 138 | You can get compilation feedback with [`ghcid`](https://github.com/ndmitchell/ghcid) by running `nix-script-haskell --ghcid path/to/your/script.hs`. 139 | 140 | # Controlling the Nixpkgs version 141 | 142 | `nix-script` will generate derivations that `import {}` by default. 143 | This means your scripts are built with the Nixpkgs version set in the `NIX_PATH` 144 | environment variable. 145 | 146 | For example, you can use a specific Nixpkgs version available in your Nix store with 147 | 148 | ``` 149 | NIX_PATH=nixpkgs=/nix/store/HASHHASHHASH-source 150 | ``` 151 | 152 | The `NIX_PATH` environment variable is included in cache key calculations, so if 153 | you change your package set your scripts will automatically be rebuilt the next 154 | time you run them. 155 | 156 | # Climate Action 157 | 158 | The original author Brian Hicks has added the following note which I fully 159 | support: 160 | 161 | > I want my open-source work to support projects addressing the climate crisis 162 | > (for example, projects in clean energy, public transit, reforestation, or 163 | > sustainable agriculture.) If you are working on such a project, and find a bug 164 | > or missing feature in any of my libraries, **please let me know and I will 165 | > treat your issue as high priority.** I'd also be happy to support such 166 | > projects in other ways, just ask! 167 | 168 | # License 169 | 170 | `nix-script` is licensed under the BSD 3-Clause license, located at `LICENSE`. 171 | 172 | # Magix 173 | 174 | My Rust is a bit rusty, and `nix-script` is a complex project. Maybe a bit more 175 | complex than it needs to be. I tried building a simpler (but also less 176 | feature-rich) alternative in Haskell: 177 | [Magix](https://github.com/dschrempf/magix). 178 | -------------------------------------------------------------------------------- /SPEC.md: -------------------------------------------------------------------------------- 1 | # Specification 2 | 3 | `nix-script` is a program that brings the power and utility of Nix to scripting tasks. 4 | It transparently compiles and caches both binaries and script dependencies to keep invocation fast. 5 | 6 | ## Reading this Document 7 | 8 | This document describes nix-script version 2, a complete rewrite of nix-script version 1. 9 | 10 | Each section is tagged with done-ness and determined-ness, with one of these values: 11 | 12 | - *implemented*: this feature is implemented as specified in the section below 13 | - *partially implemented*: this feature is not completely done. 14 | Details will be given. 15 | - *defined*: this feature is ready to implemented. 16 | Details may change as we discover the system. 17 | - *partially defined*: this feature still needs to be thought through completely. 18 | It may be partially implemented as a spike. 19 | This will be noted if so. 20 | - *speculative*: this would be nice to have, but we need to think through it a lot more first. 21 | 22 | ## Transformation to Derivations 23 | 24 | *status: implemented* 25 | 26 | `nix-script` parses extra shebang (`#!`) lines into arguments to `mkDerivation`. 27 | This set of shebangs, when placed in `cool-script`: 28 | 29 | ```haskell 30 | #!/usr/bin/env nix-script 31 | #!buildInputs (haskellPackages.ghcWithPackages (ps: [ ps.aeson ps.text ])) 32 | #!runtimeInputs jq 33 | #!buildPhase mv $SRC $SRC.hs; ghc -o $OUT $SRC.hs 34 | ``` 35 | 36 | ... would produce a derivation that looked approximately like this: 37 | 38 | ```nix 39 | { pkgs ? import { }, jq ? pkgs.jq }: 40 | pkgs.stdenv.mkDerivation { 41 | name = "cool-script"; 42 | 43 | src = ./.; # actual implementation filters for `cool-script.hs` 44 | 45 | buildInputs = [ (pkgs.haskellPackages.ghcWithPackages (ps: [ ps.aeson ps.text ])) ]; 46 | buildPhase = '' 47 | SRC=cool-script 48 | 49 | mkdir bin 50 | OUT=bin/cool-script 51 | 52 | # specified buildPhase below 53 | mv $SRC $SRC.hs; ghc -o $OUT $SRC.hs 54 | ''; 55 | 56 | installPhase = '' 57 | mkdir -p $out 58 | mv bin $out/bin 59 | 60 | makeWrapper cool-script $out/bin/cool-script \ 61 | --argv0 cool-script \ 62 | --prefix PATH : ${pkgs.lib.makeBinPath [ jq ]} 63 | ''; 64 | } 65 | ``` 66 | 67 | You can also control these settings with flags (e.g. `nix-script --build-command ...`) or add to the directives specified in the source (e.g. `nix-script --runtime-input ...`.) 68 | Command-line arguments always take precedence, then shebangs. 69 | 70 | ### Keys Accepted 71 | 72 | *status: implemented* 73 | 74 | | `#!` line | Meaning | Notes | 75 | |-------------------|-----------------------------------------------|----------------------------------------------------------------------------------------------------------------------| 76 | | `#!build` | build command for script | should read from `$INPUT` and write to `$OUTPUT`. Will be run in the source directory. | 77 | | `#!buildRoot` | build step will include all source here | must be a parent directory of the script | 78 | | `#!buildInputs` | build inputs, as a Nix list | e.g. `buildInputs = [ the-thing-you-specify ];`. | 79 | | `#!runtimeInputs` | runtime inputs, as a Nix list | see note on `buildInputs`. | 80 | | `#!interpreter` | interpret "built" binary with this script | Must be a binary which accepts at least one argument (the build source). Binary must be provided by `runtimeInputs`. | 81 | | `#!runtimeFiles` | files or directories to include at build time | multiple calls will be merged. | 82 | 83 | ### What about environment variables as inputs? 84 | 85 | *status: defined* 86 | 87 | In `nix-script` version 1, we also accepted environment variables like `BUILD_COMMAND` and `RUNTIME_INPUTS`. 88 | These were mostly useful for writing wrapper scripts, but in `nix-script` version 2, we do that differently. 89 | However, if this ends up being something that breaks your workflow please open an issue and we'll see what we can do here. 90 | 91 | ### Lifting inputs 92 | 93 | *status: implemented* 94 | 95 | In the example above, `buildInputs` is not lifted to the top-level function arguments, but `runtimeInputs` is. 96 | To do this, we parse these shebangs as a list. 97 | Items that are expressions are left alone and items that appear to be references are lifted to the inputs. 98 | 99 | ### Exporting 100 | 101 | *status: implemented* 102 | 103 | Running `nix-script --export --build-root path/to path/to/script.sh` will print the derivation to stdout instead of building it. 104 | We intend here to provide a mechanism for things like import-from-derivation. 105 | 106 | ## Caching 107 | 108 | *status: implemented* 109 | 110 | `nix-script` manages a directory of symlinks for caching. 111 | The names of these links are script hashes and the targets are locations under `/nix/store`. 112 | 113 | When `nix-script` is invoked, the basic operation is to: 114 | 115 | 1. create a hash of the script, based on parameters 116 | 1. build, if necessary 117 | 2. run the built derivation 118 | 119 | "if necessary" here can mean a couple of different things: 120 | 121 | 1. there was not a match in the nix-script cache for the given hash 122 | 2. there was a match, but the binary is missing (e.g. because `nix-collect-garbage` removed it) 123 | 124 | ### Hash Calculation 125 | 126 | *status: implemented* 127 | 128 | The hash includes: 129 | 130 | - the bytes of the script source 131 | - the directives calculated between script source and command-line flags 132 | - bytes of any files in the file specified by `--build-root` 133 | 134 | ## Shell mode 135 | 136 | *status: implemented* 137 | 138 | You can get into a bash shell with all your script's build-time dependencies by calling `nix-script --shell path-to-your-script`. 139 | 140 | Shell mode implements many of the same command-line flags that `nix-shell` does. 141 | For example: 142 | 143 | - `--packages / -p PACKAGES...` to include the specified packages in the shel 144 | - `--run CMD` to run a command in the shell 145 | - `--pure` to do the equivalent of `nix-shell --pure` (that is: ignore your `PATH` and some other things) 146 | 147 | Run `nix-script --help` to see the full set of commands. 148 | 149 | ## Wrapper Scripts 150 | 151 | *status: speculative* 152 | 153 | If `nix-script` is equivalent to `mkDerivation`, wrapper scripts are equivalent to [the language and framework support in nixpkgs](https://nixos.org/manual/nixpkgs/stable/#chap-language-support). 154 | 155 | Wrapper scripts are expected to accept parsed shebang lines as environment variables and invoke `nix-script`. 156 | 157 | For example, here's how you'd define `nix-script-bash`: 158 | 159 | ```sh 160 | #!/usr/bin/env bash 161 | set -euo pipefail 162 | 163 | nix-script --build 'cp $SRC $OUT; chmod +x $OUT' --runtime-inputs bash "$@" 164 | ``` 165 | 166 | Wrapper scripts may also use `nix-script` to manage their own dependencies. 167 | 168 | ### Parsing shebangs 169 | 170 | *status: partially implemented* (schema not finalized; breaking schema changes will not trigger a major version bump) 171 | 172 | To help writing wrapper scripts, `nix-script` also provides a way to extract the shebang lines from a source file. 173 | For example: `nix-script --parse $1` in the script above, assuming no other arguments existed. 174 | 175 | For example: 176 | 177 | ```json 178 | { 179 | "build_command": "mv $SRC $SRC.hs; ghc -o $OUT $SRC.hs", 180 | "build_root": null, 181 | "build_inputs": [ 182 | { 183 | "raw": "haskellPackages.ghcWithPackages (ps: [ ps.text ])" 184 | } 185 | ], 186 | "interpreter": null, 187 | "runtime_inputs": [], 188 | "runtime_files": [], 189 | "raw": { 190 | "buildInputs": [ 191 | "(haskellPackages.ghcWithPackages (ps: [ ps.text ]))" 192 | ], 193 | "/usr/bin/env": [ 194 | "nix-script" 195 | ], 196 | "build": [ 197 | "mv $SRC $SRC.hs; ghc -o $OUT $SRC.hs" 198 | ] 199 | } 200 | } 201 | ``` 202 | 203 | ## Runtime Variables 204 | 205 | *status: implemented* (but this list may grow) 206 | 207 | We set some environment variables that the script can access at runtime: 208 | 209 | | Variable | Meaning | 210 | |----------------------|--------------------------------------------------------------------------------------------------------------------| 211 | | `RUNTIME_FILES_ROOT` | If `#!runtimeFiles` or `--runtime-files` was specified, this is set to where we put them. | 212 | | `SCRIPT_FILE` | the name of the script as originally invoked (name is awkward but remains for compatibility with nix-script 1.0.0) | 213 | -------------------------------------------------------------------------------- /default.nix: -------------------------------------------------------------------------------- 1 | (import ( 2 | let 3 | lock = builtins.fromJSON (builtins.readFile ./flake.lock); 4 | in 5 | fetchTarball { 6 | url = "https://github.com/edolstra/flake-compat/archive/${lock.nodes.flake-compat.locked.rev}.tar.gz"; 7 | sha256 = lock.nodes.flake-compat.locked.narHash; 8 | } 9 | ) { src = ./.; }).defaultNix 10 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-compat": { 4 | "flake": false, 5 | "locked": { 6 | "lastModified": 1747046372, 7 | "narHash": "sha256-CIVLLkVgvHYbgI2UpXvIIBJ12HWgX+fjA8Xf8PUmqCY=", 8 | "owner": "edolstra", 9 | "repo": "flake-compat", 10 | "rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885", 11 | "type": "github" 12 | }, 13 | "original": { 14 | "owner": "edolstra", 15 | "repo": "flake-compat", 16 | "type": "github" 17 | } 18 | }, 19 | "flake-utils": { 20 | "inputs": { 21 | "systems": "systems" 22 | }, 23 | "locked": { 24 | "lastModified": 1731533236, 25 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 26 | "owner": "numtide", 27 | "repo": "flake-utils", 28 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 29 | "type": "github" 30 | }, 31 | "original": { 32 | "owner": "numtide", 33 | "repo": "flake-utils", 34 | "type": "github" 35 | } 36 | }, 37 | "naersk": { 38 | "inputs": { 39 | "nixpkgs": [ 40 | "nixpkgs" 41 | ] 42 | }, 43 | "locked": { 44 | "lastModified": 1745925850, 45 | "narHash": "sha256-cyAAMal0aPrlb1NgzMxZqeN1mAJ2pJseDhm2m6Um8T0=", 46 | "owner": "nix-community", 47 | "repo": "naersk", 48 | "rev": "38bc60bbc157ae266d4a0c96671c6c742ee17a5f", 49 | "type": "github" 50 | }, 51 | "original": { 52 | "owner": "nix-community", 53 | "repo": "naersk", 54 | "type": "github" 55 | } 56 | }, 57 | "nixpkgs": { 58 | "locked": { 59 | "lastModified": 1748248602, 60 | "narHash": "sha256-LanRAm0IRpL36KpCKSknEwkBFvTLc9mDHKeAmfTrHwg=", 61 | "owner": "NixOS", 62 | "repo": "nixpkgs", 63 | "rev": "ad331efcaf680eb1c838cb339472399ea7b3cdab", 64 | "type": "github" 65 | }, 66 | "original": { 67 | "owner": "NixOS", 68 | "ref": "nixpkgs-unstable", 69 | "repo": "nixpkgs", 70 | "type": "github" 71 | } 72 | }, 73 | "root": { 74 | "inputs": { 75 | "flake-compat": "flake-compat", 76 | "flake-utils": "flake-utils", 77 | "naersk": "naersk", 78 | "nixpkgs": "nixpkgs" 79 | } 80 | }, 81 | "systems": { 82 | "locked": { 83 | "lastModified": 1681028828, 84 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 85 | "owner": "nix-systems", 86 | "repo": "default", 87 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 88 | "type": "github" 89 | }, 90 | "original": { 91 | "owner": "nix-systems", 92 | "repo": "default", 93 | "type": "github" 94 | } 95 | } 96 | }, 97 | "root": "root", 98 | "version": 7 99 | } 100 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | inputs = { 3 | # Required when installing `nix-script` without Flakes. See `default.nix`. 4 | flake-compat = { 5 | url = "github:edolstra/flake-compat"; 6 | flake = false; 7 | }; 8 | 9 | flake-utils.url = "github:numtide/flake-utils"; 10 | 11 | naersk.url = "github:nix-community/naersk"; 12 | naersk.inputs.nixpkgs.follows = "nixpkgs"; 13 | 14 | nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; 15 | }; 16 | 17 | outputs = 18 | { 19 | self, 20 | flake-utils, 21 | naersk, 22 | nixpkgs, 23 | ... 24 | }: 25 | { 26 | overlay = 27 | final: prev: 28 | let 29 | naerskLib = naersk.lib."${final.system}"; 30 | nixScript = naerskLib.buildPackage rec { 31 | name = "nix-script"; 32 | version = "3.0.0"; 33 | 34 | root = ./.; 35 | 36 | nativeBuildInputs = [ prev.clippy ]; 37 | 38 | preBuild = '' 39 | # Make sure the version of the packages and the Nix derivations match. 40 | grep -q -e 'version = "${version}"' ${name}/Cargo.toml || \ 41 | (echo "Nix Flake version mismatch ${version}!" && exit 1) 42 | ''; 43 | 44 | doCheck = true; 45 | checkPhase = '' 46 | cargo clippy -- --deny warnings 47 | ''; 48 | }; 49 | nixScriptBash = prev.writeShellScriptBin "nix-script-bash" '' 50 | exec ${nixScript}/bin/nix-script \ 51 | --build-command 'cp $SRC $OUT' \ 52 | --interpreter bash \ 53 | "$@" 54 | ''; 55 | nixScriptAll = prev.symlinkJoin { 56 | name = "nix-script-all"; 57 | paths = [ 58 | nixScript 59 | nixScriptBash 60 | ]; 61 | }; 62 | in 63 | { 64 | nix-script = nixScriptAll; 65 | }; 66 | } 67 | // flake-utils.lib.eachDefaultSystem ( 68 | system: 69 | let 70 | pkgs = import nixpkgs { 71 | inherit system; 72 | overlays = [ self.overlay ]; 73 | }; 74 | in 75 | { 76 | packages = { 77 | nix-script = pkgs.nix-script; 78 | }; 79 | 80 | defaultPackage = pkgs.nix-script; 81 | 82 | devShell = pkgs.mkShell { 83 | NIX_PKGS = nixpkgs; 84 | packages = 85 | with pkgs; 86 | [ 87 | # Rust. 88 | cargo 89 | clippy 90 | rustc 91 | rustfmt 92 | rust-analyzer 93 | 94 | # External Cargo commands. 95 | cargo-audit 96 | cargo-edit 97 | cargo-udeps 98 | ] 99 | ++ (lib.optionals stdenv.isDarwin [ libiconv ]); 100 | }; 101 | } 102 | ); 103 | } 104 | -------------------------------------------------------------------------------- /nix-script-directives/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "nix-script-directives" 3 | version = "3.0.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | anyhow = "1.0.98" 8 | log = "0.4.27" 9 | rnix = "0.12.0" 10 | rowan = "0.15.16" 11 | serde = { version = "1.0.219", features = [ "derive" ] } 12 | -------------------------------------------------------------------------------- /nix-script-directives/src/expr.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result}; 2 | use core::hash::{Hash, Hasher}; 3 | use rnix::ast::List; 4 | use rnix::{Root, SyntaxKind, SyntaxNode}; 5 | use rowan::ast::AstNode; 6 | use std::cmp::Ordering; 7 | use std::fmt::{self, Display}; 8 | use std::str::FromStr; 9 | 10 | #[derive(Debug, Eq, serde::Serialize, Clone)] 11 | pub struct Expr { 12 | raw: String, 13 | #[serde(skip)] 14 | parsed: SyntaxNode, 15 | } 16 | 17 | impl Expr { 18 | pub fn parse_as_list(source: &str) -> Result> { 19 | let root: Root = Root::parse(&format!("[{source}]")) 20 | .ok() 21 | .context("could not parse Nix expression as list")?; 22 | let syntax_node: SyntaxNode = root 23 | .expr() 24 | .expect("root of ast should have a child") 25 | .syntax() 26 | .clone(); 27 | let list: List = List::cast(syntax_node).context("could not cast syntax node to list")?; 28 | 29 | Ok(list 30 | .items() 31 | .map(|e| Self::from(e.syntax().clone())) 32 | .collect()) 33 | } 34 | 35 | pub fn kind(&self) -> SyntaxKind { 36 | self.parsed.kind() 37 | } 38 | 39 | pub fn is_leaf(&self) -> bool { 40 | self.kind() == SyntaxKind::NODE_IDENT 41 | } 42 | } 43 | 44 | impl Display for Expr { 45 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> { 46 | write!(f, "{}", self.raw) 47 | } 48 | } 49 | 50 | impl PartialEq for Expr { 51 | fn eq(&self, other: &Self) -> bool { 52 | self.raw == other.raw 53 | } 54 | } 55 | 56 | impl Ord for Expr { 57 | fn cmp(&self, other: &Self) -> Ordering { 58 | self.raw.cmp(&other.raw) 59 | } 60 | } 61 | 62 | impl PartialOrd for Expr { 63 | fn partial_cmp(&self, other: &Self) -> Option { 64 | Some(self.cmp(other)) 65 | } 66 | } 67 | 68 | impl Hash for Expr { 69 | fn hash(&self, hasher: &mut H) { 70 | hasher.write(self.raw.as_ref()) 71 | } 72 | } 73 | 74 | impl FromStr for Expr { 75 | type Err = anyhow::Error; 76 | 77 | fn from_str(source: &str) -> Result { 78 | Ok(Self::from( 79 | Root::parse(source) 80 | .ok() 81 | .context("could not parse Nix expression")? 82 | .expr() 83 | .expect("root of AST should not be empty") 84 | .syntax() 85 | .clone(), 86 | )) 87 | } 88 | } 89 | 90 | /// Unwrap parentheses when converting from a [`SyntaxNode`]. 91 | impl From for Expr { 92 | fn from(outer: SyntaxNode) -> Expr { 93 | if outer.kind() == SyntaxKind::NODE_PAREN { 94 | if let Some(inner) = outer.children().next() { 95 | return Self::from(inner); 96 | } 97 | } 98 | 99 | Self { 100 | raw: outer.to_string(), 101 | parsed: outer, 102 | } 103 | } 104 | } 105 | 106 | unsafe impl Send for Expr {} 107 | 108 | unsafe impl Sync for Expr {} 109 | 110 | #[cfg(test)] 111 | mod tests { 112 | use super::*; 113 | 114 | mod equality { 115 | use super::*; 116 | 117 | #[test] 118 | fn equal_if_raw_is_equal() { 119 | assert_eq!(Expr::from_str("a").unwrap(), Expr::from_str("a").unwrap()) 120 | } 121 | 122 | #[test] 123 | fn unequal_if_raw_is_unequal() { 124 | assert!(Expr::from_str("a").unwrap() != Expr::from_str("b").unwrap()) 125 | } 126 | } 127 | 128 | mod parse { 129 | use super::*; 130 | 131 | #[test] 132 | fn accepts_valid() { 133 | assert!(Expr::from_str("a").is_ok()) 134 | } 135 | 136 | #[test] 137 | fn rejects_invalid() { 138 | assert!(Expr::from_str("[").is_err()) 139 | } 140 | 141 | #[test] 142 | fn unwraps_root() { 143 | assert!(Expr::from_str("a").unwrap().is_leaf()) 144 | } 145 | 146 | #[test] 147 | fn unwraps_parens() { 148 | assert!(Expr::from_str("(a)").unwrap().is_leaf()) 149 | } 150 | 151 | #[test] 152 | fn unwraps_all_parens() { 153 | assert!(Expr::from_str("((a))").unwrap().is_leaf()) 154 | } 155 | } 156 | 157 | mod parse_as_list { 158 | use super::*; 159 | 160 | #[test] 161 | fn single_item() { 162 | let exprs = Expr::parse_as_list("a").unwrap(); 163 | 164 | assert_eq!(1, exprs.len()); 165 | assert_eq!("a", exprs[0].raw); 166 | } 167 | 168 | #[test] 169 | fn multiple_items() { 170 | let exprs = Expr::parse_as_list("a b").unwrap(); 171 | 172 | assert_eq!(2, exprs.len()); 173 | assert_eq!("a", exprs[0].raw); 174 | assert_eq!("b", exprs[1].raw); 175 | } 176 | } 177 | 178 | mod is_leaf { 179 | use super::*; 180 | 181 | #[test] 182 | fn ident_yes() { 183 | let expr = Expr::from_str("a").unwrap(); 184 | assert!(expr.is_leaf()); 185 | } 186 | 187 | #[test] 188 | fn apply_no() { 189 | let haskell_env = "haskellPackages.ghcWithPackages (ps: [ ps.text ])"; 190 | let expr = Expr::from_str(haskell_env).unwrap(); 191 | assert!(!expr.is_leaf()); 192 | } 193 | } 194 | 195 | mod display { 196 | use super::*; 197 | 198 | #[test] 199 | fn same_as_node() { 200 | let parsed = Expr::from_str("a b c").unwrap(); 201 | assert_eq!(parsed.to_string(), parsed.parsed.to_string()); 202 | } 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /nix-script-directives/src/lib.rs: -------------------------------------------------------------------------------- 1 | #[warn(clippy::cargo)] 2 | pub mod expr; 3 | mod parser; 4 | 5 | use crate::expr::Expr; 6 | use anyhow::{Context, Result}; 7 | use core::hash::{Hash, Hasher}; 8 | use parser::Parser; 9 | use rnix::SyntaxKind; 10 | use std::collections::HashMap; 11 | use std::path::Path; 12 | use std::path::PathBuf; 13 | use std::str::FromStr; 14 | 15 | #[derive(Debug, serde::Serialize)] 16 | pub struct Directives { 17 | pub build_command: Option, 18 | pub build_root: Option, 19 | pub build_inputs: Vec, 20 | pub interpreter: Option, 21 | pub runtime_inputs: Vec, 22 | pub runtime_files: Vec, 23 | pub nixpkgs_config: Option, 24 | pub all: HashMap>, 25 | } 26 | 27 | impl Directives { 28 | pub fn from_file(indicator: &str, filename: &Path) -> Result { 29 | let source = std::fs::read_to_string(filename).context("could not read source")?; 30 | Self::parse(indicator, &source) 31 | } 32 | 33 | fn parse(indicator: &str, source: &str) -> Result { 34 | let parser = Parser::new(indicator).context("could not construct parser")?; 35 | let fields = parser.parse(source); 36 | 37 | Self::from_directives(fields) 38 | } 39 | 40 | fn from_directives(fields: HashMap<&str, Vec<&str>>) -> Result { 41 | let build_command = Self::once("build", &fields)?.map(|s| s.to_owned()); 42 | let build_root = Self::once("buildRoot", &fields)?.map(PathBuf::from); 43 | let build_inputs = Self::exprs("buildInputs", &fields)?; 44 | let interpreter = Self::once("interpreter", &fields)?.map(|s| s.to_owned()); 45 | let runtime_inputs = Self::exprs("runtimeInputs", &fields)?; 46 | let runtime_files = Self::files("runtimeFiles", &fields); 47 | let nixpkgs_config = Self::once_attrset("nixpkgsConfig", &fields)?; 48 | 49 | Ok(Directives { 50 | build_command, 51 | build_root, 52 | build_inputs, 53 | interpreter, 54 | runtime_inputs, 55 | runtime_files, 56 | nixpkgs_config, 57 | all: fields 58 | .iter() 59 | .map(|(k, v)| (k.to_string(), v.iter().map(|s| s.to_string()).collect())) 60 | .collect(), 61 | }) 62 | } 63 | 64 | fn once<'field>( 65 | field: &'field str, 66 | fields: &HashMap<&'field str, Vec<&'field str>>, 67 | ) -> Result> { 68 | match fields.get(field) { 69 | Some(value) => { 70 | if value.len() != 1 { 71 | anyhow::bail!("multiple `{}` directives but need exactly one", field); 72 | } 73 | 74 | Ok(Some(value[0])) 75 | } 76 | None => Ok(None), 77 | } 78 | } 79 | 80 | fn once_attrset<'field>( 81 | field: &'field str, 82 | fields: &HashMap<&'field str, Vec<&'field str>>, 83 | ) -> Result> { 84 | match Self::once(field, fields)? { 85 | Some(raw_options) => { 86 | let parsed = Expr::from_str(raw_options) 87 | .with_context(|| format!("could not parse `{field}` as a Nix expression"))?; 88 | 89 | match parsed.kind() { 90 | SyntaxKind::NODE_ATTR_SET => Ok(Some(parsed)), 91 | other => anyhow::bail!( 92 | "`{}` directive should be a Nix record but is a `{:?}`", 93 | field, 94 | other 95 | ), 96 | } 97 | } 98 | None => Ok(None), 99 | } 100 | } 101 | 102 | fn exprs<'field>( 103 | field: &'field str, 104 | fields: &HashMap<&'field str, Vec<&'field str>>, 105 | ) -> Result> { 106 | match fields.get(field) { 107 | None => Ok(Vec::new()), 108 | Some(lines) => { 109 | Expr::parse_as_list(&lines.join(" ")).context("could not parse runtime inputs") 110 | } 111 | } 112 | } 113 | 114 | fn files<'field>( 115 | field: &'field str, 116 | fields: &HashMap<&'field str, Vec<&'field str>>, 117 | ) -> Vec { 118 | match fields.get(field) { 119 | None => Vec::new(), 120 | Some(lines) => lines.join(" ").split(' ').map(PathBuf::from).collect(), 121 | } 122 | } 123 | 124 | pub fn maybe_override_build_command(&mut self, maybe_new: &Option) { 125 | if maybe_new.is_some() { 126 | maybe_new.clone_into(&mut self.build_command) 127 | } 128 | } 129 | 130 | pub fn merge_build_inputs(&mut self, new: &[String]) -> Result<()> { 131 | for item in new { 132 | let parsed = (item).parse().context("could not parse build input")?; 133 | 134 | if !self.build_inputs.contains(&parsed) { 135 | self.build_inputs.push(parsed) 136 | } 137 | } 138 | 139 | Ok(()) 140 | } 141 | 142 | pub fn override_interpreter(&mut self, interpreter: &str) { 143 | self.interpreter = Some(interpreter.to_owned()); 144 | } 145 | 146 | pub fn merge_runtime_inputs(&mut self, new: &[String]) -> Result<()> { 147 | for item in new { 148 | let parsed = (item).parse().context("could not parse build input")?; 149 | 150 | if !self.runtime_inputs.contains(&parsed) { 151 | self.runtime_inputs.push(parsed) 152 | } 153 | } 154 | 155 | Ok(()) 156 | } 157 | 158 | pub fn merge_runtime_files(&mut self, new: &[PathBuf]) { 159 | for item in new { 160 | if !self.runtime_files.contains(item) { 161 | self.runtime_files.push(item.clone()) 162 | } 163 | } 164 | } 165 | 166 | pub fn override_nixpkgs_config(&mut self, expr: &Expr) -> Result<()> { 167 | match expr.kind() { 168 | SyntaxKind::NODE_ATTR_SET => self.nixpkgs_config = Some(expr.clone()), 169 | other => anyhow::bail!( 170 | "Nixpkgs config was no Nix attribute set, but a `{:?}`", 171 | other, 172 | ), 173 | }; 174 | 175 | Ok(()) 176 | } 177 | } 178 | 179 | impl Hash for Directives { 180 | fn hash(&self, hasher: &mut H) { 181 | if let Some(build_command) = &self.build_command { 182 | hasher.write(build_command.as_ref()) 183 | } 184 | 185 | for input in &self.build_inputs { 186 | input.hash(hasher) 187 | } 188 | 189 | if let Some(interpreter) = &self.interpreter { 190 | hasher.write(interpreter.as_ref()) 191 | } 192 | 193 | for input in &self.runtime_inputs { 194 | input.hash(hasher) 195 | } 196 | 197 | if let Some(build_root) = &self.build_root { 198 | hasher.write(build_root.display().to_string().as_ref()) 199 | } 200 | 201 | for file in &self.runtime_files { 202 | hasher.write(file.display().to_string().as_ref()) 203 | } 204 | 205 | if let Some(nixpkgs_config) = &self.nixpkgs_config { 206 | hasher.write(nixpkgs_config.to_string().as_ref()) 207 | } 208 | } 209 | } 210 | 211 | #[cfg(test)] 212 | mod tests { 213 | use super::*; 214 | 215 | mod from_directives { 216 | use super::*; 217 | 218 | #[test] 219 | fn only_one_build_command_allowed() { 220 | let problem = Directives::from_directives(HashMap::from([("build", vec!["a", "b"])])) 221 | .unwrap_err(); 222 | 223 | assert!(problem.to_string().contains("multiple `build` directives"),) 224 | } 225 | 226 | #[test] 227 | fn combines_build_inputs() { 228 | let directives = 229 | Directives::from_directives(HashMap::from([("buildInputs", vec!["a b", "c d"])])) 230 | .unwrap(); 231 | 232 | let expected: Vec = vec![ 233 | "a".parse().unwrap(), 234 | "b".parse().unwrap(), 235 | "c".parse().unwrap(), 236 | "d".parse().unwrap(), 237 | ]; 238 | 239 | assert_eq!(expected, directives.build_inputs); 240 | } 241 | 242 | #[test] 243 | fn only_one_interpreter_allowed() { 244 | let problem = 245 | Directives::from_directives(HashMap::from([("interpreter", vec!["a", "b"])])) 246 | .unwrap_err(); 247 | 248 | assert!(problem 249 | .to_string() 250 | .contains("multiple `interpreter` directives")) 251 | } 252 | 253 | #[test] 254 | fn combines_runtime_inputs() { 255 | let directives = 256 | Directives::from_directives(HashMap::from([("runtimeInputs", vec!["a b", "c d"])])) 257 | .unwrap(); 258 | 259 | let expected: Vec = vec![ 260 | ("a").parse().unwrap(), 261 | ("b").parse().unwrap(), 262 | ("c").parse().unwrap(), 263 | ("d").parse().unwrap(), 264 | ]; 265 | 266 | assert_eq!(expected, directives.runtime_inputs); 267 | } 268 | 269 | #[test] 270 | fn only_one_build_root_allowed() { 271 | let problem = 272 | Directives::from_directives(HashMap::from([("buildRoot", vec!["a", "b"])])) 273 | .unwrap_err(); 274 | 275 | assert!(problem 276 | .to_string() 277 | .contains("multiple `buildRoot` directives")) 278 | } 279 | 280 | #[test] 281 | fn sets_root() { 282 | let directives = 283 | Directives::from_directives(HashMap::from([("buildRoot", vec!["."])])).unwrap(); 284 | 285 | assert_eq!(Some(PathBuf::from(".")), directives.build_root) 286 | } 287 | 288 | #[test] 289 | fn combines_runtime_files() { 290 | let directives = 291 | Directives::from_directives(HashMap::from([("runtimeFiles", vec!["a b", "c d"])])) 292 | .unwrap(); 293 | 294 | let expected = vec![ 295 | PathBuf::from("a"), 296 | PathBuf::from("b"), 297 | PathBuf::from("c"), 298 | PathBuf::from("d"), 299 | ]; 300 | 301 | assert_eq!(expected, directives.runtime_files); 302 | } 303 | 304 | #[test] 305 | fn includes_others_raw() { 306 | let directives = 307 | Directives::from_directives(HashMap::from([("other", vec!["other"])])).unwrap(); 308 | 309 | assert_eq!( 310 | Some(&vec!["other".to_string()]), 311 | directives.all.get("other") 312 | ) 313 | } 314 | 315 | #[test] 316 | fn only_one_nixpkgs_options_allowed() { 317 | let problem = 318 | Directives::from_directives(HashMap::from([("nixpkgsConfig", vec!["{}", "{}"])])) 319 | .unwrap_err(); 320 | 321 | assert!(problem 322 | .to_string() 323 | .contains("multiple `nixpkgsConfig` directives")) 324 | } 325 | 326 | #[test] 327 | fn nixpkgs_options_must_be_a_attrset() { 328 | let problem = 329 | Directives::from_directives(HashMap::from([("nixpkgsConfig", vec!["1"])])) 330 | .unwrap_err(); 331 | 332 | assert!(problem.to_string().contains("`nixpkgsConfig` directive"),) 333 | } 334 | 335 | #[test] 336 | fn nixpkgs_options_takes_an_attrset() { 337 | let options = "{ system = \"x86_64-darwin\"; }"; 338 | let directives = 339 | Directives::from_directives(HashMap::from([("nixpkgsConfig", vec![options])])) 340 | .unwrap(); 341 | 342 | assert_eq!( 343 | Some(options.to_string()), 344 | directives.nixpkgs_config.map(|o| o.to_string()), 345 | ) 346 | } 347 | } 348 | 349 | mod hash { 350 | use super::*; 351 | 352 | use std::collections::hash_map::DefaultHasher; 353 | use std::hash::{Hash, Hasher}; 354 | 355 | fn assert_have_different_hashes(l: H, r: H) { 356 | let mut l_hasher = DefaultHasher::new(); 357 | let mut r_hasher = DefaultHasher::new(); 358 | 359 | l.hash(&mut l_hasher); 360 | r.hash(&mut r_hasher); 361 | 362 | println!("l: {}, r: {}", l_hasher.finish(), r_hasher.finish()); 363 | assert!(l_hasher.finish() != r_hasher.finish()) 364 | } 365 | 366 | #[test] 367 | fn build_command_changes_hash() { 368 | assert_have_different_hashes( 369 | Directives::from_directives(HashMap::from([("build", vec!["a"])])).unwrap(), 370 | Directives::from_directives(HashMap::from([("build", vec!["b"])])).unwrap(), 371 | ) 372 | } 373 | 374 | #[test] 375 | fn build_inputs_changes_hash() { 376 | assert_have_different_hashes( 377 | Directives::from_directives(HashMap::from([("buildInputs", vec!["a"])])).unwrap(), 378 | Directives::from_directives(HashMap::from([("buildInputs", vec!["b"])])).unwrap(), 379 | ) 380 | } 381 | 382 | #[test] 383 | fn interpreter_changes_hash() { 384 | assert_have_different_hashes( 385 | Directives::from_directives(HashMap::from([("interpreter", vec!["a"])])).unwrap(), 386 | Directives::from_directives(HashMap::from([("interpreter", vec!["b"])])).unwrap(), 387 | ) 388 | } 389 | 390 | #[test] 391 | fn runtime_inputs_changes_hash() { 392 | assert_have_different_hashes( 393 | Directives::from_directives(HashMap::from([("runtimeInputs", vec!["a"])])).unwrap(), 394 | Directives::from_directives(HashMap::from([("runtimeInputs", vec!["b"])])).unwrap(), 395 | ) 396 | } 397 | 398 | #[test] 399 | fn root_changes_hash() { 400 | assert_have_different_hashes( 401 | Directives::from_directives(HashMap::from([("buildRoot", vec!["a"])])).unwrap(), 402 | Directives::from_directives(HashMap::from([("buildRoot", vec!["b"])])).unwrap(), 403 | ) 404 | } 405 | 406 | #[test] 407 | fn runtime_files_change_hash() { 408 | assert_have_different_hashes( 409 | Directives::from_directives(HashMap::from([("runtimeFiles", vec!["a"])])).unwrap(), 410 | Directives::from_directives(HashMap::from([("runtimeFiles", vec!["b"])])).unwrap(), 411 | ) 412 | } 413 | 414 | #[test] 415 | fn nixpkgs_config_changes_hash() { 416 | assert_have_different_hashes( 417 | Directives::from_directives(HashMap::from([( 418 | "nixpkgsConfig", 419 | vec!["{ system = \"x86_64-darwin\"; }"], 420 | )])) 421 | .unwrap(), 422 | Directives::from_directives(HashMap::from([( 423 | "nixpkgsConfig", 424 | vec!["{ system = \"aarch64-darwin\"; }"], 425 | )])) 426 | .unwrap(), 427 | ) 428 | } 429 | } 430 | } 431 | -------------------------------------------------------------------------------- /nix-script-directives/src/parser.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use std::collections::HashMap; 3 | 4 | #[derive(Debug)] 5 | pub struct Parser { 6 | indicator: String, 7 | } 8 | 9 | impl Parser { 10 | pub fn new(indicator: &str) -> Result { 11 | if indicator.is_empty() { 12 | anyhow::bail!("a blank indicator is not allowed") 13 | } 14 | 15 | Ok(Parser { 16 | indicator: indicator.to_string(), 17 | }) 18 | } 19 | 20 | pub fn parse<'a>(&self, source: &'a str) -> HashMap<&'a str, Vec<&'a str>> { 21 | let mut out = HashMap::new(); 22 | 23 | for line in source.lines() { 24 | if !line.starts_with(&self.indicator) { 25 | continue; 26 | } 27 | 28 | let line_without_indicator = line[self.indicator.len()..].trim_start(); 29 | let mut words = line_without_indicator.split_whitespace(); 30 | 31 | if let Some(key) = words.next() { 32 | let value = line_without_indicator[key.len()..].trim_start(); 33 | 34 | if value.is_empty() { 35 | log::warn!("skipping directive \"{}\" because value was empty", key); 36 | continue; 37 | } 38 | 39 | let entry = out.entry(key).or_insert_with(Vec::new); 40 | entry.push(value); 41 | } 42 | } 43 | 44 | out 45 | } 46 | } 47 | 48 | #[cfg(test)] 49 | mod tests { 50 | use super::*; 51 | 52 | mod new { 53 | use super::*; 54 | 55 | #[test] 56 | fn blank_indicator_is_not_allowed() { 57 | assert_eq!( 58 | "a blank indicator is not allowed", 59 | Parser::new("").unwrap_err().to_string(), 60 | ) 61 | } 62 | } 63 | 64 | mod parse { 65 | use super::*; 66 | 67 | #[test] 68 | fn blank_is_blank() { 69 | let directives = Parser::new("#!").unwrap().parse(""); 70 | 71 | assert!(directives.is_empty()); 72 | } 73 | 74 | #[test] 75 | fn ignores_non_shebangs() { 76 | let directives = Parser::new("#!").unwrap().parse("nope"); 77 | 78 | assert!(directives.is_empty()); 79 | } 80 | 81 | #[test] 82 | fn matches_shebangs() { 83 | let directives = Parser::new("#!").unwrap().parse("#!buildInputs jq"); 84 | 85 | assert_eq!(Some(&vec!["jq"]), directives.get("buildInputs")); 86 | } 87 | 88 | #[test] 89 | fn matches_comment_chars() { 90 | let directives = Parser::new("//").unwrap().parse("// buildInputs jq"); 91 | 92 | assert_eq!(Some(&vec!["jq"]), directives.get("buildInputs")); 93 | } 94 | 95 | #[test] 96 | fn removes_empty_directives() { 97 | let directives = Parser::new("#!").unwrap().parse("#!buildInputs"); 98 | 99 | assert_eq!(None, directives.get("buildInputs")); 100 | } 101 | 102 | #[test] 103 | fn combines_multiple_lines() { 104 | let directives = Parser::new("#!") 105 | .unwrap() 106 | .parse("#!buildInputs a\n#!buildInputs b"); 107 | 108 | assert_eq!(Some(&vec!["a", "b"]), directives.get("buildInputs")); 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /nix-script-haskell/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "nix-script-haskell" 3 | version = "3.0.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | anyhow = "1.0.98" 8 | clap = { version = "4.5.38", features = [ "std", "color", "suggestions", "derive", "cargo", "env" ] } 9 | nix-script-directives = { path = "../nix-script-directives" } 10 | log = "0.4.27" 11 | env_logger = "0.11.6" 12 | 13 | [dev-dependencies] 14 | assert_cmd = "2.0.17" 15 | -------------------------------------------------------------------------------- /nix-script-haskell/sample-scripts/ghc-flags.hs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env nix-script-haskell 2 | #!ghcFlags -threaded 3 | 4 | import Control.Concurrent 5 | import System.Exit 6 | 7 | main :: IO () 8 | main = do 9 | if rtsSupportsBoundThreads 10 | then putStrLn "Success! Bound threads are supported" 11 | else do 12 | putStrLn "Failure! Bound threads are not supported" 13 | exitFailure 14 | -------------------------------------------------------------------------------- /nix-script-haskell/sample-scripts/hello-world.hs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env nix-script-haskell 2 | 3 | main :: IO () 4 | main = 5 | putStrLn "Hello, World!" 6 | -------------------------------------------------------------------------------- /nix-script-haskell/sample-scripts/no-extension: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env nix-script-haskell 2 | 3 | main :: IO () 4 | main = 5 | putStrLn "Hello, World!" 6 | -------------------------------------------------------------------------------- /nix-script-haskell/sample-scripts/relude.hs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env nix-script-haskell 2 | #!haskellPackages relude 3 | 4 | {-# LANGUAGE OverloadedStrings #-} 5 | {-# LANGUAGE NoImplicitPrelude #-} 6 | 7 | import Relude 8 | 9 | main :: IO () 10 | main = putTextLn "Hello, World!" 11 | -------------------------------------------------------------------------------- /nix-script-haskell/src/main.rs: -------------------------------------------------------------------------------- 1 | mod opts; 2 | 3 | use clap::Parser; 4 | use opts::Opts; 5 | 6 | fn main() { 7 | env_logger::Builder::from_env("NIX_SCRIPT_LOG").init(); 8 | 9 | let opts = Opts::parse(); 10 | log::trace!("opts: {:?}", opts); 11 | 12 | match opts.run().map(|status| status.code()) { 13 | Ok(Some(code)) => std::process::exit(code), 14 | Ok(None) => { 15 | log::warn!("no exit code; was the script killed with a signal?"); 16 | std::process::exit(1) 17 | } 18 | Err(err) => { 19 | eprintln!("{err:?}"); 20 | std::process::exit(1) 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /nix-script-haskell/src/opts.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result}; 2 | use clap::Parser; 3 | use nix_script_directives::Directives; 4 | use std::path::PathBuf; 5 | use std::process::{Command, ExitStatus}; 6 | 7 | /// `nix-script-haskell` is a wrapper around `nix-script` with options for 8 | /// scripts written in Haskell. 9 | /// 10 | /// I pay attention to all the same #! directives as nix-script, so you can 11 | /// still use `#!runtimeInputs` and friends to get external dependencies. (There 12 | /// is no need to specify `#!build` or `#!buildInputs` with regards to GHC or 13 | /// packages, though; I take care of that.) 14 | /// 15 | /// In addition, I pay attention to some additional directives specific to 16 | /// Haskell programs: 17 | /// 18 | /// `#!haskellPackages` should contain a list of packages the compiling GHC 19 | /// instance will know about. The available set of packages depends on your 20 | /// Nix installation; look in `haskellPackages` on `search.nixos.org` to get a 21 | /// full list. 22 | /// 23 | /// `#!ghcFlags` should be a string of command-line options to pass to `ghc` 24 | /// when compiling. 25 | #[derive(Debug, Parser)] 26 | #[clap(version, trailing_var_arg = true)] 27 | pub struct Opts { 28 | /// Launch a ghcid session watching the script 29 | #[clap(long, conflicts_with("shell"))] 30 | ghcid: bool, 31 | 32 | /// Enter a shell with all script dependencies 33 | #[clap(long, conflicts_with("ghcid"))] 34 | shell: bool, 35 | 36 | /// In shell mode, run this command instead of a shell. 37 | #[clap(long, requires("shell"))] 38 | run: Option, 39 | 40 | /// In shell mode, run a "pure" shell (that is, one that isolates the 41 | /// shell a little more from what you have in your environment.) 42 | #[clap(long, requires("shell"))] 43 | pure: bool, 44 | 45 | #[clap(long, default_value("nix-script"), hide(true))] 46 | nix_script_bin: PathBuf, 47 | 48 | /// The script and args to pass to nix-script 49 | #[arg(num_args = 1.., required = true)] 50 | script_and_args: Vec, 51 | } 52 | 53 | impl Opts { 54 | pub fn run(&self) -> Result { 55 | let (script, args) = self 56 | .get_script_and_args() 57 | .context("could not get script and args")?; 58 | 59 | let directives = Directives::from_file("#!", &script) 60 | .context("could not parse directives from script")?; 61 | 62 | let mut nix_script = Command::new(&self.nix_script_bin); 63 | 64 | let build_command = format!( 65 | "mv $SRC $SRC.hs; ghc {} -o $OUT $SRC.hs", 66 | directives 67 | .all 68 | .get("ghcFlags") 69 | .map(|ps| ps.join(" ")) 70 | .unwrap_or_default() 71 | ); 72 | log::debug!("build command is `{}`", build_command); 73 | nix_script.arg("--build-command").arg(build_command); 74 | 75 | let compiler = format!( 76 | "haskellPackages.ghcWithPackages (ps: with ps; [ {} ])", 77 | directives 78 | .all 79 | .get("haskellPackages") 80 | .map(|ps| ps.join(" ")) 81 | .unwrap_or_default() 82 | ); 83 | log::debug!("compiler is `{}`", &compiler); 84 | nix_script.arg("--build-input").arg(compiler); 85 | 86 | if self.shell { 87 | log::debug!("entering shell mode"); 88 | nix_script.arg("--shell"); 89 | } else if self.ghcid { 90 | log::debug!("entering ghcid mode"); 91 | nix_script 92 | .arg("--shell") 93 | .arg("--runtime-input") 94 | .arg("ghcid") 95 | .arg("--run") 96 | .arg(format!("ghcid {}", script.display())); 97 | } 98 | 99 | nix_script.arg(script); 100 | nix_script.args(args); 101 | 102 | let mut child = nix_script.spawn().with_context(|| { 103 | format!( 104 | "could not call {}. Is it on the PATH?", 105 | self.nix_script_bin.display() 106 | ) 107 | })?; 108 | 109 | child.wait().context("could not run the script") 110 | } 111 | 112 | fn get_script_and_args(&self) -> Result<(PathBuf, Vec)> { 113 | log::trace!("parsing script and args"); 114 | 115 | let script = PathBuf::from( 116 | self.script_and_args 117 | .first() 118 | .context("no script to run; this is a bug; please report")?, 119 | ); 120 | 121 | Ok((script, self.script_and_args[1..].to_vec())) 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /nix-script-haskell/tests/sample_scripts.rs: -------------------------------------------------------------------------------- 1 | use assert_cmd::Command; 2 | use std::path::PathBuf; 3 | 4 | fn run_test(asserter: A) 5 | where 6 | A: FnOnce(&mut Command), 7 | { 8 | let nix_script_bin = PathBuf::from(env!("CARGO_BIN_EXE_nix-script-haskell")) 9 | .parent() 10 | .unwrap() 11 | .join("nix-script"); 12 | 13 | // `cargo test` should have automatically created this binary. If not, 14 | // we'd better bail early! 15 | assert!(nix_script_bin.exists()); 16 | 17 | let mut command = Command::cargo_bin(env!("CARGO_BIN_EXE_nix-script-haskell")).unwrap(); 18 | 19 | command.arg("--nix-script-bin").arg(nix_script_bin); 20 | 21 | asserter(&mut command); 22 | } 23 | 24 | #[test] 25 | fn hello_world() { 26 | run_test(|cmd| { 27 | cmd.arg("sample-scripts/hello-world.hs") 28 | .assert() 29 | // 30 | .success() 31 | .stdout("Hello, World!\n"); 32 | }); 33 | } 34 | 35 | #[test] 36 | fn ghc_flags() { 37 | run_test(|cmd| { 38 | cmd.arg("sample-scripts/ghc-flags.hs") 39 | .assert() 40 | // 41 | .success() 42 | .stdout("Success! Bound threads are supported\n"); 43 | }); 44 | } 45 | 46 | #[test] 47 | fn no_extension() { 48 | run_test(|cmd| { 49 | cmd.arg("sample-scripts/no-extension") 50 | .assert() 51 | // 52 | .success() 53 | .stdout("Hello, World!\n"); 54 | }); 55 | } 56 | 57 | #[test] 58 | fn relude() { 59 | run_test(|cmd| { 60 | cmd.arg("sample-scripts/relude.hs") 61 | .assert() 62 | // 63 | .success() 64 | .stdout("Hello, World!\n"); 65 | }); 66 | } 67 | -------------------------------------------------------------------------------- /nix-script/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "nix-script" 3 | version = "3.0.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | anyhow = "1.0.98" 8 | clap = { version = "4.5.38", features = [ "std", "color", "suggestions", "derive", "cargo", "env" ] } 9 | directories = "6.0.0" 10 | env_logger = "0.11.6" 11 | fs2 = "0.4.3" 12 | lazy_static = "1.5.0" 13 | log = "0.4.27" 14 | nix-script-directives = { path = "../nix-script-directives" } 15 | once_cell = "1.21.3" 16 | path-absolutize = "3.1.1" 17 | seahash = "4.1.0" 18 | serde_json = "1.0.140" 19 | walkdir = "2.5.0" 20 | 21 | [dev-dependencies] 22 | assert_cmd = "2.0.17" 23 | rnix = "0.12.0" 24 | tempfile = "3.20.0" 25 | -------------------------------------------------------------------------------- /nix-script/sample-scripts/hello-world.hs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env nix-script 2 | #!build mv $SRC $SRC.hs; ghc -o $OUT $SRC.hs 3 | #!buildInputs (haskellPackages.ghcWithPackages (ps: [ ps.text ])) 4 | 5 | {-# LANGUAGE OverloadedStrings #-} 6 | 7 | import Data.Text.IO as TextIO 8 | 9 | main :: IO () 10 | main = 11 | TextIO.putStrLn "Hello, World!" 12 | -------------------------------------------------------------------------------- /nix-script/sample-scripts/hello-world.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env nix-script 2 | #!interpreter python3 3 | #!runtimeInputs python3 4 | #!build mv $SRC $OUT 5 | print("Hello, World!") 6 | -------------------------------------------------------------------------------- /nix-script/sample-scripts/jq.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env nix-script 2 | #!build cp $SRC $OUT 3 | #!interpreter bash 4 | #!runtimeInputs bash jq 5 | # shellcheck shell=bash 6 | set -euo pipefail 7 | 8 | echo '{"message": "Hello, World!"}' | jq -r .message 9 | -------------------------------------------------------------------------------- /nix-script/src/builder.rs: -------------------------------------------------------------------------------- 1 | use crate::clean_path::clean_path; 2 | use crate::derivation::Derivation; 3 | use anyhow::{Context, Result}; 4 | use nix_script_directives::Directives; 5 | use once_cell::unsync::OnceCell; 6 | use path_absolutize::Absolutize; 7 | use seahash::SeaHasher; 8 | use std::fs; 9 | use std::hash::Hash; 10 | use std::hash::Hasher; 11 | use std::io::ErrorKind; 12 | use std::os::unix::ffi::OsStrExt; 13 | use std::path::{Path, PathBuf}; 14 | use std::process::{Command, Stdio}; 15 | use walkdir::WalkDir; 16 | 17 | #[derive(Debug)] 18 | pub struct Builder { 19 | source: Source, 20 | } 21 | 22 | lazy_static::lazy_static! { 23 | static ref CURRENT: PathBuf = PathBuf::from("./."); 24 | } 25 | 26 | impl Builder { 27 | pub fn from_script(script: &Path) -> Self { 28 | log::trace!("constructing source from script"); 29 | 30 | Self { 31 | source: Source::Script { 32 | script: script.to_owned(), 33 | tempdir: OnceCell::new(), 34 | }, 35 | } 36 | } 37 | 38 | pub fn from_directory(raw_root: &Path, raw_script: &Path) -> Result { 39 | log::trace!("constructing source from directory"); 40 | let root = clean_path(raw_root).context("could not clean path to root")?; 41 | 42 | let script = raw_script.strip_prefix(&root) 43 | .context("could not find a path from the provided root to the script file (root must contain script)")? 44 | .to_owned(); 45 | 46 | log::debug!( 47 | "calculated script path from root `{}` as `{}`", 48 | root.display(), 49 | script.display() 50 | ); 51 | 52 | Ok(Self { 53 | source: Source::Directory { 54 | script, 55 | absolute_root: root 56 | .absolutize() 57 | .context("could not find absolute path to root")? 58 | .to_path_buf(), 59 | root, 60 | tempdir: OnceCell::new(), 61 | }, 62 | }) 63 | } 64 | 65 | pub fn derivation(&self, directives: &Directives, for_export: bool) -> Result { 66 | let build_command = match &directives.build_command { 67 | Some(bc) => bc, 68 | None => anyhow::bail!("need a build command, either by specifying a `build` directive or passing the `--build-command` option") 69 | }; 70 | 71 | let root = if for_export { 72 | &*CURRENT 73 | } else { 74 | self.source 75 | .root() 76 | .context("could not get the root directory of the derivation")? 77 | }; 78 | 79 | let mut derivation = Derivation::new( 80 | root, 81 | self.source 82 | .script() 83 | .context("could not get the script name of the derivation")?, 84 | build_command, 85 | directives.nixpkgs_config.as_ref(), 86 | ) 87 | .context("could not create a Nix derivation")?; 88 | 89 | log::trace!("adding build inputs"); 90 | derivation.add_build_inputs(directives.build_inputs.clone()); 91 | 92 | log::trace!("adding runtime inputs"); 93 | derivation.add_runtime_inputs(directives.runtime_inputs.clone()); 94 | 95 | log::trace!("adding runtime files"); 96 | derivation.add_runtime_files(directives.runtime_files.clone()); 97 | 98 | if let Some(interpreter) = &directives.interpreter { 99 | log::debug!("using interpreter from directives"); 100 | derivation 101 | .set_interpreter(interpreter) 102 | .context("could not set interpreter from file directives")? 103 | } else { 104 | log::trace!("not using an interpreter") 105 | }; 106 | 107 | Ok(derivation) 108 | } 109 | 110 | pub fn hash(&self, directives: &Directives) -> Result { 111 | let mut hasher = SeaHasher::new(); 112 | 113 | // TODO: should we use the derivation here instead? It seems like this 114 | // should be equivalent (that is, it should change when the derivation 115 | // does). The cost is not huge if we have to change it, though... just a 116 | // few rebuilds. It's probably fine? 117 | directives.hash(&mut hasher); 118 | log::trace!("hashed directives, hash is now {:x}", hasher.finish()); 119 | 120 | let out = std::env::var_os("NIX_PATH"); 121 | match out { 122 | Some(nix_path) => { 123 | hasher.write(nix_path.as_bytes()); 124 | log::trace!("hashed NIX_PATH, hash is now {:x}", hasher.finish()); 125 | }, 126 | None => log::warn!("no NIX_PATH environment variable; updates to may not trigger rebuilds of scripts"), 127 | }; 128 | 129 | self.source 130 | .hash(&mut hasher) 131 | .context("could not hash source")?; 132 | log::trace!("hashed source, hash is now {:x}", hasher.finish()); 133 | 134 | Ok(format!("{:x}", hasher.finish())) 135 | } 136 | 137 | pub fn build( 138 | &mut self, 139 | cache_root: &Path, 140 | hash: &str, 141 | directives: &Directives, 142 | ) -> Result { 143 | self.source 144 | .isolate(cache_root, hash) 145 | .context("could not isolate source in order to build")?; 146 | log::trace!("isolated source"); 147 | 148 | let build_path = self 149 | .source 150 | .derivation_path(cache_root, hash) 151 | .context("could not determine where to run the build")?; 152 | log::trace!("run the build in {}", build_path.display()); 153 | 154 | if !self.source.has_default_nix() { 155 | let derivation = self 156 | .derivation(directives, false) 157 | .context("could not prepare derivation to build")?; 158 | 159 | log::debug!("writing derivation to {}", build_path.display()); 160 | log::trace!("derivation contents: {}", derivation); 161 | fs::write(build_path.join("default.nix"), derivation.to_string()) 162 | .context("could not write derivation contents")?; 163 | } 164 | 165 | log::info!("building"); 166 | let mut output = Command::new("nix-build") 167 | .arg(build_path) 168 | // TODO: It might be good to explicitly set `--out-link` to 169 | // somewhere in the cache! 170 | .arg("--no-out-link") 171 | .stdout(Stdio::piped()) 172 | .stderr(Stdio::inherit()) 173 | .output() 174 | .map_err(|err| match err.kind() { 175 | ErrorKind::NotFound => { 176 | anyhow::anyhow!("No nix-build binary available. Is Nix installed?") 177 | } 178 | _ => anyhow::anyhow!("{}", err), 179 | }) 180 | .context("failed to build")?; 181 | 182 | match output.status.code() { 183 | Some(0) => {} 184 | Some(other) => anyhow::bail!("nix-build exited with code {}", other), 185 | None => anyhow::bail!("nix-build was terminated by a signal"), 186 | } 187 | 188 | // Trim newline from the end of the output. 189 | match output.stdout.pop() { 190 | Some(0x0A) => {} 191 | Some(other) => { 192 | log::debug!("stdout: {:x?} ending in {:x}", output.stdout, other); 193 | anyhow::bail!("stdout did not end in a single newline, but {:x}", other) 194 | } 195 | None => { 196 | anyhow::bail!("stdout of nix-build stdout was empty. Was there a build error?") 197 | } 198 | }; 199 | 200 | Ok(PathBuf::from(std::str::from_utf8(&output.stdout).context( 201 | "could not convert the output path of nix-build to a string", 202 | )?)) 203 | } 204 | } 205 | 206 | #[derive(Debug)] 207 | enum Source { 208 | Script { 209 | tempdir: OnceCell, 210 | script: PathBuf, 211 | }, 212 | Directory { 213 | script: PathBuf, 214 | 215 | root: PathBuf, 216 | absolute_root: PathBuf, 217 | 218 | // Only created if we need a place to put `default.nix`. 219 | tempdir: OnceCell, 220 | }, 221 | } 222 | 223 | impl Source { 224 | fn root(&self) -> Result<&Path> { 225 | match self { 226 | Self::Script { 227 | tempdir, script, .. 228 | } => Ok(tempdir 229 | .get() 230 | .map(|tempdir| tempdir.dest.as_ref()) 231 | .or_else(|| script.parent()) 232 | .context( 233 | "cannot find a path to the root for this script; this is a bug; please report", 234 | )?), 235 | Self::Directory { 236 | tempdir, 237 | root, 238 | absolute_root, 239 | .. 240 | } => { 241 | if tempdir.get().is_some() { 242 | Ok(absolute_root) 243 | } else { 244 | Ok(root) 245 | } 246 | } 247 | } 248 | } 249 | 250 | fn script(&self) -> Result<&Path> { 251 | match self { 252 | Self::Script { script, .. } => script 253 | .file_name() 254 | .with_context(|| { 255 | format!("script path ({}) did not have a filename", script.display()) 256 | }) 257 | .map(|p| p.as_ref()), 258 | Self::Directory { script, .. } => Ok(script), 259 | } 260 | } 261 | 262 | fn isolate(&mut self, cache_root: &Path, hash: &str) -> Result<()> { 263 | match self { 264 | Self::Script { script, tempdir } => { 265 | let target = tempdir.get_or_try_init(|| { 266 | TempBuildRoot::new_in( 267 | cache_root, 268 | hash, 269 | script 270 | .file_name() 271 | .context("could not get a script name to determine build root") 272 | .map(|p| p.as_ref())?, 273 | ) 274 | })?; 275 | 276 | log::trace!( 277 | "copying build script into temporary build directory at {}", 278 | target.dest.display() 279 | ); 280 | 281 | let script_dest = target.dest.join( 282 | script 283 | .file_name() 284 | .context("the script path did not have a file name")?, 285 | ); 286 | 287 | fs::copy(script, &script_dest) 288 | .context("could not copy source to temporary build directory")?; 289 | 290 | Ok(()) 291 | } 292 | 293 | // We don't need to do anything to isolate if we're working with 294 | // a directory since we build in place. 295 | Self::Directory { .. } => Ok(()), 296 | } 297 | } 298 | 299 | fn derivation_path(&self, cache_root: &Path, hash: &str) -> Result<&PathBuf> { 300 | match self { 301 | Self::Script { tempdir, .. } => 302 | tempdir.get() 303 | .context("trying to build a script but no temporary directory has been created; this is a bug; please report") 304 | .map(|temp| &temp.dest), 305 | Self::Directory { root, tempdir, .. } => if root.join("default.nix").exists() { 306 | log::info!("a default.nix exists in the build directory; using that instead of creating our own"); 307 | Ok(root) 308 | } else { 309 | let target = tempdir 310 | .get_or_try_init(|| TempBuildRoot::new_in(cache_root, hash, self.script().context("could not get script name to create a temporary directory")?)) 311 | .context("could not create a place to write default.nix away from the source root")?; 312 | 313 | Ok(&target.dest) 314 | }, 315 | } 316 | } 317 | 318 | fn has_default_nix(&self) -> bool { 319 | match self { 320 | Self::Script { .. } => false, 321 | Self::Directory { root, .. } => root.join("default.nix").exists(), 322 | } 323 | } 324 | 325 | fn hash(&self, hasher: &mut H) -> Result<()> { 326 | match self { 327 | Self::Script { script, .. } => { 328 | log::debug!("hashing {}", script.display()); 329 | hasher.write( 330 | fs::read_to_string(script) 331 | .context("could not read script contents")? 332 | .as_ref(), 333 | ) 334 | } 335 | Self::Directory { root, .. } => { 336 | for path_res in WalkDir::new(root) 337 | .min_depth(1) 338 | .follow_links(true) 339 | .sort_by_file_name() 340 | { 341 | let path = path_res.context("could not read directory entry")?; 342 | if path.file_type().is_dir() { 343 | continue; 344 | } 345 | 346 | log::debug!("hashing {}", path.path().display()); 347 | hasher.write(path.file_name().as_bytes()); 348 | hasher.write( 349 | fs::read_to_string(path.path()) 350 | .with_context(|| { 351 | format!("could not read {} in script source", path.path().display()) 352 | })? 353 | .as_ref(), 354 | ); 355 | } 356 | } 357 | }; 358 | 359 | Ok(()) 360 | } 361 | } 362 | 363 | /// When you run a build, Nix uses the directory name as part of the calculation 364 | /// for the final path in the store. That means that if we have random temporary 365 | /// directory names like `nix-script-a4beff` we'll bust the cache every time. We 366 | /// can get around this by building in a directory name that never changes, 367 | /// which we can obtain with the hash! 368 | #[derive(Debug)] 369 | struct TempBuildRoot { 370 | dest: PathBuf, 371 | } 372 | 373 | impl TempBuildRoot { 374 | fn new_in(root: &Path, hash: &str, script_name: &Path) -> Result { 375 | let dest = root.join(format!("{}-{}-src", hash, script_name.display())); 376 | fs::create_dir_all(&dest).context("could not create temporary directory")?; 377 | 378 | log::trace!("created temporary directory {}", dest.display()); 379 | 380 | Ok(TempBuildRoot { dest }) 381 | } 382 | } 383 | 384 | impl Drop for TempBuildRoot { 385 | fn drop(&mut self) { 386 | log::trace!("attempting to remove temporary directory {:?}", &self.dest); 387 | 388 | if let Err(err) = fs::remove_dir_all(&self.dest) { 389 | log::warn!( 390 | "error while removing the temporary directory at {}: {}", 391 | self.dest.display(), 392 | err, 393 | ) 394 | } 395 | } 396 | } 397 | -------------------------------------------------------------------------------- /nix-script/src/clean_path.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use std::path::{Component, Path, PathBuf}; 3 | 4 | lazy_static::lazy_static! { 5 | static ref ROOT: PathBuf = PathBuf::from("/"); 6 | } 7 | 8 | pub fn clean_path(path: &Path) -> Result { 9 | if path.is_relative() { 10 | let components: Vec = path.components().collect(); 11 | 12 | match components.first() { 13 | Some(Component::CurDir) => { 14 | if components.len() == 1 { 15 | Ok(PathBuf::from("./.")) 16 | } else { 17 | Ok(path.to_owned()) 18 | } 19 | } 20 | Some(_) => Ok(PathBuf::from(".").join(path)), 21 | None => anyhow::bail!("couldn't generate a Nix-safe version of a blank path"), 22 | } 23 | } else if path == *ROOT { 24 | Ok(PathBuf::from("/.")) 25 | } else { 26 | Ok(path.to_owned()) 27 | } 28 | } 29 | 30 | #[cfg(test)] 31 | mod tests { 32 | use super::*; 33 | 34 | #[test] 35 | fn empty() { 36 | assert_eq!( 37 | String::from("couldn't generate a Nix-safe version of a blank path"), 38 | clean_path(&PathBuf::new()).unwrap_err().to_string() 39 | ) 40 | } 41 | 42 | #[test] 43 | fn relative() { 44 | assert_eq!( 45 | String::from("./foo"), 46 | clean_path(&PathBuf::from("foo")) 47 | .unwrap() 48 | .display() 49 | .to_string() 50 | ) 51 | } 52 | 53 | #[test] 54 | fn relative_starting_with_current_directory() { 55 | assert_eq!( 56 | String::from("./foo"), 57 | clean_path(&PathBuf::from("./foo")) 58 | .unwrap() 59 | .display() 60 | .to_string() 61 | ) 62 | } 63 | 64 | #[test] 65 | fn current_directory() { 66 | assert_eq!( 67 | String::from("./."), 68 | clean_path(&PathBuf::from(".")) 69 | .unwrap() 70 | .display() 71 | .to_string() 72 | ) 73 | } 74 | 75 | #[test] 76 | fn absolute() { 77 | assert_eq!( 78 | String::from("/foo"), 79 | clean_path(&PathBuf::from("/foo")) 80 | .unwrap() 81 | .display() 82 | .to_string() 83 | ) 84 | } 85 | 86 | #[test] 87 | fn only_root() { 88 | assert_eq!( 89 | String::from("/."), 90 | clean_path(&PathBuf::from("/")) 91 | .unwrap() 92 | .display() 93 | .to_string() 94 | ) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /nix-script/src/derivation.rs: -------------------------------------------------------------------------------- 1 | mod inputs; 2 | 3 | use crate::clean_path::clean_path; 4 | use anyhow::{Context, Result}; 5 | use inputs::Inputs; 6 | use nix_script_directives::expr::Expr; 7 | use std::collections::BTreeSet; 8 | use std::fmt::{self, Display}; 9 | use std::path::{Path, PathBuf}; 10 | 11 | #[derive(Debug)] 12 | pub struct Derivation { 13 | inputs: Inputs, 14 | 15 | name: String, 16 | src: PathBuf, 17 | root: PathBuf, 18 | 19 | build_command: String, 20 | 21 | build_inputs: BTreeSet, 22 | 23 | interpreter: Option<(String, Option)>, 24 | runtime_inputs: BTreeSet, 25 | runtime_files: BTreeSet, 26 | } 27 | 28 | impl Derivation { 29 | pub fn new( 30 | root: &Path, 31 | src: &Path, 32 | build_command: &str, 33 | nixpkgs_options: Option<&Expr>, 34 | ) -> Result { 35 | log::trace!( 36 | "creating a new derivation with root {} and src {}", 37 | root.display(), 38 | src.display() 39 | ); 40 | 41 | let final_nixpkgs_options = match nixpkgs_options { 42 | // Argh! I don't love this clone but it seems to be the least 43 | // unreasonable way around the fact that we don't own the 44 | // expressions we're being passed. 45 | Some(options) => options.clone(), 46 | None => ("{ }").parse().context( 47 | "hardcoded empty attrset did not parse successfully; please report this bug", 48 | )?, 49 | }; 50 | 51 | Ok(Self { 52 | inputs: Inputs::from(vec![ 53 | ( 54 | "pkgs".into(), 55 | Some(format!("import {final_nixpkgs_options}")), 56 | ), 57 | ("makeWrapper".into(), Some("pkgs.makeWrapper".into())), 58 | ]), 59 | name: src 60 | .file_name() 61 | .and_then(|name| name.to_str()) 62 | .map(|name| name.to_owned()) 63 | .context("could not determine derivation name from input path")?, 64 | src: src.to_owned(), 65 | root: clean_path(root).context("could not determine path to source for derivation")?, 66 | build_command: build_command.to_owned(), 67 | build_inputs: BTreeSet::new(), 68 | interpreter: None, 69 | runtime_inputs: BTreeSet::new(), 70 | runtime_files: BTreeSet::new(), 71 | }) 72 | } 73 | 74 | pub fn add_build_inputs(&mut self, build_inputs: Vec) { 75 | for build_input in build_inputs { 76 | if build_input.is_leaf() { 77 | log::trace!("extracting build input `{}`", build_input); 78 | self.inputs 79 | .insert(build_input.to_string(), Some(format!("pkgs.{build_input}"))); 80 | } 81 | self.build_inputs.insert(build_input); 82 | } 83 | } 84 | 85 | pub fn set_interpreter(&mut self, interpreter: &str) -> Result<()> { 86 | let trimmed = interpreter.trim(); 87 | let mut words = trimmed.split(' '); 88 | 89 | let command = words 90 | .next() 91 | .context("need at least a command in the interpreter, but got a blank string")?; 92 | 93 | let args = trimmed[command.len()..].trim(); 94 | 95 | self.interpreter = Some(( 96 | command.to_owned(), 97 | if args.is_empty() { 98 | None 99 | } else { 100 | Some(args.to_owned()) 101 | }, 102 | )); 103 | 104 | Ok(()) 105 | } 106 | 107 | pub fn add_runtime_inputs(&mut self, runtime_inputs: Vec) { 108 | for runtime_input in runtime_inputs { 109 | if runtime_input.is_leaf() { 110 | log::trace!("extracting build input `{}`", runtime_input); 111 | self.inputs.insert( 112 | runtime_input.to_string(), 113 | Some(format!("pkgs.{runtime_input}")), 114 | ); 115 | } 116 | self.runtime_inputs.insert(runtime_input); 117 | } 118 | } 119 | 120 | pub fn add_runtime_files(&mut self, runtime_files: Vec) { 121 | for runtime_file in runtime_files { 122 | self.runtime_files.insert(runtime_file); 123 | } 124 | } 125 | } 126 | 127 | impl Display for Derivation { 128 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> { 129 | write!( 130 | f, 131 | "{}:\npkgs.stdenv.mkDerivation {{\n name = \"{}\";\n src = {};\n\n", 132 | self.inputs, 133 | self.name, 134 | self.root.display(), 135 | )?; 136 | 137 | if !self.build_inputs.is_empty() { 138 | write!(f, " buildInputs = with pkgs; ")?; 139 | fmt_list(f, &self.build_inputs)?; 140 | writeln!(f, ";")?; 141 | } 142 | 143 | // build phase 144 | write!( 145 | f, 146 | " buildPhase = ''\n SRC={}\n\n mkdir bin\n OUT=bin/{}\n\n", 147 | self.src.display(), 148 | self.name, 149 | )?; 150 | if self.build_command.is_empty() { 151 | write!(f, " echo build command is not set\n exit 1\n")?; 152 | } else { 153 | writeln!(f, " {}", self.build_command)?; 154 | } 155 | write!(f, " '';\n\n")?; 156 | 157 | // install phase 158 | if !self.runtime_inputs.is_empty() { 159 | write!(f, " nativeBuildInputs = with pkgs; ")?; 160 | fmt_list(f, &self.runtime_inputs)?; 161 | writeln!(f, ";")?; 162 | } 163 | 164 | write!( 165 | f, 166 | " installPhase = ''\n mkdir -p $out\n mv bin $out/bin" 167 | )?; 168 | 169 | if !self.runtime_files.is_empty() { 170 | let target: PathBuf = ["$out", "usr", "share", &self.name].iter().collect(); 171 | write!(f, "\n\n mkdir -p {}", target.display())?; 172 | for file in &self.runtime_files { 173 | write!(f, "\n mv {} {}", &file.display(), target.display())?; 174 | } 175 | } 176 | 177 | write!( 178 | f, 179 | "\n\n source ${{makeWrapper}}/nix-support/setup-hook\n " 180 | )?; 181 | 182 | if let Some((command, maybe_args)) = &self.interpreter { 183 | write!( 184 | f, 185 | "mv $out/bin/{} $out/bin/.{}\n makeWrapper $(command -v {}) $out/bin/{} \\\n", 186 | self.name, self.name, command, self.name 187 | )?; 188 | 189 | if let Some(args) = maybe_args { 190 | write!( 191 | f, 192 | " --add-flags \"{} $out/bin/.{}\" ", 193 | args, self.name 194 | )? 195 | } else { 196 | write!(f, " --add-flags \"$out/bin/.{}\"", self.name)?; 197 | } 198 | } else { 199 | write!( 200 | f, 201 | "wrapProgram $out/bin/{} --argv0 {}", 202 | self.name, self.name 203 | )? 204 | } 205 | 206 | if !self.runtime_files.is_empty() { 207 | write!( 208 | f, 209 | " \\\n --set RUNTIME_FILES_ROOT $out/usr/share/{}", 210 | self.name 211 | )?; 212 | } 213 | 214 | write!(f, " \\\n --set SCRIPT_FILE {}", self.name)?; 215 | 216 | if !self.runtime_inputs.is_empty() { 217 | write!( 218 | f, 219 | " \\\n --prefix PATH : ${{with pkgs; lib.makeBinPath " 220 | )?; 221 | fmt_list(f, &self.runtime_inputs)?; 222 | write!(f, "}}")?; 223 | } 224 | 225 | write!(f, "\n '';\n")?; 226 | 227 | write!(f, "}}") 228 | } 229 | } 230 | 231 | fn fmt_list(f: &mut fmt::Formatter<'_>, exprs: &BTreeSet) -> Result<(), fmt::Error> { 232 | write!(f, "[")?; 233 | for expr in exprs { 234 | if !expr.is_leaf() { 235 | write!(f, " ({expr})")?; 236 | } else { 237 | write!(f, " {expr}")?; 238 | } 239 | } 240 | write!(f, " ]") 241 | } 242 | 243 | #[cfg(test)] 244 | mod tests { 245 | use super::*; 246 | 247 | pub fn assert_no_errors(src: &str) { 248 | let empty: Vec = Vec::new(); 249 | println!("{}", src); 250 | assert_eq!(empty, rnix::Root::parse(src).errors()) 251 | } 252 | 253 | mod to_string { 254 | use super::*; 255 | use std::path::PathBuf; 256 | 257 | #[test] 258 | fn empty() { 259 | let root = PathBuf::from("/"); 260 | let path: PathBuf = ["path", "to", "my", "cool-script"].iter().collect(); 261 | let derivation = Derivation::new(&root, &path, "mv $SRC $DEST", None).unwrap(); 262 | 263 | assert_no_errors(&derivation.to_string()); 264 | } 265 | 266 | #[test] 267 | fn with_build_inputs() { 268 | let root = PathBuf::from("/"); 269 | let path = PathBuf::from("X"); 270 | let mut derivation = Derivation::new(&root, &path, "mv $SRC $DEST", None).unwrap(); 271 | derivation.add_build_inputs(vec![("jq").parse().unwrap(), ("bash").parse().unwrap()]); 272 | 273 | assert_no_errors(&derivation.to_string()); 274 | } 275 | 276 | #[test] 277 | fn with_runtime_inputs() { 278 | let root = PathBuf::from("/"); 279 | let path = PathBuf::from("X"); 280 | let mut derivation = Derivation::new(&root, &path, "mv $SRC $DEST", None).unwrap(); 281 | derivation.add_runtime_inputs(vec![("jq").parse().unwrap()]); 282 | 283 | assert_no_errors(&derivation.to_string()); 284 | } 285 | 286 | #[test] 287 | fn with_interpreter() { 288 | let root = PathBuf::from("/"); 289 | let path = PathBuf::from("X"); 290 | let mut derivation = Derivation::new(&root, &path, "mv $SRC $DEST", None).unwrap(); 291 | derivation.set_interpreter("bash").unwrap(); 292 | 293 | assert_no_errors(&derivation.to_string()); 294 | } 295 | } 296 | } 297 | -------------------------------------------------------------------------------- /nix-script/src/derivation/inputs.rs: -------------------------------------------------------------------------------- 1 | use std::collections::BTreeMap; 2 | use std::convert::From; 3 | use std::fmt::{self, Display}; 4 | 5 | #[derive(Debug)] 6 | pub struct Inputs(BTreeMap>); 7 | 8 | impl Inputs { 9 | pub fn new() -> Self { 10 | Inputs(BTreeMap::new()) 11 | } 12 | 13 | pub fn insert(&mut self, name: String, default: Option) { 14 | self.0.insert(name, default); 15 | } 16 | } 17 | 18 | impl From)>> for Inputs { 19 | fn from(inputs: Vec<(String, Option)>) -> Self { 20 | let mut out = Inputs::new(); 21 | 22 | for (name, default) in inputs { 23 | out.insert(name, default); 24 | } 25 | 26 | out 27 | } 28 | } 29 | 30 | impl Display for Inputs { 31 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> { 32 | write!(f, "{{ ")?; 33 | 34 | let mut done_with_first = false; 35 | 36 | for (key, default) in &self.0 { 37 | if done_with_first { 38 | write!(f, ", ")?; 39 | } else { 40 | done_with_first = true; 41 | } 42 | 43 | write!(f, "{key}")?; 44 | if let Some(value) = default { 45 | write!(f, " ? {value}")?; 46 | } 47 | } 48 | 49 | write!(f, " }}") 50 | } 51 | } 52 | 53 | #[cfg(test)] 54 | mod tests { 55 | use super::*; 56 | use crate::derivation::tests::assert_no_errors; 57 | 58 | mod to_string { 59 | use super::*; 60 | 61 | #[test] 62 | fn with_one() { 63 | assert_eq!( 64 | String::from("{ pkgs }"), 65 | Inputs::from(vec![("pkgs".into(), None)]).to_string(), 66 | ) 67 | } 68 | 69 | #[test] 70 | fn with_many() { 71 | assert_eq!( 72 | String::from("{ jq ? pkgs.jq, pkgs }"), 73 | Inputs::from(vec![ 74 | ("pkgs".into(), None), 75 | ("jq".into(), Some("pkgs.jq".into())) 76 | ]) 77 | .to_string(), 78 | ) 79 | } 80 | 81 | #[test] 82 | fn with_one_valid() { 83 | assert_no_errors(&format!("{}: 1", Inputs::from(vec![("pkgs".into(), None)]))) 84 | } 85 | 86 | #[test] 87 | fn with_many_valid() { 88 | assert_no_errors(&format!( 89 | "{}: 1", 90 | Inputs::from(vec![ 91 | ("pkgs".into(), None), 92 | ("jq".into(), Some("pkgs.jq".into())) 93 | ]) 94 | )) 95 | } 96 | } 97 | 98 | mod insert { 99 | use super::*; 100 | 101 | #[test] 102 | fn adds_when_not_present() { 103 | let mut inputs = Inputs::new(); 104 | inputs.insert("pkgs".into(), None); 105 | assert_eq!("{ pkgs }", inputs.to_string()); 106 | } 107 | 108 | #[test] 109 | fn override_when_present() { 110 | let mut inputs = Inputs::new(); 111 | inputs.insert("pkgs".into(), None); 112 | inputs.insert("pkgs".into(), Some("default".into())); 113 | assert_eq!("{ pkgs ? default }", inputs.to_string()) 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /nix-script/src/main.rs: -------------------------------------------------------------------------------- 1 | mod builder; 2 | mod clean_path; 3 | mod derivation; 4 | mod opts; 5 | 6 | use clap::Parser; 7 | use opts::Opts; 8 | 9 | fn main() { 10 | env_logger::Builder::from_env("NIX_SCRIPT_LOG").init(); 11 | 12 | let opts = Opts::parse(); 13 | log::trace!("opts: {:?}", opts); 14 | 15 | match opts.run().map(|status| status.code()) { 16 | Ok(Some(code)) => std::process::exit(code), 17 | Ok(None) => { 18 | log::warn!("No exit code; was the script killed with a signal?"); 19 | std::process::exit(1) 20 | } 21 | Err(err) => { 22 | eprintln!("{err:?}"); 23 | std::process::exit(1) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /nix-script/src/opts.rs: -------------------------------------------------------------------------------- 1 | use crate::builder::Builder; 2 | use crate::clean_path::clean_path; 3 | 4 | use anyhow::{Context, Result}; 5 | use clap::Parser; 6 | use fs2::FileExt; 7 | use nix_script_directives::expr::Expr; 8 | use nix_script_directives::Directives; 9 | use std::env; 10 | use std::fs::{self, File}; 11 | use std::io::ErrorKind; 12 | use std::os::unix::fs::symlink; 13 | use std::os::unix::process::ExitStatusExt; 14 | use std::path::{Path, PathBuf}; 15 | use std::process::{Command, ExitStatus}; 16 | 17 | // TODO: Options for the rest of the directives. 18 | #[derive(Debug, Parser)] 19 | #[clap(version, trailing_var_arg = true)] 20 | pub struct Opts { 21 | /// What indicator do directives start with in the source file? 22 | #[clap(long, default_value = "#!")] 23 | indicator: String, 24 | 25 | /// How should we build this script? (Will override any `#!build` line 26 | /// present in the script.) 27 | #[clap(long)] 28 | build_command: Option, 29 | 30 | /// Add build inputs to those specified by the source directives. 31 | #[clap(long("build-input"))] 32 | build_inputs: Vec, 33 | 34 | /// Run the script by passing it to this interpreter instead of running 35 | /// the compiled binary directly. The interpreter must be included via some 36 | /// runtime input. 37 | #[clap(long("interpreter"))] 38 | interpreter: Option, 39 | 40 | /// Add runtime inputs to those specified by the source directives. 41 | #[clap(long("runtime-input"))] 42 | runtime_inputs: Vec, 43 | 44 | /// Override the configuration that will be passed to nixpkgs on import. 45 | #[clap( 46 | long("nixpkgs-config"), 47 | value_parser = clap::value_parser!(Expr), 48 | env("NIX_SCRIPT_NIXPKGS_CONFIG") 49 | )] 50 | nixpkgs_config: Option, 51 | 52 | /// Instead of executing the script, parse directives from the file and 53 | /// print them as JSON to stdout. 54 | #[clap(long("parse"), conflicts_with_all(&["export", "shell"]))] 55 | parse: bool, 56 | 57 | /// Instead of executing the script, print the derivation we build 58 | /// to stdout. 59 | #[clap(long("export"), conflicts_with_all(&["parse", "shell"]))] 60 | export: bool, 61 | 62 | /// Enter a shell with build-time and runtime inputs available. 63 | #[clap(long, conflicts_with_all(&["parse", "export"]))] 64 | shell: bool, 65 | 66 | /// In shell mode, run this command instead of a shell. 67 | #[clap(long, requires("shell"))] 68 | run: Option, 69 | 70 | /// In shell mode, run a "pure" shell (that is, one that isolates the 71 | /// shell a little more from what you have in your environment.) 72 | #[clap(long, requires("shell"))] 73 | pure: bool, 74 | 75 | /// Use this folder as the root for any building we do. You can use this 76 | /// to bring other files into scope in your build. If there is a `default.nix` 77 | /// file in the specified root, we will use that instead of generating our own. 78 | #[clap(long)] 79 | build_root: Option, 80 | 81 | /// Include files for use at runtime (relative to the build root). 82 | #[clap(long)] 83 | runtime_files: Vec, 84 | 85 | /// Where should we cache files? 86 | #[clap(long("cache-directory"), env("NIX_SCRIPT_CACHE"))] 87 | cache_directory: Option, 88 | 89 | /// The script to run (required), plus any arguments (optional). Any positional 90 | /// arguments after the script name will be passed on to the script. 91 | // Note: it'd be better to have a "script" and "args" field separately, 92 | // but there's a parsing issue in Clap (not a bug, but maybe a bug?) that 93 | // prevents passing args starting in -- after the script if we do that. See 94 | // https://github.com/clap-rs/clap/issues/1538 95 | #[clap(num_args = 1.., required = true)] 96 | script_and_args: Vec, 97 | } 98 | 99 | impl Opts { 100 | pub fn run(&self) -> Result { 101 | // First things first: what are we running? Where does it live? What 102 | // are its arguments? 103 | let (mut script, args) = self 104 | .parse_script_and_args() 105 | .context("could not parse script and args")?; 106 | script = clean_path(&script).context("could not clean path to script")?; 107 | 108 | if self.shell && !args.is_empty() { 109 | log::warn!("You specified both `--shell` and script args. I am going to ignore the args! Use `--run` if you want to run something in the shell immediately."); 110 | } 111 | 112 | let script_name = script 113 | .file_name() 114 | .context("script did not have a file name")? 115 | .to_str() 116 | .context("filename was not valid UTF-8")?; 117 | 118 | // Parse our directives, but don't combine them with command-line arguments yet! 119 | let mut directives = Directives::from_file(&self.indicator, &script) 120 | .context("could not parse directives from script")?; 121 | 122 | let mut build_root = self.build_root.to_owned(); 123 | if build_root.is_none() { 124 | if let Some(from_directives) = &directives.build_root { 125 | let out = script 126 | .parent() 127 | .map(Path::to_path_buf) 128 | .unwrap_or_else(|| PathBuf::from(".")); 129 | 130 | out.join(from_directives) 131 | .canonicalize() 132 | .context("could not canonicalize final path to build root")?; 133 | 134 | log::debug!("path to root from script directive: {}", out.display()); 135 | 136 | build_root = Some(out); 137 | } 138 | }; 139 | if build_root.is_none() 140 | && (!self.runtime_files.is_empty() || !directives.runtime_files.is_empty()) 141 | { 142 | log::warn!("Requested runtime files without specifying a build root. I am assuming it is the parent directory of the script for now, but you should set it explicitly!"); 143 | build_root = Some( 144 | script 145 | .parent() 146 | .map(|p| p.to_owned()) 147 | .unwrap_or_else(|| PathBuf::from(".")), 148 | ); 149 | } 150 | 151 | let mut builder = if let Some(build_root) = &build_root { 152 | Builder::from_directory(build_root, &script) 153 | .context("could not initialize source in directory")? 154 | } else { 155 | Builder::from_script(&script) 156 | }; 157 | 158 | // First place we might bail early: if a script just wants to parse 159 | // directives using our parser, we dump JSON and quit instead of running. 160 | if self.parse { 161 | println!( 162 | "{}", 163 | serde_json::to_string(&directives).context("could not serialize directives")? 164 | ); 165 | return Ok(ExitStatus::from_raw(0)); 166 | } 167 | 168 | // We don't merge command-line and script directives until now because 169 | // we shouldn't provide them in the output of `--parse` without showing 170 | // where each option came from. For now, we're assuming that people who 171 | // write wrapper scripts know what they want to pass into `nix-script`. 172 | directives.maybe_override_build_command(&self.build_command); 173 | directives 174 | .merge_build_inputs(&self.build_inputs) 175 | .context("could not add build inputs provided on the command line")?; 176 | if let Some(interpreter) = &self.interpreter { 177 | directives.override_interpreter(interpreter) 178 | } 179 | directives 180 | .merge_runtime_inputs(&self.runtime_inputs) 181 | .context("could not add runtime inputs provided on the command line")?; 182 | directives.merge_runtime_files(&self.runtime_files); 183 | if let Some(expr) = &self.nixpkgs_config { 184 | directives 185 | .override_nixpkgs_config(expr) 186 | .context("could not set nixpkgs config provided on the command line")?; 187 | } 188 | 189 | // Second place we might bail early: if we're requesting a shell instead 190 | // of building and running the script. 191 | if self.shell { 192 | return self.run_shell(script, &directives); 193 | } 194 | 195 | // Third place we can bail early: if someone wants the generated 196 | // derivation to do IFD or similar. 197 | if self.export { 198 | // We check here instead of inside while isolating the script or 199 | // similar so we can get an early bail that doesn't create trash 200 | // in the system's temporary directories. 201 | if build_root.is_none() { 202 | anyhow::bail!( 203 | "I do not have a root to refer to while exporting, so I cannot isolate the script and dependencies. Specify a --build-root and try this again!" 204 | ) 205 | } 206 | 207 | println!( 208 | "{}", 209 | builder 210 | .derivation(&directives, true) 211 | .context("could not create a Nix derivation from the script")? 212 | ); 213 | return Ok(ExitStatus::from_raw(0)); 214 | } 215 | 216 | let cache_directory = self 217 | .get_cache_directory() 218 | .context("could not get cache directory")?; 219 | log::debug!( 220 | "using `{}` as the cache directory", 221 | cache_directory.display() 222 | ); 223 | 224 | // Create hash, check cache. 225 | let hash = builder 226 | .hash(&directives) 227 | .context("could not calculate cache location for the compiled versoin of the script")?; 228 | 229 | let target_unique_id = format!("{hash}-{script_name}"); 230 | let target = cache_directory.join(target_unique_id.clone()); 231 | log::trace!("cache target: {}", target.display()); 232 | 233 | // Before we perform the build, we need to check if the symlink target 234 | // has gone stale. This can happen when you run `nix-collect-garbage`, 235 | // since we don't pin the resulting derivations. We have to do things 236 | // in a slightly less ergonomic way in order to not follow symlinks. 237 | if fs::symlink_metadata(&target).is_ok() { 238 | let link_target = fs::read_link(&target).context("failed to read existing symlink")?; 239 | 240 | if !link_target.exists() { 241 | log::info!("removing stale (garbage-collected?) symlink"); 242 | fs::remove_file(&target).context("could not remove stale symlink")?; 243 | } 244 | } 245 | 246 | if !target.exists() { 247 | log::debug!("hashed path does not exist; building"); 248 | 249 | // Initialize build lock. 250 | // 251 | // We lock the build after checking for the target. This has the 252 | // advantage that all subsequent executions will not bother with 253 | // creating lock files and obtaining locks. However, it has the 254 | // disadvantage that we always move on to building the derivation, 255 | // even when another builder has done the job for us in the 256 | // meantime. 257 | let lock_file_path = env::temp_dir().join(target_unique_id); 258 | log::debug!("creating lock file path: {:?}", lock_file_path); 259 | let lock_file = 260 | File::create(lock_file_path.clone()).context("could not create lock file")?; 261 | log::debug!("locking"); 262 | // Obtain lock. 263 | // TODO: Obtain lock with timeout. 264 | lock_file 265 | .lock_exclusive() 266 | .context("could not obtain lock")?; 267 | log::debug!("obtained lock"); 268 | 269 | let out_path = builder 270 | .build(&cache_directory, &hash, &directives) 271 | .context("could not build derivation from script")?; 272 | 273 | if let Err(err) = symlink(out_path, &target) { 274 | match err.kind() { 275 | ErrorKind::AlreadyExists => { 276 | // We could hypothetically detect if the link is 277 | // pointing to the right location, but the Nix paths 278 | // change for minor reasons that don't matter for script 279 | // execution. Instead, we just warn here and trust our 280 | // cache key to do the right thing. If we get a 281 | // collision, we do! 282 | log::warn!("detected a parallel write to the cache"); 283 | } 284 | _ => return Err(err).context("could not create symlink in cache"), 285 | } 286 | } 287 | 288 | // Make sure that we remove the temporary build directory before releasing the lock. 289 | drop(builder); 290 | // Release lock. 291 | log::debug!("releasing lock"); 292 | fs2::FileExt::unlock(&lock_file).context("could not release lock")?; 293 | // Do not remove the lock file because other tasks may still be 294 | // waiting for obtaining a lock on the file. 295 | } else { 296 | log::debug!("hashed path exists; skipping build"); 297 | } 298 | 299 | let mut child = Command::new(target.join("bin").join(script_name)) 300 | .args(args) 301 | .spawn() 302 | .context("could not start the script")?; 303 | 304 | child.wait().context("could not run the script") 305 | } 306 | 307 | fn parse_script_and_args(&self) -> Result<(PathBuf, Vec)> { 308 | log::trace!("parsing script and args"); 309 | let mut script_and_args = self.script_and_args.iter(); 310 | 311 | let script = PathBuf::from( 312 | script_and_args 313 | .next() 314 | .context("no script name; this is a bug; please report")?, 315 | ); 316 | 317 | Ok((script, self.script_and_args[1..].to_vec())) 318 | } 319 | 320 | fn get_cache_directory(&self) -> Result { 321 | let mut target = match &self.cache_directory { 322 | Some(explicit) => explicit.to_owned(), 323 | None => { 324 | let dirs = directories::ProjectDirs::from("zone", "bytes", "nix-script").context( 325 | "couldn't load HOME (set --cache-directory explicitly to get around this.)", 326 | )?; 327 | 328 | dirs.cache_dir().to_owned() 329 | } 330 | }; 331 | 332 | if target.is_relative() { 333 | target = std::env::current_dir() 334 | .context("no the current directory while calculating absolute path to the cache")? 335 | .join(target) 336 | } 337 | 338 | if !target.exists() { 339 | log::trace!("creating cache directory"); 340 | std::fs::create_dir_all(&target).context("could not create cache directory")?; 341 | } 342 | 343 | Ok(target) 344 | } 345 | 346 | fn run_shell(&self, script_file: PathBuf, directives: &Directives) -> Result { 347 | log::debug!("entering shell mode"); 348 | 349 | let mut command = Command::new("nix-shell"); 350 | 351 | log::trace!("setting SCRIPT_FILE to `{}`", script_file.display()); 352 | command.env("SCRIPT_FILE", script_file); 353 | 354 | if self.pure { 355 | log::trace!("setting shell to pure mode"); 356 | command.arg("--pure"); 357 | } 358 | 359 | for input in &directives.build_inputs { 360 | log::trace!("adding build input `{}` to packages", input); 361 | command.arg("-p").arg(input.to_string()); 362 | } 363 | 364 | for input in &directives.runtime_inputs { 365 | log::trace!("adding runtime input `{}` to packages", input); 366 | command.arg("-p").arg(input.to_string()); 367 | } 368 | 369 | if let Some(run) = &self.run { 370 | log::trace!("running `{}`", run); 371 | command.arg("--run").arg(run); 372 | } 373 | 374 | command 375 | .spawn() 376 | .context("could not start nix-shell")? 377 | .wait() 378 | .context("could not start the shell") 379 | } 380 | } 381 | -------------------------------------------------------------------------------- /nix-script/tests/echo.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env nix-script 2 | #!build cp $SRC $OUT 3 | #!interpreter bash 4 | #!runtimeInputs bash coreutils 5 | set -euo pipefail 6 | 7 | cat 8 | -------------------------------------------------------------------------------- /nix-script/tests/end_to_end.rs: -------------------------------------------------------------------------------- 1 | use assert_cmd::Command; 2 | 3 | fn bin() -> Command { 4 | Command::cargo_bin(env!("CARGO_PKG_NAME")).unwrap() 5 | } 6 | 7 | mod sample_scripts { 8 | use super::*; 9 | 10 | #[test] 11 | fn hello_world_py() { 12 | let assert = bin().arg("sample-scripts/hello-world.py").assert(); 13 | 14 | assert.success().stdout("Hello, World!\n"); 15 | } 16 | 17 | #[test] 18 | fn hello_world_hs() { 19 | let assert = bin().arg("sample-scripts/hello-world.hs").assert(); 20 | 21 | assert.success().stdout("Hello, World!\n"); 22 | } 23 | 24 | #[test] 25 | fn jq_sh() { 26 | let assert = bin().arg("sample-scripts/jq.sh").assert(); 27 | 28 | assert.success().stdout("Hello, World!\n"); 29 | } 30 | } 31 | 32 | mod io_behavior { 33 | use super::*; 34 | use std::os::unix::fs::symlink; 35 | use std::path::PathBuf; 36 | use tempfile::tempdir; 37 | 38 | #[test] 39 | fn forwards_success_code() { 40 | let assert = bin().arg("tests/exit-with-code.sh").arg("0").assert(); 41 | 42 | assert.success(); 43 | } 44 | 45 | #[test] 46 | fn forwards_error_code() { 47 | let assert = bin().arg("tests/exit-with-code.sh").arg("1").assert(); 48 | 49 | assert.code(1); 50 | } 51 | 52 | #[test] 53 | fn forwards_custom_code() { 54 | let assert = bin().arg("tests/exit-with-code.sh").arg("32").assert(); 55 | 56 | assert.code(32); 57 | } 58 | 59 | #[test] 60 | fn forwards_stdin() { 61 | let assert = bin() 62 | .arg("tests/echo.sh") 63 | .write_stdin("Hello, World!") 64 | .assert(); 65 | 66 | assert.success().stdout("Hello, World!"); 67 | } 68 | 69 | #[test] 70 | fn gc_safety() { 71 | let temp = tempdir().unwrap(); 72 | 73 | // Run once to set up the cache. 74 | bin() 75 | .env("NIX_SCRIPT_CACHE", temp.path().display().to_string()) 76 | .arg("tests/exit-with-code.sh") 77 | .arg("0") 78 | .assert() 79 | .success(); 80 | 81 | // Mess with the symlink to make it point to an invalid destination. Note 82 | // that we can't use the more ergonomic `DirEntry.path()` here because 83 | // it traverses symlinks. 84 | let mut cache_entries = std::fs::read_dir(temp.path()).unwrap(); 85 | let filename = cache_entries.next().unwrap().unwrap().file_name(); 86 | let link = temp.path().join(filename); 87 | std::fs::remove_file(&link).unwrap(); 88 | symlink(PathBuf::from("garbage"), &link).unwrap(); 89 | 90 | // Run the command again to make sure we handle the newly-bad link. 91 | bin() 92 | .env("NIX_SCRIPT_CACHE", temp.path().display().to_string()) 93 | .arg("tests/exit-with-code.sh") 94 | .arg("0") 95 | .assert() 96 | .success(); 97 | } 98 | 99 | #[test] 100 | fn include_runtime_file() { 101 | bin() 102 | .arg("tests/with_runtime_file/script.sh") 103 | .assert() 104 | .success() 105 | .stdout("Hello, World!\n"); 106 | } 107 | 108 | #[test] 109 | fn add_build_command_and_interpreter() { 110 | // this test the things we'll need to do for nix-script-bash, just to 111 | // make sure we don't break it! 112 | bin() 113 | .arg("--build-command") 114 | .arg("cp $SRC $OUT") 115 | .arg("--interpreter") 116 | .arg("bash") 117 | .arg("tests/nix-script-bash-target.sh") 118 | // 119 | .assert() 120 | .success() 121 | .stdout("Hello, World!\n"); 122 | } 123 | 124 | #[test] 125 | fn script_file() { 126 | bin() 127 | .arg("tests/script-name.sh") 128 | // 129 | .assert() 130 | .success() 131 | .stdout("script-name.sh\n"); 132 | } 133 | 134 | #[test] 135 | fn shell_run() { 136 | bin() 137 | .arg("--shell") 138 | .arg("--run") 139 | .arg("echo 'Hello, Shell!'") 140 | // exit with code 1 if we don't actually enter the shell 141 | .arg("tests/exit-with-code.sh") 142 | .arg("1") 143 | // 144 | .assert() 145 | .success() 146 | .stdout("Hello, Shell!\n"); 147 | } 148 | 149 | #[test] 150 | fn shell_run_inputs() { 151 | bin() 152 | .arg("--runtime-input") 153 | .arg("jq") 154 | .arg("--shell") 155 | .arg("--pure") 156 | .arg("--run") 157 | .arg("echo '{\"message\": \"Hello, jq!\"}' | jq -r .message") 158 | // exit with code 1 if we don't actually enter the shell 159 | .arg("tests/exit-with-code.sh") 160 | .arg("1") 161 | // 162 | .assert() 163 | .success() 164 | .stdout("Hello, jq!\n"); 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /nix-script/tests/exit-with-code.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env nix-script 2 | #!build cp $SRC $OUT 3 | #!interpreter bash 4 | #!runtimeInputs bash 5 | set -euo pipefail 6 | 7 | exit "${1:-0}" 8 | -------------------------------------------------------------------------------- /nix-script/tests/nix-script-bash-target.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env nix-script-bash 2 | set -euo pipefail 3 | 4 | echo "Hello, World!" 5 | -------------------------------------------------------------------------------- /nix-script/tests/script-name.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env nix-script 2 | #!build cp $SRC $OUT 3 | #!interpreter bash 4 | #!runtimeInputs bash 5 | set -euo pipefail 6 | 7 | echo "$SCRIPT_FILE" 8 | -------------------------------------------------------------------------------- /nix-script/tests/with_runtime_file/message: -------------------------------------------------------------------------------- 1 | Hello, World! 2 | -------------------------------------------------------------------------------- /nix-script/tests/with_runtime_file/script.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env nix-script 2 | #!buildRoot . 3 | #!build cp $SRC $OUT 4 | #!interpreter bash 5 | #!runtimeFiles message 6 | set -euo pipefail 7 | 8 | cat "$RUNTIME_FILES_ROOT/message" 9 | -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | (import ( 2 | let 3 | lock = builtins.fromJSON (builtins.readFile ./flake.lock); 4 | in 5 | fetchTarball { 6 | url = "https://github.com/edolstra/flake-compat/archive/${lock.nodes.flake-compat.locked.rev}.tar.gz"; 7 | sha256 = lock.nodes.flake-compat.locked.narHash; 8 | } 9 | ) { src = ./.; }).shellNix 10 | --------------------------------------------------------------------------------