[
  {
    "path": ".github/workflows/release.yml",
    "content": "on:\n  push:\n    tags: '*'\n\nname: Create Release\n\njobs:\n  publish-crate:\n    name: Publish to crates.io\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v1\n      - uses: actions-rs/toolchain@v1\n        with:\n          profile: minimal\n          toolchain: stable\n      - run: cargo login ${CRATES_IO_TOKEN}\n        env:\n          CRATES_IO_TOKEN: ${{ secrets.CRATES_IO_TOKEN }}\n      - run: cargo build --release --locked\n      - name: publish chomp\n        run: cargo publish\n\n  create-github-release:\n    name: Create GitHub Release\n    needs: publish-crate\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v2\n      - name: Create Release Notes\n        uses: actions/github-script@v4.0.2\n        with:\n          github-token: ${{ secrets.GITHUB_TOKEN }}\n          script: |\n            await github.request(`POST /repos/${{ github.repository }}/releases`, {\n              tag_name: \"${{ github.ref }}\",\n              generate_release_notes: true\n            });\n\n  build:\n    name: Build assets for ${{ matrix.os }}\n    needs: create-github-release\n    runs-on: ${{ matrix.os }}\n    strategy:\n      matrix:\n        name: [\n            linux,\n            windows,\n            macos\n        ]\n        include:\n          - name: linux\n            os: ubuntu-latest\n            artifact_name: chomp\n            asset_name: chomp-linux\n            asset_extension: .tar.gz\n          - name: windows\n            os: windows-latest\n            artifact_name: chomp.exe\n            asset_name: chomp-windows\n            asset_extension: .zip\n          - name: macos\n            os: macos-latest\n            artifact_name: chomp\n            asset_name: chomp-macos\n            asset_extension: .tar.gz\n\n    steps:\n    - uses: actions/checkout@v1\n\n    - name: Set env\n      run: |\n          RELEASE_VERSION=$(echo ${GITHUB_REF:10})\n          echo \"asset_name=${{ matrix.asset_name }}-${RELEASE_VERSION}${{ matrix.asset_extension }}\" >> $GITHUB_ENV\n      shell: bash\n\n    - uses: actions-rs/toolchain@v1\n      with:\n        profile: minimal\n        toolchain: stable\n\n    - name: Build\n      run: cargo build --release --locked\n\n    - name: Archive release\n      shell: bash\n      run: |\n        cp \"target/release/${{ matrix.artifact_name }}\" \"${{ matrix.artifact_name }}\"\n        if [ \"${{ matrix.os }}\" = \"windows-latest\" ]; then\n          7z a \"${asset_name}\" \"${{ matrix.artifact_name }}\"\n        else\n          tar czf \"${asset_name}\" \"${{ matrix.artifact_name }}\"\n        fi\n\n    - name: Upload binaries to release\n      uses: svenstaro/upload-release-action@v1-release\n      with:\n        repo_token: ${{ secrets.GITHUB_TOKEN }}\n        file: chomp*${{ matrix.asset_extension }}\n        file_glob: true\n        tag: ${{ github.ref }}\n"
  },
  {
    "path": ".github/workflows/test.yml",
    "content": "name: Test\n\non:\n  push:\n    branches: [ main ]\n  pull_request:\n    branches: [ main ]\n\nenv:\n  CARGO_TERM_COLOR: always\n  RUST_BACKTRACE: 1\n\njobs:\n  build-ubuntu:\n    runs-on: ubuntu-latest\n    steps:\n    - uses: actions/checkout@v3\n    - name: Build\n      run: cargo build --verbose\n    - name: Set PATH\n      run: echo \"$(pwd)/target/debug:$PATH\" >> $GITHUB_PATH\n    - uses: actions/checkout@v3\n      with:\n        repository: 'guybedford/chomp-extensions'\n        path: 'chomp-extensions'\n    - name: Run Core Tests\n      run: chomp -c test/chompfile.toml test\n      env:\n        CHOMP_CORE: ../chomp-extensions\n    - name: Run Template Tests\n      run: chomp -c chomp-extensions/chompfile.toml test\n\n  build-windows:\n    runs-on: windows-latest\n    steps:\n    - uses: actions/checkout@v3\n    - name: Install latest stable\n      uses: actions-rs/toolchain@v1\n      with:\n          toolchain: stable\n          override: true\n          components: cargo\n    - name: Build\n      run: cargo build --verbose\n    - name: Set PATH\n      run: echo echo \"$(pwd)/target/debug\" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append\n    - uses: actions/checkout@v3\n      with:\n        repository: 'guybedford/chomp-extensions'\n        path: 'chomp-extensions'\n    - name: Run Core Tests\n      run: chomp -c test/chompfile.toml test\n      env:\n        CHOMP_CORE: ../chomp-extensions\n    - name: Run Template Tests\n      run: chomp -c chomp-extensions/chompfile.toml test\n"
  },
  {
    "path": ".gitignore",
    "content": "node_modules\ntarget\ntest/package.json\npackage-lock.json\nsandbox\ntest/output\nvendor\n"
  },
  {
    "path": ".gitmodules",
    "content": ""
  },
  {
    "path": "Cargo.toml",
    "content": "[package]\nname = \"chompbuild\"\nversion = \"0.3.0\"\nauthors = [\"Guy Bedford <guybedford@gmail.com>\"]\nedition = \"2021\"\nlicense = \"Apache-2.0\"\nrepository = \"https://github.com/guybedford/chomp/\"\nhomepage = \"https://chompbuild.com/\"\nkeywords = [\"make\", \"task\", \"runner\", \"javascript\", \"web\"]\ncategories = [\"command-line-utilities\", \"development-tools\", \"web-programming\"]\nreadme = \"README.md\"\ndescription = \"Make-like parallel task runner with a JS extension system\"\n\n[[bin]]\nname = \"chomp\"\npath = \"src/main.rs\"\n\n[target.'cfg(target_os=\"windows\")'.dependencies.winapi]\nversion = \"0.3\"\nfeatures = [\"consoleapi\", \"errhandlingapi\", \"fileapi\", \"handleapi\"]\n\n[dependencies]\nanyhow = \"1\"\nasync-recursion = \"1\"\ncapturing-glob = \"0\"\nbase64 = \"0.22\"\nclap = \"4\"\nconvert_case = \"0.11\"\nderivative = \"2\"\ndirs = \"6\"\nfutures = \"0\"\nhyper = { version = \"1\", features = [\"client\", \"http1\", \"http2\"] }\nhyper-tls = \"0.6\"\nhyper-util = { version = \"0.1\", features = [\"client-legacy\", \"tokio\", \"http1\", \"http2\"] }\nhttp-body-util = \"0.1\"\nbytes = \"1\"\nlazy_static = \"1\"\nmime_guess = \"2\"\nnotify = \"8\"\nnotify-debouncer-mini = \"0.7\"\nnum_cpus = \"1\"\npercent-encoding = \"2\"\nregex = \"1\"\nserde = { version = \"1\", features = [\"derive\"] }\nserde_json = \"1\"\nserde_v8 = \"0.181.0\"\nsha2 = \"0.11\"\ntokio = { version = \"1\", features = [\"full\"] }\ntokio-stream = \"0.1\"\ntokio-util = \"0.7\"\ntoml = \"0.8\"\nuuid = { version = \"1\", features = [\"v4\"] }\nv8 = \"0.89\"\nwarp = { version = \"0.4\", features = [\"server\", \"websocket\"] }\ndirectories = \"6\"\npathdiff = \"0\"\n"
  },
  {
    "path": "LICENSE",
    "content": "\n                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright [yyyy] [name of copyright owner]\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License."
  },
  {
    "path": "README.md",
    "content": "# CHOMP\n\n[![Crates.io](https://img.shields.io/badge/crates.io-chompbuild-green.svg)](https://crates.io/crates/chompbuild)\n[![Discord](https://img.shields.io/badge/chat-on%20discord-green.svg?logo=discord)](https://discord.gg/5E9zrhguTy)\n\n\nChomp is a frontend task runner with advanced features focused on _ease-of-use_ and _not getting in the way_!\n\n1. An advanced task runner with a single command!\n1. Easily adapt existing projects / task systems - no need for a rewrite.\n1. You enable and manage advanced task runner features with single-line updates.\n\nChomp is a great option for frontend projects where the goal is getting advanced task runner features (like smart caching) without complexity and overhead.\n\n## One-line migration from npm scripts\n\nChomp can import a project's established `package.json` scripts without breaking them, as it supports the same features:\n\n```bash\nchomp --init --import-scripts\n```\n\nNow you can run your npm scripts using Chomp!\n\n> i.e `npm run <task>` becomes `chomp <task>` and behaves the same, and you can opt in to further features as needed.\n\nThe only difference is, with Chomp — it's faster. And, with a few more tweaks, you can enable smart caching, parallelism, and more!\n\n## What features does Chomp provide?\n\nChomp is an advanced task runner. It provides features similar to [turbo](https://turbo.build/repo) and [nx](https://nx.dev/) but focuses on ease of use, *not monorepos. It's based on the same principles as traditional make files.\n\n### Parallelism\n\nChomp [runs tasks in parallel](./docs/task.md#serial-dependencies), based on an extecuted task's dependencies!\n\n### Watch/Serve\n\nChomp [watches any task](./docs/task.md#watched-rebuilds) by including a `--watch` or `--serve` option! Read more about the power of [`--watch`](./docs/task.md#watched-rebuilds) and [`--serve`](./docs/task.md#static-server).\n\n### A JS extension system\n\nChomp has a [JS extension system](./docs/extensions.md) that allows you to extend Chomp with your own custom tasks\n\n### Smart caching\n\nChomp [caches tasks](./docs/task.md#task-caching) based on task dependencies like other tasks or updated files. You don't have to worry about it!\n\n> \\*Chomp works for monrepos but it's architected for ease of use and not getting in the way first.\n\n## Install\n\nIf you use [Cargo](https://rustup.rs/), run:\n\n```\ncargo install chompbuild\n```\n\nIf you don't use Cargo, run:\n\n```\nnpm install -g chomp\n```\n\n> Note: npm scripts add over 100ms to the script run time.\n\nCommon platform binaries are also available for [all releases](https://github.com/guybedford/chomp/releases).\n\nTo quickly set up Chomp in a GitHub Actions CI workflow, see the [Chomp GitHub Action](https://github.com/guybedford/chomp-action).\n\n## Documentation\n\n* [CLI Usage](https://github.com/guybedford/chomp/blob/main/docs/cli.md)\n* [Chompfile Definitions](https://github.com/guybedford/chomp/blob/main/docs/chompfile.md)\n* [Task Definitions](https://github.com/guybedford/chomp/blob/main/docs/task.md)\n* [Extensions](https://github.com/guybedford/chomp/blob/main/docs/extensions.md)\n\n## Getting Started\n\n### Migrating from npm Scripts\n\nTo convert an existing project using npm `\"scripts\"` to Chomp, run:\n\n```sh\n$ chomp --init --import-scripts\n√ chompfile.toml created with 2 package.json script tasks imported.\n```\n\nor the shorter version:\n\n```sh\n$ chomp -Ii\n√ chompfile.toml created with 2 package.json script tasks imported.\n```\n\nThen use `chomp <name>` instead of `npm run <name>`, and enjoy the new features of task dependence, incremental builds, and parallelism!\n\n### Hello World\n\n`chomp` works against a [`chompfile.toml`](https://github.com/guybedford/chomp/blob/main/docs/chompfile.md) [TOML configuration](https://toml.io/) in the same directory as the `chomp` command is run.\n\nChomp builds up tasks as trees of files which depend on other files, then runs those tasks with [maximum parallelism](https://github.com/guybedford/chomp/blob/main/docs/task.md#task-dependence).\n\nFor example, here's a task called `hello` which builds `hello.txt` based on the contents of `name.txt`, which itself is built by another command:\n\nchompfile.toml\n```toml\nversion = 0.1\n\n[[task]]\ntarget = 'name.txt'\nrun = '''\n  echo \"No name.txt, writing one.\"\n  echo \"World\" > name.txt\n'''\n\n[[task]]\nname = 'hello'\ntarget = 'hello.txt'\ndep = 'name.txt'\nrun = '''\n  echo \"Hello $(cat name.txt)\" > hello.txt\n'''\n```\n\nwith this file saved, the hello command will run all dependency commands before executing its own command:\n\n```sh\n$ chomp hello\n\n🞂 name.txt\nNo name.txt, writing one.\n√ name.txt [4.4739ms]\n🞂 hello.txt\n√ hello.txt [5.8352ms]\n\n$ cat hello.txt\nHello World\n```\n\nFinally it populates the `hello.txt` file with the combined output.\n\nSubsequent runs use the mtime of the target files to determine what needs to be rerun.\n\nRerunning the `hello` command will see that the `hello.txt` target is defined, and that the `name.txt` dependency didn't change, so it will skip running the command again:\n\n```sh\nchomp hello\n\n● name.txt [cached]\n● hello.txt [cached]\n```\n\nChanging the contents of `name.txt` will then invalidate the `hello.txt` target only, not rerunning the `name.txt` command:\n\n```sh\n$ echo \"Chomp\" > name.txt\n$ chomp hello\n\n● name.txt [cached]\n  hello.txt invalidated by name.txt\n🞂 hello.txt\n√ hello.txt [5.7243ms]\n\n$ cat hello.txt\nHello Chomp\n```\n\nThe [`deps`](https://github.com/guybedford/chomp/blob/main/docs/task.md#task-dependence) array can be defined for targets, whose targets will then be run first with [invalidation based on target / deps mtime comparisons](https://github.com/guybedford/chomp/blob/main/docs/task.md#task-caching) per the standard Makefile approach.\n\nPowershell is used on Windows, while Bash is used on POSIX systems. Since both `echo` and `>` are defined on both systems, the examples above work cross-platform (Powershell is automatically put into UTF-8 mode for `>` to work similarly).\n\nNote that `&&` and `||` are not supported in Powershell, so multiline scripts and `;` are preferred instead.\n\n#### JS Tasks\n\nAlternatively we can use `engine = 'node'` or `engine = 'deno'` to write JavaScript in the `run` function instead:\n\nchompfile.toml\n```toml\nversion = 0.1\n\n[[task]]\ntarget = 'name.txt'\nengine = 'node'\nrun = '''\n  import { writeFile } from 'fs/promises';\n  console.log(\"No name.txt, writing one.\");\n  await writeFile(process.env.TARGET, 'World');\n'''\n\n[[task]]\nname = 'hello'\ntarget = 'hello.txt'\ndeps = ['name.txt']\nengine = 'node'\nrun = '''\n  import { readFile, writeFile } from 'fs/promises';\n  const name = (await readFile(process.env.DEP, 'utf8')).trim();\n  await writeFile(process.env.TARGET, `Hello ${name}`);\n'''\n```\n\nTasks are run with maximum parallelism as permitted by the task graph, which can be controlled via the [`-j` flag](https://github.com/guybedford/chomp/blob/main/docs/cli.md#jobs) to limit the number of simultaneous executions.\n\nUsing the [`--watch` flag](https://github.com/guybedford/chomp/blob/main/docs/cli.md#watch) watches all dependencies and applies incremental rebuilds over invalidations only.\n\nOr, using `chomp hello --serve` runs a [static file server](https://github.com/guybedford/chomp/blob/main/docs/task.md#static-server) with watched rebuilds.\n\nSee the [task documentation](https://github.com/guybedford/chomp/blob/main/docs/task.md) for further details.\n\n#### Monorepos\n\nThere is no first-class monorepo support in chomp, but some simple techniques can achieve the same result.\n\nFor example, consider a monorepo where `packages/[pkgname]/chompfile.toml` defines per-package tasks.\n\nA base-level `chompfile.toml` could run the `test` task of all the sub-packages with the following `chompfile.toml`:\n\n```toml\n[[task]]\nname = 'test'\ndep = 'packages/#/chompfile.toml'\nrun = 'chomp -c $DEP test'\n```\n\n`chomp test` will then use [task interpolation](https://github.com/guybedford/chomp/blob/main/docs/task.md#task-interpolation) to run the multiple sub-package test tasks in parallel. A similar approach can also be used for a [basic unit testing](https://github.com/guybedford/chomp/blob/main/docs/task.md#testing).\n\nBy adding [`serial = 'true'`](https://github.com/guybedford/chomp/blob/main/docs/task.md#serial-dependencies), the interpolation can be made to run in series rather than in parallel.\n\nCross-project dependencies are [not currently supported](https://github.com/guybedford/chomp/issues/119). Instead, if `packages/a/chompfile.toml`'s build task depends on `packages/b/chompfile.toml`'s build task to run first, then `packages/a/chompfile.toml` might look like:\n\n```toml\n[[task]]\nname = 'build'\nrun = 'cargo build'\ndep = 'build:deps'\n\n[[task]]\nname = 'build:deps'\nrun = 'chomp -c ../a build'\n```\n\nThis would still be fast, so long as `packages/a/chompfile.toml`'s `build` task has its targets and dependencies properly configured to do zero work if the all target mtimes are greater than their dependencies.\n\n### Extensions\n\nExtensions are able to register task templates for use in Chompfiles.\n\nExtensions are loaded using the `extensions` list, which can be any local or remote JS file:\n\n```toml\nversion = 0.1\nextensions = [\n  \"./local.js\",\n  \"https://remote.com/extension.js\"\n]\n```\n\nA core extensions library is provided with useful templates for the JS ecosystem, with\nthe short protocol `chomp:ext`, a shorthand for the `@chompbuild/extensions` package contents.\n\nA simple example is included below.\n\n_See the [@chompbuild/extensions package](https://github.com/guybedford/chomp-extensions) for extension descriptions and examples._\n\n#### Example: TypeScript with SWC\n\nTo compile TypeScript with the SWC template:\n\n```toml\nversion = 0.1\nextensions = ['chomp@0.1:swc']\n\n[[task]]\nname = 'build:typescript'\ntemplate = 'swc'\ntarget = 'lib/##.js'\ndeps = ['src/##.ts']\n```\n\nIn the above, all `src/**/*.ts` files will be globbed, have SWC run on them, and output into `lib/[file].js` along with their source maps.\n\nThe `##` and `#` interpolation syntax is special because, unlike glob dependencies (which are also supported), there must be a 1:1 relationship between a dependency and its target.\n\nOnly non-existent files, or files whose `src` mtimes are invalidated will be rebuilt. If SWC itself is updated, all files that depend on it will be re-built.\n\nSpecific files or patterns can be built directly by name as well, skipping all other build work:\n\n```sh\nchomp lib/main.js lib/dep.js\n\n🞂 lib/dep.js\n🞂 lib/app.js\n√ lib/dep.js [317.2838ms]\n√ lib/app.js [310.0831ms]\n```\n\nPatterns are also supported for building tasks by name or filename (the below two commands are equivalent):\n\n```sh\n$ chomp lib/*.js\n$ chomp :build:*\n```\n\nTo remove the template magic, run `chomp --eject` to convert the `chompfile.toml` into its untemplated form:\n\n```sh\n$ chomp --eject\n\n√ chompfile.toml template tasks ejected\n```\n\nResulting in the updated _chompfile.toml_:\n\n```toml\nversion = 0.1\n\n[[task]]\nname = 'build:typescript'\ntarget = 'lib/##.js'\ndep = 'src/##.ts'\nstdio = 'stderr-only'\nrun = 'node ./node_modules/@swc/cli/bin/swc.js $DEP -o $TARGET --no-swcrc --source-maps -C jsc.parser.syntax=typescript -C jsc.parser.importAssertions=true -C jsc.parser.topLevelAwait=true -C jsc.parser.importMeta=true -C jsc.parser.privateMethod=true -C jsc.parser.dynamicImport=true -C jsc.target=es2016 -C jsc.experimental.keepImportAssertions=true'\n```\n\n# License\n\nApache-2.0\n"
  },
  {
    "path": "chompfile.toml",
    "content": "version = 0.1\n\ndefault-task = 'build'\n\n[[task]]\nname = 'build'\ndeps = ['src/**/*.rs']\nrun = 'cargo build'\n\n[[task]]\nname = 'build:release'\ndeps = ['src/**/*.rs']\nrun = 'cargo build --release --locked'\n\n[[task]]\nname = 'test'\ndep = 'build'\ncwd = 'test'\nrun = '../target/debug/chomp test'\n\n[[task]]\nname = 'install'\nserial = true\ndep = 'build'\nrun = 'cp ./target/[dD]ebug/chomp* ~/bin/'\n\n[[task]]\nname = 'inline-version'\ndeps = ['Cargo.toml']\ntargets = ['src/main.rs', 'node-chomp/package.json']\nengine = 'node'\nrun = '''\n  import { readFileSync, writeFileSync } from 'fs';\n  const toml = readFileSync('Cargo.toml', 'utf8');\n  const [, version] = toml.match(/version\\s*=\\s*\\\"(\\d+\\.\\d+\\.\\d+)\\\"/);\n  const main = readFileSync('src/main.rs', 'utf8');\n  writeFileSync('src/main.rs', main.replace(/let version = \"\\d+.\\d+.\\d+/g, `let version = \"${version}`));\n  const pjson = JSON.parse(readFileSync('node-chomp/package.json', 'utf8'));\n  pjson.version = version;\n  writeFileSync('node-chomp/package.json', JSON.stringify(pjson, null, 2));\n'''\n"
  },
  {
    "path": "docs/chompfile.md",
    "content": "# Chompfile\n\nChomp projects are defined by a `chompfile.toml`, with Chompfiles defined using the [TOML configuration format](https://toml.io/).\n\nThe default Chompfile is `chompfile.toml`, located in the same directory as the `chomp` binary is being run from.\n\nCustom configuration can be used via `chomp -c custom.toml` or `chomp -c ./nested/chompfile.toml`.\n\nAll paths within a Chompfile are relative to the Chompfile itself regardless of the invocation CWD.\n\n## Example\n\nTo create a new Chomp project, create a new file called `chompfile.toml` and add the following lines:\n\nchompfile.toml\n```toml\nversion = 0.1\n\n[[task]]\nname = 'build'\nrun = 'echo \"Chomp Chomp\"'\n```\n\nIn the command line, type `chomp build` or just `chomp` (_\"build\"_ is the default task when none is given):\n\n```sh\n$ chomp\n\n🞂 :build\nChomp Chomp\n√ :build [6.3661ms]\n```\n\nto get the runner output.\n\nEvery Chompfile must start with the `version = 0.1` version number, at least until the project stabilizes.\n\nSee the [task documentation](tasks.md) for defining tasks.\n\n## Chompfile Definitions\n\nThe Chompfile supports the following definitions:\n\nchompfile.toml\n```toml\n# Every Chompfile must start with the Chompfile version, currently 0.1\nversion = 0.1\n\n# The default task name to run when `chomp` is run without any CLI arguments\ndefault_task = \"test\"\n\n# List of Chomp Extensions to load\nextensions = [\"extension-path\"]\n\n# Environment variables for all runs\n[env]\nENV_VAR = \"value\"\n\n# Default environment variables to only set if not already for all runs\n[env-default]\nDEFAULT_VAR = \"value\"\n\n# Static server options for `chomp --serve`\n[server]\n# Static server root path, relative to the Chomp file\nroot = \"public\"\n# Static server port\nport = 1010\n\n# Default template options by registered template name\n# When multiple tasks use the same template, this avoids duplicated `[template-options]` at the task level\n[template-options.<template name>]\nkey = value\n\n# Task definitions\n# Tasks are a TOML list of Task objects, which define the task graph\n[[task]]\nname = \"TASK\"\nrun = \"shell command\"\n```\n\nSee the [task documentation](task.md) for defining tasks, and the [extension documentation](extensions.md) for defining Chompfile extensions.\n"
  },
  {
    "path": "docs/cli.md",
    "content": "# CLI Flags\n\nUsage:\n\n```\nchomp [FLAGS/OPTIONS] <TARGET>...\n```\n\nChomp takes the following arguments and flags:\n\n* [`<TARGET>...`](#target): List of targets to build\n* [`-C, --clear-cache`](#clear-cache): Clear URL extension cache\n* [`-c, --config`](#config): Custom chompfile project or path [default: chompfile.toml]\n* [`--eject`](#eject): Ejects templates into tasks saving the rewritten chompfile.toml\n* [`-f, --force`](#force): Force rebuild targets\n* [`-F, --format`](#format): Format and save the chompfile.toml\n* [`-h, --help`](#help): Prints help information\n* [`-I, --import-scripts`](#import-scripts): Import npm package.json \"scripts\" into the chompfile.toml\n* [`-i, --init`](#init): Initialize the chompfile.toml if it does not exist\n* [`-j, --jobs`](#jobs): Maximum number of jobs to run in parallel\n* [`-l, --list`](#list): List the available chompfile tasks\n* [`-p, --port`](#port): Custom port to serve\n* [`-r, --rerun`](#rerun): Rerun the listed targets without caching\n* [`-s, --serve`](#serve): Run a local dev server\n* [`-R, --server-root`](#server-root): Server root path\n* [`-V, --version`](#version): Prints version information\n* [`-w, --watch`](#watch): Watch the input files for changes\n\n## Target\n\nThe main arguments of the `chomp` command are a list of targets to build.\n\nBuild targets can be task names, file paths relative to the `chompfile.toml`, or glob patterns of task names or file paths to build.\n\nTo disambiguate task names from file paths, task names can always be referenced with a `:` prefix - `chomp :test` instead of `chomp test`.\n\nOnly the necessary work to produce the provided targets will be performed, taking into account [task dependence](task.md#task-dependence).\n\nWhen no target is provided, the `default-task` defined in the Chompfile is run, if set.\n\n## Clear Cache\n\nWhen loading Chomp extensions from external URLs via the [`extensions` configuration](task.md#loading-extensions),\nremote extensions are cached in the user-local `[cachedir]/.chomp/` folder.\n\nExtensions are cached permanently regardless of cache headers to optimize for task run execution time.\n\nRun `chomp --clear-cache` to clear these caches.\n\nWhere possible, use unique versioned URLs for remote extensions.\n\n## Config\n\nUsually Chomp will look for `chompfile.toml` within the current working directory.\n\nRunning `chomp -c ./path/to/chompfile.toml` allows running Chomp on a folder that is not the current working directory,\nor running Chomp against a Chompfile with another name than `chompfile.toml`.\n\n## Force\n\nWhen running a task, the default [invalidation rules](task.md#task-invalidation-rules) of that [task dependence graph](task.md#task-dependence) will apply.\n\nTo treat all tasks in the target graph as invalidated, the `chomp -f task` flag can be useful to ensure everything is fresh.\n\n## Format\n\n`chomp --format` will apply the default serialization formatting to the `chompfile.toml` file.\n\nNote this command will overwrite the existing `chompfile.toml` with the new formatting.\n\nThis command is compatible with the [`--config`](#config) flag to choosing the Chompfile to operate on.\n\nDue to limitations with the Rust TOML implementation, comments are currently stripped by this operation.\n\n## Help\n\nCLI help is available via `chomp -h`.\n\n## Jobs\n\nSets the maximum number of task runs to spawn in parallel. Defaults to the logical CPU count.\n\nBy default tasks in Chomp are run with [maximum parallelization](task.md#task-parallelization).\n\n## List\n\n`chomp --list` will output a listing of the named tasks of the current `chompfile.toml` or Chompfile specified by [`--config`](#config).\n\n## Port\n\nWhen using [`chomp --serve`](#serve) to run a local static server, customizes the static server port. Defaults to `8080`.\n\n## Rerun\n\nUseful to rerun specific tasks without caching without invalidating the whole tree.\n\n`chomp -r x` will rerun task `x` even if it is cached, but without rerunning its cached dependencies.\n\nTo invalidate the full task graph use [`chomp --force`](#force).\n\n## Serve\n\nEnables the file watcher, and runs a static server with the optionally [`--port`](#port) and [`--server-root`](#server-root), which are also customizable in the [Chompfile](chompfile.md).\n\nWhen serving, a list of [task targets](#target) is still taken to watch.\n\n## Server Root\n\nWhen using [`chomp --serve`](#serve) to run a local static server, customizes the site root to serve. Defaults to the same folder as the Chompfile.\n\n## Version\n\nThe current Chomp version is available via `chomp --version`\n\n## Watch\n\nThe `--watch` flag instructs Chomp to continue running after completing the tasks, and listen to any changes to all files that were touched by the [task dependency graph](task.md#task-dependence).\n\nA [list of targets](#target) is supplied like any other Chomp run, which then informs which files are watched.\n"
  },
  {
    "path": "docs/extensions.md",
    "content": "# Extensions\n\n## Overview\n\nExecutions are loaded through the embedded V8 environment in Chomp, which includes a very simple `console.log` implementation and basic error handling, an `ENV` global detailed below and a `Chomp` global detailed below.\n\nExtensions must be declared in the active `chompfile.toml` in use via the `extensions` list, and can be loaded from any path or URL.\n\nURL extensions are fetched from the network and cached indefinitely in the global Chomp cache folder.\n\nAll executions are immediately invoked during the initialization phase, and all registrations must be made during this phase. Any\nhook registrations via the `Chomp` global made after this initialization phase will throw an error.\n\nRegistrations made by Chomp extensions can then hook into phases of the Chomp task running lifecycle including the process of task list population, template expansion and batching of tasks.\n\n## Publishing Extensions\n\nWhen developing extensions, it is recommended to load them by relative file paths:\n\nchompfile.toml\n```toml\nversion = 0.1\n\nextensions = ['./local-extension.js']\n```\n\nWhen sharing the extension between projects, hosting it on any remote URL is supported by Chomp.\n\nNote that remote URLs are cached indefinitely regardless of cache headers for performance so it is recommended to include the version in the URL. `chomp --cache-clear` can be used to clear this remote cache.\n\nIf publishing to npm, templates will be available on any npm CDN like `unpkg.com` or `ga.jspm.io`.\n\nIf publishing to JSPM, set the `package.json` property `\"type\": \"script\"` to inform JSPM the `.js` files are scripts and not modules to avoid incorrect processing.\n\n## API\n\nJavaScript extensions register hooks via the `Chomp` global scripting interface.\n\nTypeScript typing is not currently available for the `Chomp` global. PRs to provide this typing integration would be welcome.\n\n### ENV\n\nThe `ENV` JS global is available in extensions, and contains all environment variables as a dictionary.\n\nThe following Chomp-specific environment variables are also defined:\n\n* `ENV.CHOMP_EJECT`: When `--eject` is passed for template injection this is set to `\"1\"`.\n* `ENV.CHOMP_POOL_SIZE`: Set to the maximum number of jobs for Chomp, which is usually the CPU count, or the value passed to the `-j` flag when running Chomp.\n\n### Core Templates\n\nSome [experimental Chomp extensions](https://github.com/guybedford/chomp-extensions) are provided for the JS ecosystem, and PRs to this repo are very welcome.\n\nThese templates can be loaded via the `chomp:[name]` extension names.\n\nBy default these templates are loaded from the JSPM CDN at `https://ga.jspm.io/npm:@chompbuild/extensions@x.y.z/[name].js`.\n\nThis path can be overridden to an alternative remote URL or local path by setting the `CHOMP_CORE` environment variable.\n\n### Chomp.addExtension(extension: string)\n\nExtensions may load other extensions from any path or URL. Relative URLs are supported for loading extensions relative to the current extension location. Examples:\n\n```js\nChomp.addExtension('https://site.com/extension.js');\nChomp.addExtension('./local.js');\n```\n\nExtensions are resolved to absolute URLs internally so that a given extension can only be loaded once even if `addExtension` is\ncalled repeatedly on the same extension script.\n\n### Chomp.registerTask(task: ChompTask)\n\nArbitrary tasks may be added as if they were defined in the users Chompfile. This is useful for common tasks\nthat are independent of the exact project, such as initialization, workflow and bootstrapping tasks.\n\n`ChompTask` is the same interface as in the TOML definition, except that base-level kebab-case properties are\ninstead provided as camelCase.\n\nNote that extension-registered tasks are not output when running template ejection via `chomp --eject`.\n\n#### Example: Configuration Initialization Task\n\nAn example of an initialization task is to create a configuration file if it does not exist:\n\n```js\nChomp.registerTask({\n  name: 'config:init',\n  engine: 'node',\n  target: 'my.config.json',\n  run: `\n    import { writeFileSync } from 'fs';\n\n    const defaultConfig = {\n      some: 'config'\n    };\n\n    // (this task only never runs when my.config.json does not exist)\n    writeFileSync(process.env.TARGET, JSON.stringify(defaultConfig, null, 2));\n\n    console.log(\\`\\${process.env.TARGET} initialized successfully.\\`);\n  `\n});\n```\n\n### Chomp.registerTemplate(name: string, template: (task: ChompTask) => ChompTask[])\n\nRegisters a template function to a template name. In the case of multiple registration, the last registered template function for a given template name will apply, permitting overrides.\n\nTemplate task expansion happens early during initialization, and is independent of user options. All template tasks are expanded into\nuntemplated tasks internally until the final flat non-templated task list is found, which is used as the task list for the runner.\n\nTasks with a `template` field will call the associated registered template function with the task as the first argument to the template function. The template function can then return an array of tasks to register for the current run (whether they execute is still as defined by the task graph). Templates may return tasks that in turn use templates, which are then expanded recursively.\n\nWhen `--eject` is used, this same expanded template list is saved back to the `chompfile.toml` itself to switch to an untemplated\nconfiguration form. The `ENV.CHOMP_EJECT` global variable can be used to branch behaviour during ejection to provide a more user-suitable output where appropriate.\n\nFor template usage options validation, normal JS errors thrown will be reported with their message to the user. Template options\nshould be validated this way.\n\n#### Example: Runner Template\n\nAn example of a simple run template to abstract the execution details of a task:\n\n```toml\nversion = 0.1\n\n[[task]]\nname = 'template-example'\ntemplate = 'echo'\n\n[task.template-options]\nmessage = 'chomp chomp'\n```\n\nWith execution `chomp template-example` writing `chomp chomp` to the console.\n\n```js\nChomp.registerTemplate('echo', function (task) {\n  if (typeof task.templateOptions.message !== 'string')\n    throw new Error('Echo template expects a string message in template-options.');\n  if (task.run || task.engine)\n    throw new Error('Echo template does not expect a run or engine field.');\n  return [{\n    // task \"name\" and \"deps\" need to be passed through manually\n    // and similarly for tasks that define targets\n    name: task.name,\n    deps: task.deps,\n    run: `echo ${JSON.stringify(task.templateOptions.message)}`\n  }];\n});\n```\n\nTemplates get a lot more useful when they use the Deno or Node engines, as they can then\nfully encapsulate custom wrapper code for arbitrary computations from ecosystem libraries\nthat do not have CLIs.\n\n### Chomp.registerBatcher(name: string, batcher: (batch: CmdOp[], running: BatchCmd[]) => BatcherResult | undefined)\n\n#### Overview\n\nBatchers act as reducers of task executions into system execution calls. They allow for custom queueing and coalescing of task runs.\n\nFor example, consider a task that performs an `npm install` - only one `npm install` operation must ever run at a time, if a previous install is running the task should wait for it to finish executing first (queuing). Furthermore, if two npm installs (`npm install a` and `npm install b` say) are queued at the same time they can be combined together into a single npm install call: `npm install a b` (coalescing).\n\nThis is the primary use case for batchers - combining together task executions into singular executions where that will save time.\n\n_Batching is a complex topic, and is more about the exact use case solutions at hand. In most cases extensions needn't worry about batching until they really need to carefully optimize and control execution invocations for performance._\n\n#### Lifecycle\n\nTask executions are collected as a `CmdOp` list, with batching run periodically against this list. Batchers then combine and queue executions as necessary.\n\nUnder the batching model, the lifecycle of an execution includes the following steps:\n\n1. Task is batched as a `CmdOp` representing the execution of the task (the `run` and `engine` pair). This forms the batch command queue, `batch`, which is a fixed list for a given batching operation.\n2. Every 5 milliseconds, if there are batched commands, the batcher phase is initiated on the `batch` list, where all registered batchers are each passed the `batch` queue to process it in order. They are also passed the list of running executions `running` as the second argument.\n3. As each `CmdOp` in the `batch` is processed by a batcher, by being assigned by the `BatcherResult` of the batcher, it is removed from the `batch` list so the next batcher will not see it. The final _default batcher_ will just naively run the execution with simple CPU-based pooling.\n\n#### CmdOp\n\nThe queued commands of `CmdOp` are defined as:\n\n```typescript\ninterface CmdOp {\n  id: number,\n  run: string,\n  engine: 'deno' | 'node' | 'cmd',\n  name?: string,\n  cwd?: string,\n  env: Record<string, string>,\n}\n```\n\nThe `id` of the task operation is importantly used to key the batching process. Task executions are primarily defined by their `run` and `engine` pair.\n\n#### BatchCmd\n\nBatched execution commands have an almost identical interface to `CmdOp`, except with a list of `ids` of the `CmdOp` ids whose completion is fulfilled by this execution.\n\n```typescript\ninterface BatchCmd {\n  ids: number[],\n  run: string,\n  engine: 'deno' | 'node' | 'cmd',\n  cwd?: string,\n  env: BTreeMap<string, string>,\n}\n```\n\nEach `BatchCmd` real spawned execution thus corresponds to one or more `CmdOp` execution, as the reduction output of batching.\n\n#### BatcherResult\n\n`BatcherResult`, the return value of the batching function, forms the execution combining and queuing operation of the batcher. It has three optional return properties - `queue`, `exec` and `completionMap`. `queue` is a list of tasks to defer for the next batch queue allowing the ability to delay their execution. `exec` is the list of `BatchCmd` executions to immediately invoke. And `completionMap`, less commonly used, allows associating the completion of one of the operations in the batch to the completion of a currently running task in the already-`running` list.\n\n```typescript\ninterface BatcherResult {\n  queue?: number[],\n  exec?: BatchCmd[],\n  completionMap?: Record<number, number>,\n}\n```\n\nAs soon as any batcher assigns the `id` of `CmdOp` via one of these properties, that task command is considered assigned, and removed from the batch list for the next batcher. At the end of calling all batchers, any remaining task commands in the batch list are just batched by the default batcher.\n\n#### Example: Default Batcher\n\nThe code for the default batcher is a good example of how simple batching can work. It does not combine executions so creates one batch execution for each task execution, while respecting the job limit reflected via `ENV.CHOMP_POOL_SIZE` which ensures the CPUs is utilized efficiently:\n\n```js\n// Chomp's default batcher, without any batcher extensions:\nChomp.registerBatcher('defaultBatcher', function (batch, running) {\n  // If we are already running the maximum number of jobs, defer the whole batch\n  if (running.length >= ENV.CHOMP_POOL_SIZE)\n    return { defer: batch.map(({ id }) => id) };\n  \n  return {\n    // Create a single execution for every item in the batch, up to the pool size\n    exec: batch.slice(0, POOL_SIZE - running.length).map(({ id, run, engine, name, cwd, env }) => ({\n      ids: [id],\n      run,\n      engine,\n      cwd,\n      env\n    })),\n    // Once we hit the pool size limit, defer the remaining batched executions\n    defer: batch.slice(POOL_SIZE - running.length).map(({ id }) => id)\n  };\n});\n```\n\nBecause batchers run one after another, having this exact above default batcher run last means it will take care of pooling so most batchers don't need to worry so much about it. Instead most batchers just focus on the specific executions they are interested in to run, queue and combine those executions they care about specifically, while within the pool limit.\n\nThus, most batchers are of the form:\n\n```js\nChomp.registerBatcher('my-batcher', function (batch, running) {\n  if (running.length >= ENV.CHOMP_POOL_SIZE) return;\n\n  const exec = [];\n  for (const item of batch) {\n    // ignore anything not intresting to this batcher, or if we have hit the pool limit\n    if (!is_interesting(item) || exec.length + running.length >= POOL_SIZE) continue;\n    \n    // push the batched execution we're interested in,\n    // usually matching it up with another to combine their executions\n    exec.push({ ...item, ids: [item.id] });\n  }\n\n  return { exec };\n});\n```\n\n#### Running List and Completion Map\n\nAll commands that have already been spawned and have not returned a final status code and terminated their running OS process\nare provided in the `running` list as the second argument to the batcher.\n\nIn the `running` list, each `BatchCmd` will also have an associated `id` corresponding to the batch id, which is distinct from the `CmdOp` id.\n\nThe completion map is a map from `CmdOp` id to `BatchCmd` id, which allows associating the current batch execution fulfillment with the completion of a currently executing task. This is useful for eg a generic `npm install` operation, where if an `npm install` is already running we should simply attach to that instead of queueing another `npm install`. Effectively a mapping forming a late adding to the `ids` list of that previously batched command. Because multiple `CmdOp`s can map to the same `BatchCmd` in this map structure, it supports the same type of many-to-one completion attachment as the `ids` list, just for already-running tasks as opposed to as part of the baching reduction to begin with - the difference being already running tasks cannot be altered or stopped as they have already started.\n\n#### Combining Tasks\n\nThe coalescing of batch tasks implies reparsing the `run` or `env` vars of `CmdOp` task executions to collate them into a single `run` and `env` on a `BatchCmd` return of the `exec` property of the `BatcherResult`. It was a choice between this model, or modelling the data structure of the command calls more abstractly first, which seemed unnecessary overhead when execution parsing can suffice and with the flexibility of the JS language implementation the edge cases can generally be well handled.\n\nSee the core [Chomp extensions](https://github.com/guybedford/chomp-extensions) repo for further understanding through the direct examples of these hooks in use. The `npm.js` batcher demonstrates `npm install` batching and the `swc.js` batcher demonstrates compilation batching.\n"
  },
  {
    "path": "docs/task.md",
    "content": "# Chomp Tasks\n\nThe [`chompfile.toml`](chompfile.md) defines Chomp tasks as a list of Task objects of the form:\n\n_chompfile.toml_\n```toml\nversion = 0.1\n\n[[task]]\nname = 'echo'\nrun = 'echo \"Chomp\"'\n```\n\n_<div style=\"text-align: center\">An example Chompfile.</div>_\n\nRunning `chomp echo` will output the echo command.\n\n## Task API\n\nTasks support the following optional properties:\n\n* **name**: `String`, the unique task name string.\n* **target**: `String`, the file path created or modified by this task. Singular sugar for a single `targets: [String]`.\n* **targets**: `String[]`, the list of file paths created or modified by this task, identical to `target` when there is a single target.\n* **dep**: `String`, the task names or file paths this task [depends on](#task-dependence). Singular sugar for a single `deps: [String]`.\n* **deps**: `String[]`, the task names of file paths this task [depends on](#task-dependence), identical to `dep` when there is a single dependency.\n* **serial**: `Boolean`, whether [task dependencies](#task-dependence) should be processed in serial order. Defaults to false for parallel task processing.\n* **invalidation**: `\"always\" | \"mtime\" (default) | \"not-found\"`, the [task caching invalidation rules](#task-invalidation). By default a task is cached based on its target path having an mtime greater than its dependencies per \"make\" semantics. `\"always\"` never caches, and `\"not-found\"` will never rerun the task if the target exists.\n* **display**: `\"none\" | \"init-status\" | \"init-only\" | \"status-only\" | \"dot\"`, defaults to `\"init-status\"`. Useful to reduce noise in the output log. Init is the note that the task has begun, while status is the note of task success or caching. Task errors will always be reported even with `display: 'none'`. `\"dot\"` outputs a dot for each run only, for a test-like output when used alongside `stdio = 'stderr-only'`.\n* **echo**: `Boolean`, defaults to false - whether to echo the executed command of the task.\n* **stdio**: `\"none\" | \"no-stdin\" | \"stdout-only\" | \"stderr-only\" | \"all\"`, defaults to `\"all\"` where stderr and stdout are piped to the main process output and stdin is also accepted. Set to `\"no-stdin\"` to disable the stdin for tasks. `\"stdout-only\"` and `\"stderr-only\"` will output only those streams.\n* **engine**: `\"node\" | \"deno\" | \"cmd\" (default)`, the [execution engine](#task-execution) to use for the `run` string. For `node` or `deno` it is a Node.js or Deno program source string as if executed in the current directory.\n* **run**: `String`, the source code string to run in the `engine`.\n* **cwd**: `String`, the working directory to use for the `engine` execution.\n* **env**: `{ [key: String]: String }`, custom environment variables to set for the `engine` execution.\n* **env-default**: `{ [key: String]: String }`, custom default environment variables to set for the `engine` execution, only if not already present in the system environment.\n* **env-replace**: `Boolean`, defaults to `true`. Whether to support `${{VAR}}` style static environment variable replacements in the `env` and `env-default` environment variable declarations and the `run` script of Shell engine tasks.\n* **template**: `String`, a registered template name to use for task generation as a [template task](#extensions).\n* **template-options**: `{ [option: String]: any }`, the dictionary of options to apply to the `template` [template generation](#extensions), as defined by the template itself.\n* **validation**: `\"none\" | \"ok-only\" | \"targets-only\" | \"ok-targets (default)`, Validation check to determine task success condition. The default is to check the defined targets all exist and the task exited with a success status code. `\"ok-only\"` just verifies the status code, `\"targets-only\"` just verifies the targets, and `\"none\"` always treats the task as successful.\n\n## Task Execution\n\nChomp tasks are primarily characterized by their `\"run\"` and `\"engine\"` pair, `\"run\"` representing the source code of a task execution in the `\"engine\"` execution environment. Currently supported engines include the shell execution (the default), Node.js (`engine = 'node'`) or Deno (`engine = 'deno'`).\n\nThere are two ways to execute in Chomp:\n\n* Execute a task by _name_ - `chomp [name]` or `chomp :[name]` where `[name]` is the `name` field of the task being run.\n* Execute a task by _target_ file path - `chomp [target]` where `[target]` is the local file path to generate relative to the Chompfile being run.\n\n_chompfile.toml_\n```toml\nversion = 0.1\n\n[[task]]\nname = 'my-task'\ntarget = 'output.txt'\nrun = 'cat \"Chomp Chomp\" > output.txt'\n```\n_<div style=\"text-align: center\">This task writes the text `Chomp Chomp` into the file at `output.txt`, defining this file as a target output path of the task so that the task is cached.</div>_\n\nThis task writes the text `Chomp Chomp` into the file at `output.txt`, defining this as a target file output of the task.\n\n```sh\n$ chomp my-task\n$ chomp :my-task\n$ chomp output.txt\n\n🞂 output.txt\n√ output.txt [3.8352ms]\n```\n\n_<div style=\"text-align: center\">The same task can be called by task name (with or without `:` prefix) or by target path.</div>_\n\nThe leading `:` can be useful to disambiguate task names from file names when necessary. Setting a `name` on a task is completely optional.\n\nOnce the task has been called, with the target file already existing it will treat it as cached and skip subsequent executions:\n\n```sh\n$ chomp my-task\n\n● output.txt [cached]\n```\n\n### Task Completion\n\nA task is considered to have succeeded if it completes with a zero exit code, and the target or targets\nexpected of the task all exist.\n\nIf the spawned process returns a non-zero exit code the task and all its parents will be marked as failed.\n\nIf after completion, any of the targets defined for the task still do not exist, then the task is also marked as failed.\n\n### Shell Tasks\n\nThe default `engine` is the shell environment - PowerShell on Windows or Bash on posix machines.\n\nCommon commands like `echo`, `pwd`, `cat`, `rm`, `cp`, `cd`, as well as operators like `$(cmd)`, `>`, `>>`, `|` form a subset of shared behaviours that can work when scripting between all platforms. With some care and testing, it is possible to write cross-platform shell task scripts. For PowerShell 5, Chomp will execute PowerShell in UTF-8 mode (applying to `>`, `>>` and `|`), although a BOM will still be output when writing a new file with `>`. Since `&&` and `||` are not supported in Powershell, multiline scripts and `;` are preferred instead.\n\nFor example, here is an SWC task (assuming Babel is installed via `npm install @swc/core @swc/cli -D`):\n\n_chompfile.toml_\n```toml\nversion = 0.1\n\n[[task]]\nname = 'build:swc'\ntarget = 'lib/app.js'\ndep = 'src/app.ts'\nrun = 'swc $DEP -o $TARGET --source-maps'\n```\n\n_<div style=\"text-align: center\">SWC task compiling the TypeScript module `src/app.ts` into a JS module `lib/app.js`, and supporting configuration in an `.swcrc` file.</div>_\n\nThe above works without having to reference the full `node_modules/.bin/swc` command prefix since `node_modules/.bin` is automatically included in the Chomp spawned `PATH`, relative to the Chompfile itself.\n\n### Environment Variables\n\nIn addition to the `run` property, two other useful task properties are `env` and `cwd` which allow customizing the exact execution environment.\n\nIn PowerShell, defined environment variables in the task `env` are in addition made available as local variables supporting output via `$NAME` instead of `$Env:Name` for better cross-compatibility with posix shells. This process is explicit only - system-level environment variables are not given this treatment though.\n\nIn addition, static environment variable replacements are available via `${{VAR}}`, with optional spacing. Replacements that cannot be resolved to a known environment variable will be replaced with an empty string. Static replacements are available for environment variables and the shell engine run command. Set `env-replace = false` to disable static environment variable replacement for a given task.\n\n_chompfile.toml_\n```toml\nversion = 0.1\n\n[[task]]\nname = 'env-vars'\nrun = '''\n  ${{ECHO}} $PARAM1 $PARAM2\n'''\n[task.env]\nPARAM1 = 'Chomp'\n\n[task.default-env]\nECHO = 'echo'\nPARAM2 = '${{ PARAM1 }}'\n```\n\n_<div style=\"text-align: center\">Custom environment variables are also exposed as local variables in PowerShell, while `${{VAR}}` provides static replacements.</div>_\n\nOn both Posix and Windows, `chomp env-vars` will output: `Chomp Chomp`, unless the system has overrides of the `CMD` or `PARAM2` environment variables to alternative values.\n\n`default-env` permits the definition of default environment variables which are only set to the default values if these environment variables are not already set in the system environment or via the global Chompfile environment variables. Just like `env`, all variables in `default-env` are also defined as PowerShell local variables, even when they are already set in the environment and the default does not apply.\n\nThe following task-level environment variables are always defined:\n\n* `TARGET`: The path to the primary target (the interpolation target or first target).\n* `TARGETS`: The `:`-separated list of target paths for multiple targets.\n* `DEP`: The path to the primary dependency (the interpolation dependency or first dependency file).\n* `DEPS`: The `:`-separated list of expanded dependency paths.\n* `MATCH` When using [task interpolation](#task-interpolation) this provides the matched interpolation replacement value (although the `TARGET` will always be the fully substituted interpolation target for interpolation tasks).\n\nThe `PATH` environment variable is automatically extended to include `.bin` in the current folder as well as `node_modules/.bin` in the Chompfile folder.\n\n### Node.js Engine\n\nThe `\"node\"` engine allows writing a Node.js program in the `run` field of a task. This is a useful way to encapsulate cross-platform build scripts which aren't possible with cross-platform shell scripting.\n\nFor example, the same SWC task in Node.js can be written:\n\n_chompfile.toml_ls\n```toml\nversion = 0.1\n\n[[task]]\nname = 'build:swc'\ntarget = 'lib/app.js'\ndep = 'src/app.ts'\nengine = 'node'\nrun = '''\n  import swc from '@swc/core';\n  import { readFileSync, writeFileSync } from 'fs';\n  import { basename } from 'path';\n\n  const input = readFileSync(process.env.DEP, 'utf8');\n\n  const { code, map } = await swc.transform(input, {\n    filename: process.env.DEP,\n    sourceMaps: true,\n    jsc: {\n      parser: {\n        syntax: \"typescript\",\n      },\n      transform: {},\n    },\n  });\n\n  writeFileSync(process.env.TARGET, code + '\\n//# sourceMappingURL=' + basename(process.env.TARGET) + '.map');\n  writeFileSync(process.env.TARGET + '.map', JSON.stringify(map));\n'''\n```\n\nIt is usually preferable to write tasks using shell scripts since they can be much faster than bootstrapping Node.js or Deno, and can more easily support [batching](extensions.md#chompregisterbatchername-string-batcher-batch-cmdop-running-batchcmd--batcherresult--undefined).\n\n> It is usually easier to use the existing [`chomp:swc` experimental template extension](#extensions) instead of writing your own custom task for SWC.\n\n### Deno Engine\n\nJust like the `\"node\"` engine, the `\"deno\"` engine permits using JS to create build scripts.\n\nThe primary benefits being URL import support (no need for package management for tasks) and TypeScript type support (although unfortunately no editor plugins for Chompfiles means it doesn't translate to author time currently). Using a CDN like [JSPM.dev](https://jspm.org/docs/cdn#jspmdev) (importing eg `https://jspm.dev/[pkg]` etc) can be useful for these scripts to load npm packages.\n\nBy default the Deno engine will run with full permissions since that is generally the nature of build scripts.\n\n## Task Interpolation\n\nChomp works best when each task builds a single file target, instead of having a large monolithic build.\n\nTo extend the previous example to build all of `src` into `lib`, we use **task interpolation** with a `#` or `##`, which means the same thing as a `*` or `**/*` glob respectively, but it retains the important property of being a reversible mapping which is necessary for tracing task invalidations.\n\nReplacing `app` with `##` in the previous [SWC Shell example](#shell-tasks), we can achieve the full folder build:\n\n_chompfile.toml_\n```toml\nversion = 0.1\n\n[[task]]\nname = 'build:swc'\ntarget = 'lib/##.js'\ndep = 'src/##.ts'\nrun = 'swc $DEP -o $TARGET --source-maps'\n```\n_<div style=\"text-align: center\">Chomp task compiling all `.ts` files in `src` into JS modules in `lib`.</div>_\n\nBy treating each file as a separate build, we get natural build parallelization and caching where only files changed in `src` cause rebuilds.\n\nJust like any other target, interpolation targets can be built directly (or even with globbing):\n\n```sh\n$ chomp lib/app.js\n```\n_<div style=\"text-align: center\">When building an exact interpolation target, only the minimum work is done to build `lib/app.js` - no other files in `src` need to be checked other than `src/app.js`.</div>_\n\nOnly a single interpolation `dep` and `target` can be defined (with the `#` interpolation character), although additional dependencies or targets may be defined in addition by using the `deps` array instead, for example to make each compilation depend on the npm install:\n\n_chompfile.toml_\n```toml\nversion = 0.1\n\n[[task]]\nname = 'npm:install'\nrun = 'npm install'\n\n[[task]]\nname = 'build:swc'\ntarget = 'lib/##.js'\ndeps = ['src/##.js', 'npm:install']\nrun = 'swc $DEP -o $TARGET --source-maps'\n```\n_<div style=\"text-align: center\">`$DEP` and `$TARGET` will always be the primary dependency and target (the interpolation item or the first in the list). Additional dependencies and targets can always be defined.</div>_\n\n### Testing\n\nWhile Chomp is not designed to be a test runner, it can easily provide many the features of one.\n\nTests can be run with interpolation. Since interpolation expands a glob of dependencies to operate on, this same technique can be used to create targetless tests:\n\n_chompfile.toml_\n```toml\nversion = 0.1\n\n[[task]]\nname = 'test:unit:#'\ndisplay = 'status'\nstdio = 'stderr-only'\ndep = ['test/unit/#.js', 'dist/build.js']\nrun = 'node $DEP'\n```\n_<div style=\"text-align: center\">Task interpolation without a target runs the task over all dependencies, and is always invalidated, exactly what is needed for a test runner.</div>_\n\nIn the above, all files `test/unit/**/*.js` will be expanded by the `test:unit` test resulting in a separate task run for each file. Since no `targets` are defined, the task is always invalidated and re-run.\n\nUsing the `display` and `stdio` options it is also possible to hide any test output and the command init logs in the reporter.\n\nBy using `#` in the `name` of the task, individual test or test patterns can be run by name or using glob patterns:\n\n```sh\n$ chomp --watch test:unit:some-test test:unit:some-suite-*\n```\n\nThe above would run the tests `test/unit/some-test.js`, and all `test/unit/some-suite-*.js`, watching the full build graph and every unit test file for changes and rerunning the tests on change.\n\nAlternatively all unit tests can be run by passing the empty string replacement:\n\n```sh\n$ chomp test:unit:\n$ chomp test:unit:**/*\n```\n_<div style=\"text-align: center\">Both lines above are equivalent given the task name `test:unit:#`, running all the unit tests.</div>_\n\n## Task Dependence\n\nUsing the `deps` and `targets` properties (which are interchangeable with their singular forms `dep` and `target` for a single list item), task dependence graphs are built.\n\nWhen processing a task, the task graph is constructed and processed in graph order where a task will not begin until its dependencies have completed processing.\n\nDependencies of tasks are always treated as being parallel - to ensure one task always happens before another the best way is usually to treat it as a dependency. For example by having a test task depend on the build target.\n\nTask parallelization can be controlled by the [`-j` flag](cli.md#jobs) to set the maximum number of parallel child processes to spawn.\n\nFor example, here is a build that compiles with SWC, then builds into a single file with RollupJS:\n\n_chompfile.toml_\n```toml\nversion = 0.1\n\n[[task]]\nname = 'npm:install'\nrun = 'npm install'\n\n[[task]]\nname = 'build:swc'\ntarget = 'lib/##.js'\ndeps = ['src/##.js', 'npm:install']\nrun = 'swc $DEP -o $TARGET --source-maps'\n\n[[task]]\nname = 'build:rollup'\ndep = 'lib/**/*.js'\ntarget = 'dist/app.js'\nrun = 'rollup lib/app.js -d dist -m'\n```\n\n_<div style=\"text-align: center\">Practical example of a Chomp build graph using task dependence from the npm install to per-file SWC compilation to Rollup into a single file or set of files.</div>_\n\nFollowing the task graph from the top, since `build:rollup` depends on all deps in `lib`, this will make it depend on all the separate file interpolation jobs of `build:swc` and in turn their dependence. With each of the `build:swc` tasks depending on `npm:install`, this task is always run first. Then only once the `npm install` is completed successfully, the compilation of all `src/**/*.ts` into `lib/#.js` will happen with full parallelization in the task graph.\n\nTask dependency inputs can themselves be the result of targets of other tasks. Build order is fully determined by the graph in this way.\n\n## Watched Rebuilds\n\nTaking the [previous example](#task-dependence) and running:\n\n```sh\n$ chomp build:rollup --watch\n```\n_<div style=\"text-align: center\">Fine-grained watched rebuilds are a first-class feature in Chomp.</div>_\n\nwill build the `dist/app.js` file and then continue watching all of the input files in `src/**/*.ts` as well as the `package.json`. A change to any of these files will then trigger a granular live rebuild of only the changed TypeScript file or files.\n\n## Static Server\n\nAs a convenience a simple local static file server is also provided:\n\n```sh\n$ chomp build-rollup --serve\n```\n_<div style=\"text-align: center\">Running the Chomp static server.</div>_\n\nThis behaves identically to the watched rebuilds above, but will also serve the folder on localhost for browser and URL tests. This may seem outside of the expected features for a task runner, but it is actually closely associated with the watched rebuild events - a websocket protocol for in-browser hot-reloading is a [planned future addition](https://github.com/guybedford/chomp/issues/61).\n\nServer configuration can be controlled via the [`serve`](cli.md#serve) options in the Chompfile or the [`--server-root`](cli.md#server-root) and [`--port`](cli.md#port) flags.\n\nBy separating monolithic builds into sub-compilations on the file system this enables caching, parallelization, finer-grained generic build control and comprehensive incremental builds with watcher support. Replacing monolithic JS build systems with make-style file caching all the commonly expected features of JS dev workflows can still be maintained.\n\n## Task Caching\n\nTasks are cached when the _modified time_ of their `targets` is more recent than the modified time of their `deps` per standard Make-style semantics.\n\nFor example, if we change the npm task definition from the previous example to define the `dep` as the `package.json` and the `target` as the `package-lock.json`:\n\n_chompfile.toml_\n```toml\nversion = 0.1\n\n[[task]]\nname = 'npm:install'\nrun = 'npm install'\ntarget = 'package-lock.json'\ndep = 'package.json'\n```\n\nThe `npm install` operation will now be treated as cached and skipped, unless the `package.json` has been more recently modified than the `package-lock.json`.\n\nThe invalidation rule is a binary rule indicating whether or not a given task should rerun or be treated as cached.\n\nThe explicit rules of invalidation for this `mtime` invalidation are:\n\n* If no targets are defined for a task, it is always invalidated.\n* Otherwise, if no deps are defined for a task, it is invalidated only if the targets do not exist.\n* Otherwise, if the mtime of any dep is greater than the mtime of any target, the task is invalidated.\n\nTask invalidation can be customized with the `invalidation` property on a task:\n\n* `invalidation = 'mtime'` _(default)_: This is the default invalidation, as per the rules described above.\n* `invalidation = 'always'`: The task is always invalidated and rerun, without exception.\n* `invalidation = 'not-found'`: The task is only invalidated when not all targets are defined.\n\n## Serial Dependencies\n\nIn some cases, it can be preferred to write a serial pipeline of steps that should be followed.\n\nThis can be achieved by setting `serial = true` on the task:\n\n_chompfile.toml_\n```toml\nversion = 0.1\n\n[[task]]\nname = 'test'\nserial = true\ndeps = ['test:a', 'test:b', 'test:c']\n\n[[tas]]\nname = 'test:a'\nrun = 'echo a'\n\n[[task]]\nname = 'test:b'\nrun = 'echo b'\n\n[[task]]\nname = 'test:c'\nrun = 'echo c'\n```\n_<div style=\"text-align: center\">Example of a serial `test` task executing `test:a` then `test:b` then `testc` in sequence.</div>_\n\nRunning `chomp test` with the above, will run each of `test:a`, `test:b` and `test:c` one after the other to completion instead of running their dependence graphs in parallel by default, logging `a b c` every time.\n\n## Extensions\n\nExtensions are loaded via the `extensions` list in the Chompfile, and can define custom task templates, which can encapsulate the details of a task execution into a simpler definition.\n\nFor convenience Chomp provides an experimental [core extensions library](https://github.com/guybedford/chomp-extensions).\n\nFor example, to replace the npm, SWC and RollupJS compilations from the previous examples with their extension templates:\n\n_chompfile.toml_\n```toml\nversion = 0.1\n\nextensions = ['chomp:npm', 'chomp:swc', 'chomp:rollup']\n\n[[task]]\nname = 'npm:install'\ntemplate = 'npm'\n\n[[task]]\nname = 'build:swc'\ntemplate = 'swc'\ntarget = 'lib/##.js'\ndeps = ['src/##.js', 'npm:install']\n[task.template-options]\nsource-maps = true\n\n[[task]]\nname = 'build:rollup'\ntemplate = 'rollup'\ndeps = 'lib/**/*.js'\n[task.template-options]\noutdir = 'dist'\nentries = ['lib/app.js']\n```\n_<div style=\"text-align: center\">Using the `chomp:npm`, `chomp:swc` and `chomp:rollup` [experimental core extensions](https://github.com/guybedford/chomp-extensions) allows writing these tasks encapsulated from their implementations.</div>_\n\nTemplates can be loaded from any file path or URL.\n\n### Remote Extensions\n\nExtensions support any `https://` URLs or local file paths.\n\nRemote extensions are loaded once and cached locally by Chomp, regardless of cache headers, to ensure the fastest run time.\n\nThe remote extension cache can be cleared by running `chomp --clear-cache`.\n\n### Ejection\n\n`chomp --eject` transforms the Chompfile into the expanded untemplated form without extensions, allowing an opt-out from extension template workflows if it ever feels too magical. In this way templates become a sort of task construction utility.\n\n### Writing Templates\n\n> Read more on writing extensions in the [extensions documentation](extensions.md)\n\nChomp extensions can be loaded from any URL or local file path. To write custom templates, create a local extension file `local-extension.js` referencing it in the extensions list of the Chompfile:\n\n_chompfile.toml_\n```toml\nversion = 0.1\n\nextensions = ['./local-extension.js']\n```\n\n_local-extension.js_\n```js\nChomp.registerTemplate('npm', function (task) {\n  return [{\n    name: task.name,\n    run: 'npm install',\n    target: 'package-lock.json',\n    deps: ['package.json']\n  }];\n});\n\nChomp.registerTemplate('swc', function (task) {\n  const { sourceMaps } = task.templateOptions;\n  return [{\n    name: task.name,\n    targets: task.targets,\n    deps: task.deps,\n    run: `swc $DEP -o $TARGET${sourceMaps ? ' --source-maps' : ''}`\n  }];\n});\n\nChomp.registerTemplate('rollup', function (task) {\n  if (task.targets.length > 0)\n    throw new Error('Targets is not supported by the Rollup template, use the \"outdir\" and \"entries\" template options instead.');\n  const { outdir, entries } = task.templateOptions;\n  const targets = entries.map(entry => outdir + '/' + entry.split('/').pop());\n  return [{\n    name: task.name,\n    deps: task.deps,\n    targets,\n    run: `rollup ${entries.join(' ')} -d ${outdir} -m`\n  }];\n});\n```\n_<div style=\"text-align: center\">Chomp extension template registration example loaded via a local extension at `local-extension.js` for the `npm`, `swc` and `rollup` templates.</div>_\n\nTemplates are functions on tasks returning a new list of tasks. All TOML properties apply but with _camelCase_ instead of _kebab-case_.\n\nPRs to the default Chomp extensions library are welcome, or host your own on your own domain or via an npm CDN. For support on the JSPM CDN, add `\"type\": \"script\"` to the `package.json` of the package to avoid incorrect processing since template extensions are currently scripts and not modules.\n\nBecause remote extensions are cached, it is recommended to always use unique URLs with versions when hosting extensions remotely. \n\nSee the extensions documentation for the full [extensions API](extensions.md#api).\n"
  },
  {
    "path": "node-chomp/README.md",
    "content": "# Chomp Node\n\nNode.js wrapper for [Chomp](https://chompbuild.com)\n\nUsable via your favourite JS package manager:\n\n```\nnpm install -g chomp\n```\n\nChomp should then be usable as a CLI through npm:\n\n```\nchomp --version\n```\n\nNote: Installing Chomp via `cargo install chompbuild` is the recommended Chomp installation workflow for performance, or via [downloading the binaries into your path](https://github.com/guybedford/chomp/releases).\n"
  },
  {
    "path": "node-chomp/index.js",
    "content": "#!/usr/bin/env node\n\nimport BinWrapper from 'bin-wrapper';\nimport { readFileSync } from 'fs';\nimport { spawn } from 'child_process';\nimport { fileURLToPath } from 'url';\n\nlet { version } = JSON.parse(readFileSync(new URL('package.json', import.meta.url), 'utf8'));\nif (version.match(/-rebuild(\\.\\d)?$/))\n  version = version.split('-rebuild')[0];\nconst base = `https://github.com/guybedford/chomp/releases/download/${version}`\n\nconst bin = new BinWrapper({ skipCheck: true })\n  .src(`${base}/chomp-macos-${version}.tar.gz`, 'darwin')\n  .src(`${base}/chomp-linux-${version}.tar.gz`, 'linux', 'x64')\n  .src(`${base}/chomp-windows-${version}.zip`, 'win32', 'x64')\n  .dest(fileURLToPath(new URL('./vendor', import.meta.url)))\n  .use(process.platform === 'win32' ? 'chomp.exe' : 'chomp')\n  .version(version);\n\nawait bin.run();\n\nspawn(bin.path(), process.argv.slice(2), { stdio: 'inherit' });\n"
  },
  {
    "path": "node-chomp/package.json",
    "content": "{\n  \"name\": \"chomp\",\n  \"version\": \"0.3.0\",\n  \"description\": \"'JS Make' - parallel task runner CLI for the frontend ecosystem with a JS extension system\",\n  \"bin\": {\n    \"chomp\": \"index.js\"\n  },\n  \"type\": \"module\",\n  \"keywords\": [\n    \"task\",\n    \"runner\",\n    \"build\",\n    \"development\",\n    \"make\"\n  ],\n  \"author\": \"Guy Bedford\",\n  \"license\": \"Apache-2.0\",\n  \"dependencies\": {\n    \"bin-wrapper\": \"^4.1.0\"\n  },\n  \"main\": \"index.js\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/guybedford/chomp.git\"\n  },\n  \"bugs\": {\n    \"url\": \"https://github.com/guybedford/chomp/issues\"\n  },\n  \"files\": [\n    \"index.js\"\n  ],\n  \"homepage\": \"https://github.com/guybedford/chomp#readme\"\n}"
  },
  {
    "path": "src/ansi_windows.rs",
    "content": "/// Enables ANSI code support on Windows 10.\n///\n/// This uses Windows API calls to alter the properties of the console that\n/// the program is running in.\n///\n/// https://msdn.microsoft.com/en-us/library/windows/desktop/mt638032(v=vs.85).aspx\n///\n/// Returns a `Result` with the Windows error code if unsuccessful.\n#[cfg(windows)]\npub fn enable_ansi_support() -> Result<(), u32> {\n    // ref: https://docs.microsoft.com/en-us/windows/console/console-virtual-terminal-sequences#EXAMPLE_OF_ENABLING_VIRTUAL_TERMINAL_PROCESSING @@ https://archive.is/L7wRJ#76%\n\n    use std::ffi::OsStr;\n    use std::iter::once;\n    use std::os::windows::ffi::OsStrExt;\n    use std::ptr::null_mut;\n    use winapi::um::consoleapi::{GetConsoleMode, SetConsoleMode};\n    use winapi::um::errhandlingapi::GetLastError;\n    use winapi::um::fileapi::{CreateFileW, OPEN_EXISTING};\n    use winapi::um::handleapi::INVALID_HANDLE_VALUE;\n    use winapi::um::winnt::{FILE_SHARE_WRITE, GENERIC_READ, GENERIC_WRITE};\n\n    const ENABLE_VIRTUAL_TERMINAL_PROCESSING: u32 = 0x0004;\n\n    unsafe {\n        // ref: https://docs.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilew\n        // Using `CreateFileW(\"CONOUT$\", ...)` to retrieve the console handle works correctly even if STDOUT and/or STDERR are redirected\n        let console_out_name: Vec<u16> =\n            OsStr::new(\"CONOUT$\").encode_wide().chain(once(0)).collect();\n        let console_handle = CreateFileW(\n            console_out_name.as_ptr(),\n            GENERIC_READ | GENERIC_WRITE,\n            FILE_SHARE_WRITE,\n            null_mut(),\n            OPEN_EXISTING,\n            0,\n            null_mut(),\n        );\n        if console_handle == INVALID_HANDLE_VALUE {\n            return Err(GetLastError());\n        }\n\n        // ref: https://docs.microsoft.com/en-us/windows/console/getconsolemode\n        let mut console_mode: u32 = 0;\n        if 0 == GetConsoleMode(console_handle, &mut console_mode) {\n            return Err(GetLastError());\n        }\n\n        // VT processing not already enabled?\n        if console_mode & ENABLE_VIRTUAL_TERMINAL_PROCESSING == 0 {\n            // https://docs.microsoft.com/en-us/windows/console/setconsolemode\n            if 0 == SetConsoleMode(\n                console_handle,\n                console_mode | ENABLE_VIRTUAL_TERMINAL_PROCESSING,\n            ) {\n                return Err(GetLastError());\n            }\n        }\n    }\n\n    Ok(())\n}\n"
  },
  {
    "path": "src/chompfile.rs",
    "content": "// Chomp Task Runner\n// Copyright (C) 2022  Guy Bedford\n\n// This program is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n\n// This program is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n// GNU General Public License for more details.\n\n// You should have received a copy of the GNU General Public License\n// along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nuse anyhow::Result;\nuse directories::UserDirs;\nuse regex::{Captures, Regex};\nuse serde::{Deserialize, Serialize};\nuse std::{\n    collections::HashMap,\n    path::{Component, Path, PathBuf},\n};\n\n#[derive(Copy, Clone, Debug, Serialize, Deserialize, Hash, PartialEq, Eq)]\n#[serde(rename_all = \"kebab-case\")]\n#[derive(Default)]\npub enum ChompEngine {\n    #[default]\n    Shell,\n    Node,\n    Deno,\n}\n\n#[derive(Copy, Clone, Debug, Serialize, Deserialize, Hash, PartialEq, Eq)]\n#[serde(rename_all = \"kebab-case\")]\n#[derive(Default)]\npub enum TaskDisplay {\n    None,\n    Dot,\n    #[default]\n    InitStatus,\n    StatusOnly,\n    InitOnly,\n}\n\n\n#[derive(Copy, Clone, Debug, Serialize, Deserialize, Hash, PartialEq, Eq)]\n#[serde(rename_all = \"kebab-case\")]\n#[derive(Default)]\npub enum TaskStdio {\n    #[default]\n    All,\n    NoStdin,\n    StdoutOnly,\n    StderrOnly,\n    None,\n}\n\n\n\n#[derive(Debug, Serialize, Deserialize)]\n#[serde(deny_unknown_fields, rename_all = \"kebab-case\")]\npub struct Chompfile {\n    pub version: f32,\n    #[serde(default, skip_serializing_if = \"is_default\")]\n    pub echo: bool,\n    pub default_task: Option<String>,\n    #[serde(default, skip_serializing_if = \"is_default\")]\n    pub extensions: Vec<String>,\n    #[serde(default, skip_serializing_if = \"is_default\")]\n    pub env: HashMap<String, String>,\n    #[serde(default, skip_serializing_if = \"is_default\")]\n    pub env_default: HashMap<String, String>,\n    #[serde(default, skip_serializing_if = \"is_default\")]\n    pub server: ServerOptions,\n    #[serde(default, skip_serializing_if = \"is_default\")]\n    pub task: Vec<ChompTaskMaybeTemplated>,\n    #[serde(default, skip_serializing_if = \"is_default\")]\n    pub template_options: HashMap<String, HashMap<String, toml::value::Value>>,\n}\n\n#[derive(Debug, Serialize, PartialEq, Deserialize, Clone)]\npub struct ServerOptions {\n    #[serde(default = \"default_root\", skip_serializing_if = \"is_default\")]\n    pub root: String,\n    #[serde(default = \"default_port\", skip_serializing_if = \"is_default\")]\n    pub port: u16,\n}\n\nfn default_root() -> String {\n    \".\".to_string()\n}\n\nfn default_port() -> u16 {\n    5776\n}\n\nimpl Default for ServerOptions {\n    fn default() -> Self {\n        ServerOptions {\n            root: \".\".to_string(),\n            port: default_port(),\n        }\n    }\n}\n\n#[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Copy)]\n#[serde(rename_all = \"kebab-case\")]\n#[derive(Default)]\npub enum InvalidationCheck {\n    NotFound,\n    #[default]\n    Mtime,\n    Always,\n}\n\n#[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Copy)]\n#[serde(rename_all = \"kebab-case\")]\n#[derive(Default)]\npub enum ValidationCheck {\n    #[default]\n    OkTargets,\n    TargetsOnly,\n    OkOnly,\n    NotOk,\n    None,\n}\n\n\n\n#[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Copy)]\n#[serde(rename_all = \"kebab-case\")]\n#[derive(Default)]\npub enum WatchInvalidation {\n    #[default]\n    RestartRunning,\n    SkipRunning,\n}\n\n\n#[derive(Debug, Serialize, PartialEq, Deserialize, Clone)]\n#[serde(deny_unknown_fields, rename_all = \"kebab-case\")]\npub struct ChompTaskMaybeTemplated {\n    pub name: Option<String>,\n    pub target: Option<String>,\n    pub targets: Option<Vec<String>>,\n    pub dep: Option<String>,\n    pub deps: Option<Vec<String>>,\n    pub args: Option<Vec<String>>,\n    pub serial: Option<bool>,\n    pub watch_invalidation: Option<WatchInvalidation>,\n    pub invalidation: Option<InvalidationCheck>,\n    pub validation: Option<ValidationCheck>,\n    pub display: Option<TaskDisplay>,\n    pub stdio: Option<TaskStdio>,\n    pub engine: Option<ChompEngine>,\n    pub run: Option<String>,\n    pub cwd: Option<String>,\n    pub env_replace: Option<bool>,\n    pub template: Option<String>,\n    pub echo: Option<bool>,\n    pub template_options: Option<HashMap<String, toml::value::Value>>,\n    pub env: Option<HashMap<String, String>>,\n    pub env_default: Option<HashMap<String, String>>,\n}\n\nimpl ChompTaskMaybeTemplated {\n    pub fn new() -> Self {\n        ChompTaskMaybeTemplated {\n            name: None,\n            run: None,\n            args: None,\n            cwd: None,\n            deps: None,\n            dep: None,\n            targets: None,\n            target: None,\n            display: None,\n            engine: None,\n            env_replace: None,\n            env: None,\n            env_default: None,\n            echo: None,\n            invalidation: None,\n            validation: None,\n            serial: None,\n            stdio: None,\n            template: None,\n            template_options: None,\n            watch_invalidation: None,\n        }\n    }\n    pub fn targets_vec(&self, cwd: &str) -> Result<Vec<String>> {\n        if let Some(ref target) = self.target {\n            let target_str = resolve_path(target, cwd);\n            Ok(vec![target_str])\n        } else if let Some(ref targets) = self.targets {\n            let targets = targets.iter().map(|t| resolve_path(t, cwd)).collect();\n            Ok(targets)\n        } else {\n            Ok(vec![])\n        }\n    }\n    pub fn deps_vec(&self, chompfile: &Chompfile, cwd: &str) -> Result<Vec<String>> {\n        let names = chompfile\n            .task\n            .iter()\n            .filter_map(|t| t.name.as_ref())\n            .collect::<Vec<_>>();\n\n        if let Some(ref dep) = self.dep {\n            let dep_str = if names.contains(&dep) || skip_special_chars(dep) {\n                dep.to_string()\n            } else {\n                resolve_path(dep, cwd)\n            };\n            Ok(vec![dep_str])\n        } else if let Some(ref deps) = self.deps {\n            let deps = deps\n                .iter()\n                .map(|dep| {\n                    if names.contains(&dep) || skip_special_chars(dep) {\n                        dep.to_owned()\n                    } else {\n                        resolve_path(dep, cwd)\n                    }\n                })\n                .collect();\n            Ok(deps)\n        } else {\n            Ok(vec![])\n        }\n    }\n}\n\nfn skip_special_chars(s: &str) -> bool {\n    s.contains(':') || s.contains(\"&prev\") || s.contains(\"&next\")\n}\n\nfn is_default<T: Default + PartialEq>(t: &T) -> bool {\n    t == &T::default()\n}\n\n#[derive(Debug, Serialize, PartialEq, Deserialize, Clone)]\n#[serde(deny_unknown_fields, rename_all = \"camelCase\")]\npub struct ChompTaskMaybeTemplatedJs {\n    pub name: Option<String>,\n    pub target: Option<String>,\n    pub targets: Option<Vec<String>>,\n    pub dep: Option<String>,\n    pub deps: Option<Vec<String>>,\n    pub args: Option<Vec<String>>,\n    pub serial: Option<bool>,\n    pub invalidation: Option<InvalidationCheck>,\n    pub validation: Option<ValidationCheck>,\n    pub watch_invalidation: Option<WatchInvalidation>,\n    pub display: Option<TaskDisplay>,\n    pub stdio: Option<TaskStdio>,\n    pub engine: Option<ChompEngine>,\n    pub run: Option<String>,\n    pub cwd: Option<String>,\n    pub echo: Option<bool>,\n    pub env_replace: Option<bool>,\n    pub template: Option<String>,\n    pub template_options: Option<HashMap<String, toml::value::Value>>,\n    pub env: Option<HashMap<String, String>>,\n    pub env_default: Option<HashMap<String, String>>,\n}\n\nimpl From<ChompTaskMaybeTemplatedJs> for ChompTaskMaybeTemplated {\n    fn from(val: ChompTaskMaybeTemplatedJs) -> Self {\n        ChompTaskMaybeTemplated {\n            cwd: val.cwd,\n            name: val.name,\n            args: val.args,\n            target: val.target,\n            targets: val.targets,\n            display: val.display,\n            stdio: val.stdio,\n            invalidation: val.invalidation,\n            validation: val.validation,\n            dep: val.dep,\n            deps: val.deps,\n            echo: val.echo,\n            serial: val.serial,\n            env_replace: val.env_replace,\n            env: val.env,\n            env_default: val.env_default,\n            run: val.run,\n            engine: val.engine,\n            template: val.template,\n            template_options: val.template_options,\n            watch_invalidation: val.watch_invalidation,\n        }\n    }\n}\n\npub fn resolve_path(target: &str, cwd: &str) -> String {\n    path_from(cwd, target).to_string_lossy().replace('\\\\', \"/\")\n}\n/// https://stackoverflow.com/questions/68231306/stdfscanonicalize-for-files-that-dont-exist\n/// build a usable path from a user input which may be absolute\n/// (if it starts with / or ~) or relative to the supplied base_dir.\n/// (we might want to try detect windows drives in the future, too)\npub fn path_from<P: AsRef<Path>>(base_dir: P, input: &str) -> PathBuf {\n    let tilde = Regex::new(r\"^~(/|$)\").unwrap();\n    if input.starts_with('/') {\n        // if the input starts with a `/`, we use it as is\n        input.into()\n    } else if tilde.is_match(input) {\n        // if the input starts with `~` as first token, we replace\n        // this `~` with the user home directory\n        PathBuf::from(&*tilde.replace(input, |c: &Captures| {\n            if let Some(user_dirs) = UserDirs::new() {\n                format!(\"{}{}\", user_dirs.home_dir().to_string_lossy(), &c[1],)\n            } else {\n                // warn!(\"no user dirs found, no expansion of ~\");\n                c[0].to_string()\n            }\n        }))\n    } else {\n        // we put the input behind the source (the selected directory\n        // or its parent) and we normalize so that the user can type\n        // paths with `../`\n        normalize_path(base_dir.as_ref().join(input))\n    }\n}\n\n/// Improve the path to try remove and solve .. token.\n///\n/// This assumes that `a/b/../c` is `a/c` which might be different from\n/// what the OS would have chosen when b is a link. This is OK\n/// for broot verb arguments but can't be generally used elsewhere\n///\n/// This function ensures a given path ending with '/' still\n/// ends with '/' after normalization.\npub fn normalize_path<P: AsRef<Path>>(path: P) -> PathBuf {\n    let ends_with_slash = path.as_ref().to_str().is_some_and(|s| s.ends_with('/'));\n    let mut normalized = PathBuf::new();\n    for component in path.as_ref().components() {\n        match &component {\n            Component::ParentDir => {\n                if !normalized.pop() {\n                    normalized.push(component);\n                }\n            }\n            _ => {\n                normalized.push(component);\n            }\n        }\n    }\n    if ends_with_slash {\n        normalized.push(\"\");\n    }\n    normalized\n}\n"
  },
  {
    "path": "src/engines/cmd.rs",
    "content": "// Chomp Task Runner\n// Copyright (C) 2022  Guy Bedford\n\n// This program is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n\n// This program is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n// GNU General Public License for more details.\n\n// You should have received a copy of the GNU General Public License\n// along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nuse crate::chompfile::TaskStdio;\nuse crate::engines::BatchCmd;\nuse regex::Regex;\nuse std::collections::BTreeMap;\nuse std::env;\nuse std::fs;\nuse std::path::{Path, PathBuf};\nuse std::process::Stdio;\nuse tokio::process::{Child, Command};\n\nfn replace_env_vars(arg: &str, env: &BTreeMap<String, String>) -> String {\n    let mut out_arg = arg.to_string();\n    if out_arg.find('$').is_none() {\n        return out_arg;\n    }\n    for (name, value) in env {\n        if !out_arg.contains(name) {\n            continue;\n        }\n        let mut env_str = String::from(\"$\");\n        env_str.push_str(name);\n        if out_arg.contains(&env_str) {\n            out_arg = out_arg.replace(&env_str, value);\n            if out_arg.find('$').is_none() {\n                return out_arg;\n            }\n        }\n        let mut env_str_curly = String::from(\"${\");\n        env_str_curly.push_str(name);\n        env_str_curly.push('}');\n        if out_arg.contains(&env_str_curly) {\n            out_arg = out_arg.replace(&env_str_curly, value);\n            if out_arg.find('$').is_none() {\n                return out_arg;\n            }\n        }\n    }\n    for (name, value) in env::vars() {\n        let name = name.to_uppercase();\n        if !out_arg.contains(&name) {\n            continue;\n        }\n        let mut env_str = String::from(\"$\");\n        env_str.push_str(&name);\n        if out_arg.contains(&env_str) {\n            out_arg = out_arg.replace(&env_str, &value);\n            if out_arg.find('$').is_none() {\n                return out_arg;\n            }\n        }\n        let mut env_str_curly = String::from(\"${\");\n        env_str_curly.push_str(&name);\n        env_str_curly.push('}');\n        if out_arg.contains(&env_str_curly) {\n            out_arg = out_arg.replace(&env_str_curly, &value);\n            if out_arg.find('$').is_none() {\n                return out_arg;\n            }\n        }\n    }\n    out_arg\n}\n\nfn set_cmd_stdio(command: &mut Command, stdio: TaskStdio) {\n    match stdio {\n        TaskStdio::All => {}\n        TaskStdio::StderrOnly => {\n            command.stdin(Stdio::null());\n            command.stdout(Stdio::null());\n        }\n        TaskStdio::StdoutOnly => {\n            command.stdin(Stdio::null());\n            command.stderr(Stdio::null());\n        }\n        TaskStdio::NoStdin => {\n            command.stdin(Stdio::null());\n        }\n        TaskStdio::None => {\n            command.stdin(Stdio::null());\n            command.stdout(Stdio::null());\n            command.stderr(Stdio::null());\n        }\n    };\n}\n\n#[cfg(target_os = \"windows\")]\npub fn create_cmd(\n    cwd: &str,\n    path: &str,\n    batch_cmd: &BatchCmd,\n    fastpath_fallback: bool,\n) -> Option<Child> {\n    let run = batch_cmd.run.trim();\n    lazy_static! {\n        static ref CMD: Regex = Regex::new(\n            \"(?x)\n            ^(?P<cmd>[^`~!\\\\#&*()\\t\\\\{\\\\[|;'\\\"\\\\n<>?\\\\\\\\\\\\ ]+?)\n             (?P<args>(?:\\\\ (?:\n                [^`~!\\\\#&*()\\t\\\\{\\\\[|;'\\\"\\\\n<>?\\\\\\\\\\\\ ]+ |\n                (?:\\\"[^\\\"\\\\n\\\\\\\\]*?\\\") |\n                (?:'[^'\\\"\\\\n\\\\\\\\]*?')\n            )*?)*?)$\n        \"\n        )\n        .unwrap();\n        static ref ARGS: Regex = Regex::new(\n            \"(?x)\n            \\\\ (?:[^`~!\\\\#&*()\\t\\\\{\\\\[|;'\\\"\\\\n<>?\\\\\\\\\\\\ ]+ |\n                (?:\\\"[^\\\"\\\\n\\\\\\\\]*?\\\") |\n                (?:'[^'\\\"\\\\n\\\\\\\\]*?'))\n        \"\n        )\n        .unwrap();\n    }\n    if batch_cmd.echo {\n        println!(\"{}\", &run);\n    }\n    // fast path for direct commands to skip the shell entirely\n    if let Some(capture) = CMD.captures(run) {\n        let mut cmd = String::from(&capture[\"cmd\"]);\n        let mut do_spawn = true;\n        // Path-like must be exact\n        if cmd.contains('/') || cmd.contains('\\\\') {\n            // canonicalize returns UNC...\n            let cmd_buf = PathBuf::from(&cmd);\n            let cmd_buf = if Path::is_absolute(&cmd_buf) {\n                cmd_buf\n            } else {\n                let mut buf = PathBuf::from(&cwd);\n                buf.push(cmd_buf);\n                buf\n            };\n\n            if let Ok(unc_path) = fs::canonicalize(cmd_buf) {\n                let unc_str = unc_path.to_str().unwrap();\n                if unc_str.starts_with(r\"\\\\?\\\") {\n                    cmd = String::from(&unc_path.to_str().unwrap()[4..]);\n                } else {\n                    do_spawn = false;\n                }\n            } else {\n                do_spawn = false;\n            }\n        }\n        if do_spawn {\n            // Try \".cmd\" extension first\n            // Note: this requires latest Rust version\n            let mut cmd_with_ext = cmd.to_owned();\n            cmd_with_ext.push_str(\".cmd\");\n            let mut command = Command::new(&cmd_with_ext);\n            command.env(\"PATH\", path);\n            for (name, value) in &batch_cmd.env {\n                command.env(name, value);\n            }\n            command.current_dir(cwd);\n            for arg in ARGS.captures_iter(&capture[\"args\"]) {\n                let arg = arg.get(0).unwrap().as_str();\n                let first_char = arg.as_bytes()[1];\n                let arg_str = if first_char == b'\\'' || first_char == b'\"' {\n                    &arg[2..arg.len() - 1]\n                } else {\n                    &arg[1..arg.len()]\n                };\n                if !batch_cmd.env.is_empty() {\n                    command.arg(replace_env_vars(arg_str, &batch_cmd.env));\n                } else {\n                    command.arg(arg_str);\n                }\n            }\n            set_cmd_stdio(&mut command, batch_cmd.stdio.unwrap_or_default());\n            match command.spawn() {\n                Ok(child) => return Some(child),\n                Err(_) => {\n                    let mut command = Command::new(&cmd);\n                    command.env(\"PATH\", path);\n                    for (name, value) in &batch_cmd.env {\n                        command.env(name, value);\n                    }\n                    command.current_dir(cwd);\n                    for arg in ARGS.captures_iter(&capture[\"args\"]) {\n                        let arg = arg.get(0).unwrap().as_str();\n                        let first_char = arg.as_bytes()[1];\n                        let arg_str = if first_char == b'\\'' || first_char == b'\"' {\n                            &arg[2..arg.len() - 1]\n                        } else {\n                            &arg[1..arg.len()]\n                        };\n                        if !batch_cmd.env.is_empty() {\n                            command.arg(replace_env_vars(arg_str, &batch_cmd.env));\n                        } else {\n                            command.arg(arg_str);\n                        }\n                    }\n                    set_cmd_stdio(&mut command, batch_cmd.stdio.unwrap_or_default());\n                    match command.spawn() {\n                        Ok(child) => return Some(child),\n                        Err(_) => {\n                            if !fastpath_fallback {\n                                return None;\n                            }\n                        } // fallback to shell\n                    }\n                }\n            };\n        }\n    }\n\n    let shell = if env::var(\"PSModulePath\").is_ok() {\n        \"powershell\"\n    } else {\n        panic!(\"Powershell is required on Windows for arbitrary scripts\");\n        // \"cmd\"\n    };\n    let mut command = Command::new(shell);\n    if shell == \"powershell\" {\n        command.arg(\"-ExecutionPolicy\");\n        command.arg(\"Unrestricted\");\n        command.arg(\"-NonInteractive\");\n        command.arg(\"-NoLogo\");\n        // ensure file operations use UTF8\n        let mut run_str = String::from(\n            \"$PSDefaultParameterValues['Out-File:Encoding']='utf8';$ErrorActionPreference='Stop';\",\n        );\n        // we also set _custom_ variables as local variables for easy substitution\n        for (name, value) in &batch_cmd.env {\n            run_str.push_str(&format!(\"${}='{}';\", name, value.replace(\"'\", \"''\")));\n        }\n        run_str.push('\\n');\n        run_str.push_str(run);\n        command.arg(run_str);\n    } else {\n        command.arg(\"/d\");\n        // command.arg(\"/s\");\n        command.arg(\"/c\");\n        command.arg(run);\n    }\n    command.env(\"PATH\", path);\n    for (name, value) in &batch_cmd.env {\n        command.env(name, value);\n    }\n    command.current_dir(cwd);\n    set_cmd_stdio(&mut command, batch_cmd.stdio.unwrap_or_default());\n    Some(command.spawn().unwrap())\n}\n\n#[cfg(not(target_os = \"windows\"))]\npub fn create_cmd(\n    cwd: &str,\n    path: &str,\n    batch_cmd: &BatchCmd,\n    fastpath_fallback: bool,\n) -> Option<Child> {\n    let run = batch_cmd.run.trim();\n    lazy_static! {\n        static ref CMD: Regex = Regex::new(\n            \"(?x)\n            ^(?P<cmd>[^`~!\\\\#&*()\\t\\\\{\\\\[|;'\\\"\\\\n<>?\\\\\\\\\\\\ ]+?)\n             (?P<args>(?:\\\\ (?:\n                [^`~!\\\\#&*()\\t\\\\{\\\\[|;'\\\"\\\\n<>?\\\\\\\\\\\\ ]+ |\n                (?:\\\"[^\\\"\\\\n\\\\\\\\]*?\\\") |\n                (?:'[^'\\\"\\\\n\\\\\\\\]*?')\n            )*?)*?)$\n        \"\n        )\n        .unwrap();\n        static ref ARGS: Regex = Regex::new(\n            \"(?x)\n            \\\\ (?:[^`~!\\\\#&*()\\t\\\\{\\\\[|;'\\\"\\\\n<>?\\\\\\\\\\\\ ]+ |\n                (?:\\\"[^\\\"\\\\n\\\\\\\\]*?\\\") |\n                (?:'[^'\\\"\\\\n\\\\\\\\]*?'))\n        \"\n        )\n        .unwrap();\n    }\n\n    if batch_cmd.echo {\n        println!(\"{}\", run);\n    }\n    // Spawn needs an exact path for Ubuntu?\n    // fast path for direct commands to skip the shell entirely\n    if let Some(capture) = CMD.captures(&run) {\n        let mut cmd = capture[\"cmd\"].to_string();\n        let mut do_spawn = true;\n        // Path-like must be exact\n        if cmd.contains(\"/\") {\n            let cmd_buf = PathBuf::from(&cmd);\n            let cmd_buf = if Path::is_absolute(&cmd_buf) {\n                cmd_buf\n            } else {\n                let mut buf = PathBuf::from(&cwd);\n                buf.push(cmd_buf);\n                buf\n            };\n            if let Ok(canonical) = fs::canonicalize(cmd_buf) {\n                cmd = String::from(&canonical.to_str().unwrap()[4..]);\n            } else {\n                do_spawn = false;\n            }\n        }\n        if do_spawn {\n            let mut command = Command::new(&cmd);\n            command.env(\"PATH\", &path);\n            for (name, value) in &batch_cmd.env {\n                command.env(name, value);\n            }\n            command.current_dir(cwd);\n            for arg in ARGS.captures_iter(&capture[\"args\"]) {\n                let arg = arg.get(0).unwrap().as_str();\n                let first_char = arg.as_bytes()[1];\n                let arg_str = if first_char == b'\\'' || first_char == b'\"' {\n                    &arg[2..arg.len() - 1]\n                } else {\n                    &arg[1..arg.len()]\n                };\n                if batch_cmd.env.len() > 0 {\n                    command.arg(replace_env_vars(arg_str, &batch_cmd.env));\n                } else {\n                    command.arg(arg_str);\n                }\n            }\n            set_cmd_stdio(&mut command, batch_cmd.stdio.unwrap_or_default());\n            match command.spawn() {\n                Ok(child) => return Some(child),\n                Err(_) => {\n                    if !fastpath_fallback {\n                        return None;\n                    }\n                } // fallback to shell\n            }\n        }\n    }\n\n    let mut command = Command::new(\"bash\");\n    command.env(\"PATH\", path);\n    for (name, value) in &batch_cmd.env {\n        command.env(name, value);\n    }\n    command.current_dir(cwd);\n    command.arg(\"-e\");\n    command.arg(\"-c\");\n    command.arg(&run);\n    set_cmd_stdio(&mut command, batch_cmd.stdio.unwrap_or_default());\n    Some(command.spawn().unwrap())\n}\n"
  },
  {
    "path": "src/engines/deno.rs",
    "content": "// Chomp Task Runner\n// Copyright (C) 2022  Guy Bedford\n\n// This program is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n\n// This program is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n// GNU General Public License for more details.\n\n// You should have received a copy of the GNU General Public License\n// along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nuse crate::engines::check_target_mtimes;\nuse crate::engines::create_cmd;\nuse crate::engines::CmdPool;\nuse crate::engines::Exec;\nuse crate::engines::{BatchCmd, ExecState};\nuse futures::future::FutureExt;\nuse std::env;\nuse std::time::Instant;\nuse tokio::fs;\nuse uuid::Uuid;\n\nconst DENO_CMD: &str = \"deno run -A --unstable --no-check $CHOMP_MAIN\";\n\npub fn deno_runner(cmd_pool: &mut CmdPool, mut cmd: BatchCmd, targets: Vec<String>) {\n    let start_time = Instant::now();\n    let uuid = Uuid::new_v4();\n    let mut tmp_file = env::temp_dir();\n    tmp_file.push(format!(\"{}.ts\", uuid.as_simple()));\n    let tmp_file2 = tmp_file.clone();\n    cmd.env.insert(\n        \"CHOMP_MAIN\".to_string(),\n        tmp_file.to_str().unwrap().to_string(),\n    );\n    cmd.env.insert(\n        \"CHOMP_PATH\".to_string(),\n        std::env::args().next().unwrap().to_string(),\n    );\n    let targets = targets.clone();\n    let write_future = fs::write(tmp_file, cmd.run.to_string());\n    cmd.run = DENO_CMD.to_string();\n    let exec_num = cmd_pool.exec_num;\n    cmd_pool.exec_cnt += 1;\n    let pool = cmd_pool as *mut CmdPool;\n    let echo = cmd.echo;\n    cmd.echo = false;\n    let child = create_cmd(\n        cmd.cwd.as_ref().unwrap_or(&cmd_pool.cwd),\n        &cmd_pool.path,\n        &cmd,\n        false,\n    );\n    let future = async move {\n        let cmd_pool = unsafe { &mut *pool };\n        let exec = &mut cmd_pool.execs.get_mut(&exec_num).unwrap();\n        write_future.await.expect(\"unable to write temporary file\");\n        exec.child.as_ref()?;\n        if echo {\n            println!(\"<Deno exec>\");\n        }\n        exec.state = match exec.child.as_mut().unwrap().wait().await {\n            Ok(status) => {\n                if status.success() {\n                    ExecState::Completed\n                } else {\n                    ExecState::Failed\n                }\n            }\n            Err(e) => match exec.state {\n                ExecState::Terminating => ExecState::Terminated,\n                _ => panic!(\"Unexpected exec error {:?}\", e),\n            },\n        };\n        cmd_pool.exec_cnt -= 1;\n        fs::remove_file(&tmp_file2)\n            .await\n            .expect(\"unable to cleanup tmp file\");\n        let end_time = Instant::now();\n        // finally we verify that the targets exist\n        let mtime = check_target_mtimes(targets, true).await;\n        Some((exec.state, mtime, end_time - start_time))\n    }\n    .boxed_local()\n    .shared();\n\n    cmd_pool.execs.insert(\n        exec_num,\n        Exec {\n            cmd,\n            child,\n            future,\n            state: ExecState::Executing,\n        },\n    );\n    cmd_pool.exec_num += 1;\n}\n"
  },
  {
    "path": "src/engines/mod.rs",
    "content": "// Chomp Task Runner\n// Copyright (C) 2022  Guy Bedford\n\n// This program is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n\n// This program is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n// GNU General Public License for more details.\n\n// You should have received a copy of the GNU General Public License\n// along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nmod cmd;\nmod deno;\nmod node;\n\nuse crate::chompfile::ChompEngine;\nuse crate::chompfile::TaskStdio;\nuse crate::engines::deno::deno_runner;\nuse crate::engines::node::node_runner;\nuse crate::extensions::BatcherResult;\nuse crate::task::check_target_mtimes;\nuse crate::ExtensionEnvironment;\nuse anyhow::Result;\nuse anyhow::{anyhow, Error};\nuse cmd::create_cmd;\nuse futures::future::Shared;\nuse futures::future::{Future, FutureExt};\nuse serde::{Deserialize, Serialize};\nuse std::collections::BTreeMap;\nuse std::collections::BTreeSet;\nuse std::collections::HashSet;\nuse std::env;\nuse std::path::Path;\nuse std::pin::Pin;\nuse std::rc::Rc;\nuse std::time::Duration;\nuse std::time::Instant;\nuse tokio::fs;\nuse tokio::process::Child;\nuse tokio::time::sleep;\n\npub fn replace_env_vars_static(arg: &str, env: &BTreeMap<String, String>) -> String {\n    let mut out_arg = String::new();\n    let mut pos = 0;\n    while let Some(idx) = arg[pos..].find(\"${{\") {\n        let close_idx = match arg[pos + idx + 3..].find(\"}}\") {\n            Some(idx) => idx,\n            None => {\n                out_arg.push_str(\"${{\");\n                pos = pos + idx + 3;\n                continue;\n            }\n        } + pos\n            + idx\n            + 3;\n\n        let var_str = arg[pos + idx + 3..close_idx].trim();\n        out_arg.push_str(&arg[pos..pos + idx]);\n        if let Some(replacement) = env.get(var_str) {\n            out_arg.push_str(replacement);\n        } else {\n            if let Ok(replacement) = std::env::var(var_str) {\n                out_arg.push_str(&replacement);\n            }\n        }\n        pos = close_idx + 2;\n    }\n    out_arg.push_str(&arg[pos..]);\n    out_arg\n}\n\npub struct CmdPool<'a> {\n    cmd_num: usize,\n    pub extension_env: &'a mut ExtensionEnvironment,\n    cmds: BTreeMap<usize, CmdOp>,\n    exec_num: usize,\n    execs: BTreeMap<usize, Exec<'a>>,\n    exec_cnt: usize,\n    batching: BTreeSet<usize>,\n    cmd_execs: BTreeMap<usize, usize>,\n    cwd: String,\n    path: String,\n    pool_size: usize,\n    batch_future: Option<Shared<Pin<Box<dyn Future<Output = Result<(), Rc<Error>>> + 'a>>>>,\n}\n\n#[derive(Hash, Serialize, PartialEq, Eq, Debug)]\npub struct CmdOp {\n    pub name: Option<String>,\n    pub id: usize,\n    pub run: String,\n    pub env: BTreeMap<String, String>,\n    pub cwd: Option<String>,\n    pub engine: ChompEngine,\n    pub stdio: TaskStdio,\n    pub targets: Vec<String>,\n    pub echo: bool,\n}\n\n#[derive(Debug, Deserialize, Serialize, Hash, PartialEq, Eq)]\npub struct BatchCmd {\n    pub id: Option<usize>,\n    pub run: String,\n    #[serde(default)]\n    pub echo: bool,\n    pub env: BTreeMap<String, String>,\n    pub cwd: Option<String>,\n    pub engine: ChompEngine,\n    pub stdio: Option<TaskStdio>,\n    pub ids: Vec<usize>,\n}\n\n#[derive(Debug, Clone, Copy)]\npub enum ExecState {\n    Executing,\n    Completed,\n    Failed,\n    Terminating,\n    Terminated,\n}\n\n#[derive(Debug)]\npub struct Exec<'a> {\n    cmd: BatchCmd,\n    child: Option<Child>,\n    state: ExecState,\n    future:\n        Shared<Pin<Box<dyn Future<Output = Option<(ExecState, Option<Duration>, Duration)>> + 'a>>>,\n}\n\nimpl<'a> CmdPool<'a> {\n    pub fn new(\n        pool_size: usize,\n        cwd: String,\n        extension_env: &'a mut ExtensionEnvironment,\n    ) -> CmdPool<'a> {\n        #[cfg(not(target_os = \"windows\"))]\n        let path = {\n            let mut path = String::from(&cwd);\n            path += \"/.bin:\";\n            path.push_str(&cwd);\n            path += \"/node_modules/.bin\";\n            path += \":\";\n            path.push_str(&env::var(\"PATH\").unwrap_or_default());\n            path\n        };\n        #[cfg(target_os = \"windows\")]\n        let path = {\n            let mut path = cwd.replace('/', \"\\\\\");\n            path += \"\\\\.bin;\";\n            path.push_str(&cwd.replace('/', \"\\\\\"));\n            path += \"\\\\node_modules\\\\.bin;\";\n            path.push_str(&env::var(\"PATH\").unwrap_or_default());\n            path\n        };\n        CmdPool {\n            cmd_num: 0,\n            cwd,\n            path,\n            cmds: BTreeMap::new(),\n            exec_num: 0,\n            exec_cnt: 0,\n            execs: BTreeMap::new(),\n            pool_size,\n            extension_env,\n            batching: BTreeSet::new(),\n            cmd_execs: BTreeMap::new(),\n            batch_future: None,\n        }\n    }\n\n    pub fn terminate(&mut self, cmd_num: usize, name: &str) {\n        // Note: On Windows, terminating a process does not terminate\n        // the child processes, which can leave zombie processes behind\n        println!(\"Terminating {}...\", name);\n        let exec_num = self.cmd_execs.get(&cmd_num).unwrap();\n        let exec = &mut self.execs.get_mut(exec_num).unwrap();\n        if matches!(exec.state, ExecState::Executing) {\n            exec.state = ExecState::Terminating;\n            let child = exec.child.as_mut().unwrap();\n            child.start_kill().expect(\"Unable to terminate process\");\n        }\n    }\n\n    pub fn get_exec_future(\n        &mut self,\n        cmd_num: usize,\n    ) -> Pin<\n        Box<dyn Future<Output = Result<(ExecState, Option<Duration>, Duration), Rc<Error>>> + 'a>,\n    > {\n        let pool = self as *mut CmdPool;\n        async move {\n            let this = unsafe { &mut *pool };\n            loop {\n                if let Some(exec_num) = this.cmd_execs.get(&cmd_num) {\n                    let exec = &this.execs[exec_num];\n                    let result = exec.future.clone().await;\n                    if result.is_none() {\n                        return Err(Rc::new(match exec.cmd.engine {\n                            ChompEngine::Shell => anyhow!(\"Unable to initialize shell command engine\"),\n                            ChompEngine::Node => anyhow!(\"Unable to initialize the Node.js Chomp engine.\\n\\x1b[33mMake sure Node.js is correctly installed and the \\x1b[1mnode\\x1b[0m\\x1b[33m command bin is in the environment PATH.\\x1b[0m\\n\\nSee \\x1b[36;4mhttps://nodejs.org/en/download/\\x1b[0m\\n\"),\n                            ChompEngine::Deno => anyhow!(\"Unable to initialize the Deno Chomp engine.\\n\\x1b[33mMake sure Deno is correctly installed and the \\x1b[1mdeno\\x1b[0m\\x1b[33m bin is in the environment PATH.\\x1b[0m\\n\\nSee \\x1b[36;4mhttps://deno.land/#installation\\x1b[0m\\n\"),\n                        }));\n                    }\n                    return Ok(result.unwrap());\n                }\n                if this.batch_future.is_none() {\n                    this.create_batch_future();\n                }\n                this.batch_future.as_ref().unwrap().clone().await?;\n            }\n        }.boxed_local()\n    }\n\n    fn create_batch_future(&mut self) {\n        // This is bad Rust, but it's also totally fine given the static execution model\n        // (in Zig it might even be called idomatic)...\n        let pool = self as *mut CmdPool;\n        let cmds = &mut self.cmds as *mut BTreeMap<usize, CmdOp>;\n        self.batch_future = Some(\n            async move {\n                // batches with 5 millisecond execution groupings\n                sleep(Duration::from_millis(5)).await;\n                // pool itself is static. Rust doesn't know this.\n                let this = unsafe { &mut *pool };\n                // cmds are immutable, and retained as long as executions. Rust doesn't know this.\n                let cmds = unsafe { &mut *cmds };\n                let mut batch: HashSet<&CmdOp> =\n                    this.batching.iter().map(|cmd_num| &cmds[cmd_num]).collect();\n                let running: HashSet<&BatchCmd> = this\n                    .execs\n                    .values()\n                    .filter(|exec| matches!(&exec.state, ExecState::Executing))\n                    .map(|exec| &exec.cmd)\n                    .collect();\n                let mut global_completion_map: Vec<(usize, usize)> = Vec::new();\n                let mut batched: Vec<BatchCmd> = Vec::new();\n\n                let mut batcher = 0;\n                if this.extension_env.has_batchers() {\n                    'outer: loop {\n                        let (\n                            BatcherResult {\n                                defer: mut queue,\n                                mut exec,\n                                mut completion_map,\n                            },\n                            next,\n                        ) = this.extension_env.run_batcher(batcher, &batch, &running)?;\n                        if let Some(completion_map) = completion_map.take() {\n                            for (cmd_num, exec_num) in completion_map {\n                                batch.remove(&cmds[&cmd_num]);\n                                this.batching.remove(&cmd_num);\n                                global_completion_map.push((cmd_num, exec_num));\n                            }\n                        }\n                        if let Some(queue) = queue.take() {\n                            for cmd_num in queue {\n                                batch.remove(&cmds[&cmd_num]);\n                            }\n                        }\n                        if let Some(mut exec) = exec.take() {\n                            for cmd in exec.drain(..) {\n                                for cmd_num in cmd.ids.iter() {\n                                    this.batching.remove(cmd_num);\n                                    batch.remove(&cmds[cmd_num]);\n                                }\n                                batched.push(cmd);\n                            }\n                        }\n                        match next {\n                            Some(num) => batcher = num,\n                            None => break 'outer,\n                        };\n                    }\n                }\n                for (cmd_num, exec_num) in global_completion_map {\n                    this.execs.get_mut(&exec_num).unwrap().cmd.ids.push(cmd_num);\n                }\n                for cmd in batched.drain(..) {\n                    this.new_exec(cmd).await;\n                }\n                // any leftover unbatched just get batched\n                for cmd in batch {\n                    if this.exec_cnt == this.pool_size {\n                        break;\n                    }\n                    this.batching.remove(&cmd.id);\n                    this.new_exec(BatchCmd {\n                        id: None,\n                        echo: cmd.echo,\n                        run: cmd.run.to_string(),\n                        cwd: cmd.cwd.clone(),\n                        engine: cmd.engine,\n                        env: cmd.env.clone(),\n                        stdio: Some(cmd.stdio),\n                        ids: vec![cmd.id],\n                    })\n                    .await;\n                }\n\n                this.batch_future = None;\n                Ok(())\n            }\n            .boxed_local()\n            .shared(),\n        );\n    }\n\n    async fn new_exec(&mut self, mut cmd: BatchCmd) {\n        let exec_num = self.exec_num;\n        cmd.id = Some(exec_num);\n\n        let mut targets = Vec::new();\n        for id in &cmd.ids {\n            let cmd = &self.cmds[id];\n            if let Some(name) = &cmd.name {\n                println!(\"\\x1b[1m▶ {}\\x1b[0m\", name);\n            }\n            for target in &cmd.targets {\n                let target_path = Path::new(target);\n                if let Some(parent) = target_path.parent() {\n                    fs::create_dir_all(parent).await.unwrap();\n                }\n                targets.push(target.to_string());\n            }\n        }\n\n        // cmd_execs and execs must be populated together without an await between them:\n        // get_exec_future reads cmd_execs first and then indexes execs, so any yield in between\n        // lets another future observe cmd_execs populated while execs is still missing.\n        for id in &cmd.ids {\n            self.cmd_execs.insert(*id, exec_num);\n        }\n\n        let pool = self as *mut CmdPool;\n\n        match cmd.engine {\n            ChompEngine::Shell => {\n                let start_time = Instant::now();\n                self.exec_cnt += 1;\n                let child = create_cmd(\n                    cmd.cwd.as_ref().unwrap_or(&self.cwd),\n                    &self.path,\n                    &cmd,\n                    true,\n                );\n                let future = async move {\n                    let this = unsafe { &mut *pool };\n                    let exec = &mut this.execs.get_mut(&exec_num).unwrap();\n                    exec.state = match exec.child.as_mut().unwrap().wait().await {\n                        Ok(status) => {\n                            if status.success() {\n                                ExecState::Completed\n                            } else {\n                                ExecState::Failed\n                            }\n                        }\n                        Err(e) => match exec.state {\n                            ExecState::Terminating => ExecState::Terminated,\n                            _ => panic!(\"Unexpected exec error {:?}\", e),\n                        },\n                    };\n                    let end_time = Instant::now();\n                    this.exec_cnt -= 1;\n                    // finally we verify that the targets exist\n                    let mtime = check_target_mtimes(targets, true).await;\n                    Some((exec.state, mtime, end_time - start_time))\n                }\n                .boxed_local()\n                .shared();\n                self.execs.insert(\n                    exec_num,\n                    Exec {\n                        cmd,\n                        child,\n                        future,\n                        state: ExecState::Executing,\n                    },\n                );\n                self.exec_num += 1;\n            }\n            ChompEngine::Node => node_runner(self, cmd, targets),\n            ChompEngine::Deno => deno_runner(self, cmd, targets),\n        };\n    }\n\n    pub fn batch(\n        &mut self,\n        name: Option<String>,\n        run: &String,\n        targets: Vec<String>,\n        env: BTreeMap<String, String>,\n        replacements: bool,\n        cwd: Option<String>,\n        engine: ChompEngine,\n        stdio: TaskStdio,\n        echo: bool,\n    ) -> usize {\n        let id = self.cmd_num;\n        let run = if matches!(engine, ChompEngine::Shell) && replacements {\n            replace_env_vars_static(run, &env)\n        } else {\n            run.to_string()\n        };\n        self.cmds.insert(\n            id,\n            CmdOp {\n                id,\n                cwd,\n                name,\n                run,\n                env,\n                echo,\n                engine,\n                stdio,\n                targets,\n            },\n        );\n        self.cmd_num = id + 1;\n        self.batching.insert(id);\n        if self.batch_future.is_none() {\n            self.create_batch_future();\n        }\n        id\n    }\n}\n"
  },
  {
    "path": "src/engines/node.rs",
    "content": "// Chomp Task Runner\n// Copyright (C) 2022  Guy Bedford\n\n// This program is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n\n// This program is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n// GNU General Public License for more details.\n\n// You should have received a copy of the GNU General Public License\n// along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nuse crate::engines::check_target_mtimes;\nuse crate::engines::create_cmd;\nuse crate::engines::CmdPool;\nuse crate::engines::Exec;\nuse crate::engines::{BatchCmd, ExecState};\nuse base64::{engine::general_purpose, Engine as _};\nuse futures::future::FutureExt;\nuse percent_encoding::percent_encode;\nuse percent_encoding::NON_ALPHANUMERIC;\nuse std::time::Instant;\n\n// Custom node loader to mimic current working directory despite loading from a tmp file\n// Note: We dont have to percent encode as we're not using `,! characters\n// If this becomes a problem, switch to base64 encoding rather\nconst NODE_LOADER: &str = \"let s;export function resolve(u,c,d){if(c.parentURL===undefined){const i=u.indexOf('data:text/javascript;base64,');s=Buffer.from(u.slice(i+28),'base64');return{url:u.slice(0,i)+(u[i-1]==='/'?'':'/')+'[cm]',format:'module',shortCircuit:true}}return d(u,c)}export function load(u,c,d){if(u.endsWith('[cm]'))return{source:s,format:'module',shortCircuit:true};return d(u,c)}export{load as getFormat,load as getSource}\";\n\npub fn node_runner(cmd_pool: &mut CmdPool, mut cmd: BatchCmd, targets: Vec<String>) {\n    let start_time = Instant::now();\n    cmd.env.insert(\n        \"CHOMP_PATH\".to_string(),\n        std::env::args().next().unwrap().to_string(),\n    );\n    let targets = targets.clone();\n    // On posix, command starts executing before we wait on it!\n    cmd.run = format!(\n    \"node --no-warnings --loader \\\"data:text/javascript,{}\\\" \\\"data:text/javascript;base64,{}\\\"\",\n    percent_encode(NODE_LOADER.to_string().as_bytes(), NON_ALPHANUMERIC),\n    general_purpose::STANDARD.encode(cmd.run.as_bytes())\n  );\n    let echo = cmd.echo;\n    cmd.echo = false;\n    let run_clone = if echo { Some(cmd.run.clone()) } else { None };\n    let exec_num = cmd_pool.exec_num;\n    cmd_pool.exec_cnt += 1;\n    let pool = cmd_pool as *mut CmdPool;\n    let child = create_cmd(\n        cmd.cwd.as_ref().unwrap_or(&cmd_pool.cwd),\n        &cmd_pool.path,\n        &cmd,\n        false,\n    );\n    let future = async move {\n        let cmd_pool = unsafe { &mut *pool };\n        let exec = &mut cmd_pool.execs.get_mut(&exec_num).unwrap();\n        exec.child.as_ref()?;\n        if echo {\n            println!(\"{}\", run_clone.as_ref().unwrap());\n        }\n        exec.state = match exec.child.as_mut().unwrap().wait().await {\n            Ok(status) => {\n                if status.success() {\n                    ExecState::Completed\n                } else {\n                    ExecState::Failed\n                }\n            }\n            Err(e) => match exec.state {\n                ExecState::Terminating => ExecState::Terminated,\n                _ => panic!(\"Unexpected exec error {:?}\", e),\n            },\n        };\n        cmd_pool.exec_cnt -= 1;\n        let end_time = Instant::now();\n        // finally we verify that the targets exist\n        let mtime = check_target_mtimes(targets, true).await;\n        Some((exec.state, mtime, end_time - start_time))\n    }\n    .boxed_local()\n    .shared();\n\n    cmd_pool.execs.insert(\n        exec_num,\n        Exec {\n            cmd,\n            child,\n            future,\n            state: ExecState::Executing,\n        },\n    );\n    cmd_pool.exec_num += 1;\n}\n"
  },
  {
    "path": "src/extensions.rs",
    "content": "// Chomp Task Runner\n// Copyright (C) 2022  Guy Bedford\n\n// This program is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n\n// This program is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n// GNU General Public License for more details.\n\n// You should have received a copy of the GNU General Public License\n// along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nuse crate::chompfile::ChompTaskMaybeTemplatedJs;\nuse crate::engines::BatchCmd;\nuse crate::engines::CmdOp;\nuse crate::ChompTaskMaybeTemplated;\nuse crate::Chompfile;\nuse anyhow::{anyhow, Error, Result};\nuse convert_case::{Case, Casing};\nuse serde::Deserialize;\nuse serde_v8::from_v8;\nuse serde_v8::to_v8;\nuse std::cell::RefCell;\nuse std::collections::BTreeMap;\nuse std::collections::HashMap;\nuse std::collections::HashSet;\nuse std::collections::VecDeque;\nuse std::rc::Rc;\n\npub struct ExtensionEnvironment {\n    isolate: v8::OwnedIsolate,\n    has_extensions: bool,\n    global_context: v8::Global<v8::Context>,\n}\n\n#[derive(Debug, PartialEq, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct BatcherResult {\n    pub defer: Option<Vec<usize>>,\n    pub exec: Option<Vec<BatchCmd>>,\n    pub completion_map: Option<HashMap<usize, usize>>,\n}\n\nstruct Extensions {\n    pub tasks: Vec<ChompTaskMaybeTemplatedJs>,\n    can_register: bool,\n    includes: Vec<String>,\n    templates: HashMap<String, v8::Global<v8::Function>>,\n    batchers: Vec<(String, v8::Global<v8::Function>)>,\n}\n\nimpl Extensions {\n    fn new() -> Self {\n        Extensions {\n            can_register: true,\n            tasks: Vec::new(),\n            includes: Vec::new(),\n            templates: HashMap::new(),\n            batchers: Vec::new(),\n        }\n    }\n}\n\nfn create_template_options(\n    template: &str,\n    task_options: &Option<HashMap<String, toml::value::Value>>,\n    default_options: &HashMap<String, HashMap<String, toml::value::Value>>,\n    convert_case: bool,\n) -> HashMap<String, toml::value::Value> {\n    let mut options = HashMap::new();\n    if let Some(task_options) = task_options {\n        for (key, value) in task_options {\n            let converted_key = if convert_case {\n                key.from_case(Case::Kebab).to_case(Case::Camel)\n            } else {\n                key.to_string()\n            };\n            options.insert(converted_key, value.clone());\n        }\n    };\n    if let Some(default_options) = default_options.get(template) {\n        for (key, value) in default_options {\n            let converted_key = key.from_case(Case::Kebab).to_case(Case::Camel);\n            if options.contains_key(&converted_key) {\n                continue;\n            }\n            options.insert(converted_key, value.clone());\n        }\n    }\n    options\n}\n\npub fn expand_template_tasks(\n    chompfile: &Chompfile,\n    extension_env: &mut ExtensionEnvironment,\n    cwd: &str,\n) -> Result<(bool, Vec<ChompTaskMaybeTemplated>)> {\n    let mut out_tasks = Vec::new();\n    let mut has_templates = false;\n\n    // expand tasks into initial job list\n    let mut task_queue: VecDeque<ChompTaskMaybeTemplated> = VecDeque::new();\n    for (idx, task) in chompfile.task.iter().enumerate() {\n        if task.deps.is_some() && task.dep.is_some() {\n            return Err(anyhow!(\"Invalid task: Both 'dep' and 'deps' fields are used by task {}, either a single dep or list of deps must be provided.\", idx));\n        }\n        if task.targets.is_some() && task.target.is_some() {\n            return Err(anyhow!(\"Invalid task: Both 'target' and 'targets' fields are used by task {}, either a single target or list of targets must be provided.\", idx));\n        }\n        let mut cloned = task.clone();\n        if let Some(ref template) = task.template {\n            cloned.template_options = Some(create_template_options(\n                template,\n                &task.template_options,\n                &chompfile.template_options,\n                true,\n            ))\n        };\n        task_queue.push_back(cloned);\n    }\n\n    while !task_queue.is_empty() {\n        let mut task = task_queue.pop_front().unwrap();\n        if task.template.is_none() {\n            out_tasks.push(task);\n            continue;\n        }\n        has_templates = true;\n        let template = task.template.as_ref().unwrap();\n\n        if task.deps.is_none() {\n            task.deps = Some(Default::default());\n        }\n        let js_task = ChompTaskMaybeTemplatedJs {\n            cwd: task.cwd.clone(),\n            name: task.name.clone(),\n            target: None,\n            targets: Some(task.targets_vec(cwd)?),\n            invalidation: Some(task.invalidation.unwrap_or_default()),\n            validation: Some(task.validation.unwrap_or_default()),\n            dep: None,\n            deps: Some(task.deps_vec(chompfile, cwd)?),\n            args: task.args.clone(),\n            echo: task.echo,\n            display: task.display,\n            stdio: Some(task.stdio.unwrap_or_default()),\n            serial: task.serial,\n            env_replace: task.env_replace,\n            env: task.env,\n            env_default: task.env_default,\n            run: task.run,\n            engine: task.engine,\n            template: None,\n            template_options: task.template_options,\n            watch_invalidation: task.watch_invalidation,\n        };\n        let mut template_tasks: Vec<ChompTaskMaybeTemplatedJs> =\n            extension_env.run_template(template, &js_task)?;\n        // template functions output a list of tasks\n        for mut template_task in template_tasks.drain(..).rev() {\n            template_task.template_options = if let Some(ref template) = template_task.template {\n                Some(create_template_options(\n                    template,\n                    &template_task.template_options,\n                    &chompfile.template_options,\n                    false,\n                ))\n            } else {\n                None\n            };\n            task_queue.push_front(template_task.into());\n        }\n    }\n\n    Ok((has_templates, out_tasks))\n}\n\npub fn init_js_platform() {\n    let platform = v8::new_default_platform(0, false).make_shared();\n    v8::V8::initialize_platform(platform);\n    v8::V8::initialize();\n}\n\nfn chomp_log(\n    scope: &mut v8::HandleScope,\n    args: v8::FunctionCallbackArguments,\n    mut _rv: v8::ReturnValue,\n) {\n    let mut msg = String::new();\n    let len = args.length();\n    let mut i = 0;\n    while i < len {\n        // TODO: better object logging - currently throws on objects\n        let arg: v8::Local<v8::Value> = args.get(i);\n        if i > 0 {\n            msg.push_str(\", \");\n        }\n        msg.push_str(&arg.to_rust_string_lossy(scope));\n        i += 1;\n    }\n    println!(\"{}\", &msg);\n}\n\nfn chomp_include(\n    scope: &mut v8::HandleScope,\n    args: v8::FunctionCallbackArguments,\n    mut _rv: v8::ReturnValue,\n) {\n    let include: String = {\n        let tc_scope = &mut v8::TryCatch::new(scope);\n        from_v8(tc_scope, args.get(0)).expect(\"Unable to register include\")\n    };\n    let mut extension_env = scope\n        .get_slot::<Rc<RefCell<Extensions>>>()\n        .unwrap()\n        .borrow_mut();\n    if !extension_env.can_register {\n        panic!(\"Chomp does not yet support dynamic includes.\");\n    }\n    extension_env.includes.push(include);\n}\n\nfn chomp_register_task(\n    scope: &mut v8::HandleScope,\n    args: v8::FunctionCallbackArguments,\n    mut _rv: v8::ReturnValue,\n) {\n    let task: ChompTaskMaybeTemplatedJs = {\n        let tc_scope = &mut v8::TryCatch::new(scope);\n        from_v8(tc_scope, args.get(0)).expect(\"Unable to register task\")\n    };\n    let mut extension_env = scope\n        .get_slot::<Rc<RefCell<Extensions>>>()\n        .unwrap()\n        .borrow_mut();\n    if !extension_env.can_register {\n        panic!(\"Chomp does not support dynamic task registration.\");\n    }\n    extension_env.tasks.push(task);\n}\n\nfn chomp_register_template(\n    scope: &mut v8::HandleScope,\n    args: v8::FunctionCallbackArguments,\n    mut _rv: v8::ReturnValue,\n) {\n    let name = args.get(0).to_string(scope).unwrap();\n    let name_str = name.to_rust_string_lossy(scope);\n    let tpl = v8::Local::<v8::Function>::try_from(args.get(1)).unwrap();\n    let tpl_global = v8::Global::new(scope, tpl);\n\n    let mut extension_env = scope\n        .get_slot::<Rc<RefCell<Extensions>>>()\n        .unwrap()\n        .borrow_mut();\n    if !extension_env.can_register {\n        panic!(\"Chomp does not support dynamic template registration.\");\n    }\n    extension_env.templates.insert(name_str, tpl_global);\n}\n\nfn chomp_register_batcher(\n    scope: &mut v8::HandleScope,\n    args: v8::FunctionCallbackArguments,\n    mut _rv: v8::ReturnValue,\n) {\n    let name = args.get(0).to_string(scope).unwrap();\n    let name_str = name.to_rust_string_lossy(scope);\n    let batch = v8::Local::<v8::Function>::try_from(args.get(1)).unwrap();\n    let batch_global = v8::Global::new(scope, batch);\n\n    let mut extension_env = scope\n        .get_slot::<Rc<RefCell<Extensions>>>()\n        .unwrap()\n        .borrow_mut();\n    if !extension_env.can_register {\n        panic!(\"Chomp does not support dynamic batcher registration.\");\n    }\n    // remove any existing batcher by the same name\n    if let Some(prev_batcher) = extension_env\n        .batchers\n        .iter()\n        .position(|name| name.0 == name_str)\n    {\n        extension_env.batchers.remove(prev_batcher);\n    }\n    extension_env.batchers.push((name_str, batch_global));\n}\n\nimpl ExtensionEnvironment {\n    pub fn new(global_env: &BTreeMap<String, String>) -> Self {\n        let mut isolate = v8::Isolate::new(Default::default());\n\n        let global_context = {\n            let mut handle_scope = v8::HandleScope::new(&mut isolate);\n            let context = v8::Context::new(&mut handle_scope);\n            let global = context.global(&mut handle_scope);\n\n            let scope = &mut v8::ContextScope::new(&mut handle_scope, context);\n\n            let chomp_key = v8::String::new(scope, \"Chomp\").unwrap();\n            let chomp_val = v8::Object::new(scope);\n            global.set(scope, chomp_key.into(), chomp_val.into());\n\n            let console_key = v8::String::new(scope, \"console\").unwrap();\n            let console_val = v8::Object::new(scope);\n            global.set(scope, console_key.into(), console_val.into());\n\n            let log_fn = v8::FunctionTemplate::new(scope, chomp_log)\n                .get_function(scope)\n                .unwrap();\n            let log_key = v8::String::new(scope, \"log\").unwrap();\n            console_val.set(scope, log_key.into(), log_fn.into());\n\n            let version_key = v8::String::new(scope, \"version\").unwrap();\n            let version_str = v8::String::new(scope, \"0.1\").unwrap();\n            chomp_val.set(scope, version_key.into(), version_str.into());\n\n            let task_fn = v8::FunctionTemplate::new(scope, chomp_register_task)\n                .get_function(scope)\n                .unwrap();\n            let task_key = v8::String::new(scope, \"registerTask\").unwrap();\n            chomp_val.set(scope, task_key.into(), task_fn.into());\n\n            let tpl_fn = v8::FunctionTemplate::new(scope, chomp_register_template)\n                .get_function(scope)\n                .unwrap();\n            let template_key = v8::String::new(scope, \"registerTemplate\").unwrap();\n            chomp_val.set(scope, template_key.into(), tpl_fn.into());\n\n            let batch_fn = v8::FunctionTemplate::new(scope, chomp_register_batcher)\n                .get_function(scope)\n                .unwrap();\n            let batcher_key = v8::String::new(scope, \"registerBatcher\").unwrap();\n            chomp_val.set(scope, batcher_key.into(), batch_fn.into());\n\n            let include_fn = v8::FunctionTemplate::new(scope, chomp_include)\n                .get_function(scope)\n                .unwrap();\n            let include_key = v8::String::new(scope, \"addExtension\").unwrap();\n            chomp_val.set(scope, include_key.into(), include_fn.into());\n\n            let env_key = v8::String::new(scope, \"ENV\").unwrap();\n            let env_val = v8::Object::new(scope);\n            global.set(scope, env_key.into(), env_val.into());\n\n            for (key, value) in global_env {\n                let env_key = v8::String::new(scope, key).unwrap();\n                let env_key_val = v8::String::new(scope, value).unwrap();\n                env_val.set(scope, env_key.into(), env_key_val.into());\n            }\n\n            v8::Global::new(scope, context)\n        };\n\n        let extensions = Extensions::new();\n        isolate.set_slot(Rc::new(RefCell::new(extensions)));\n\n        ExtensionEnvironment {\n            isolate,\n            has_extensions: false,\n            global_context,\n        }\n    }\n\n    fn handle_scope(&mut self) -> v8::HandleScope<'_> {\n        v8::HandleScope::with_context(&mut self.isolate, self.global_context.clone())\n    }\n\n    pub fn get_tasks(&self) -> Vec<ChompTaskMaybeTemplatedJs> {\n        self.isolate\n            .get_slot::<Rc<RefCell<Extensions>>>()\n            .unwrap()\n            .borrow()\n            .tasks\n            .clone()\n    }\n\n    fn get_extensions(&self) -> &Rc<RefCell<Extensions>> {\n        self.isolate.get_slot::<Rc<RefCell<Extensions>>>().unwrap()\n    }\n\n    pub fn add_extension(\n        &mut self,\n        extension_source: &str,\n        filename: &str,\n    ) -> Result<Option<Vec<String>>> {\n        self.has_extensions = true;\n        {\n            let mut handle_scope = self.handle_scope();\n            let code =\n                v8::String::new(&mut handle_scope, &format!(\"{{{}}}\", extension_source)).unwrap();\n            let tc_scope = &mut v8::TryCatch::new(&mut handle_scope);\n            let resource_name = v8::String::new(tc_scope, filename).unwrap().into();\n            let source_map = v8::String::new(tc_scope, \"\").unwrap().into();\n            let origin = v8::ScriptOrigin::new(\n                tc_scope,\n                resource_name,\n                0,\n                0,\n                false,\n                123,\n                source_map,\n                true,\n                false,\n                false,\n            );\n            let script = match v8::Script::compile(tc_scope, code, Some(&origin)) {\n                Some(script) => script,\n                None => return Err(v8_exception(tc_scope)),\n            };\n            match script.run(tc_scope) {\n                Some(_) => {}\n                None => return Err(v8_exception(tc_scope)),\n            };\n        }\n        let mut extensions = self.get_extensions().borrow_mut();\n        if !extensions.includes.is_empty() {\n            Ok(Some(extensions.includes.drain(..).collect()))\n        } else {\n            Ok(None)\n        }\n    }\n\n    pub fn seal_extensions(&mut self) {\n        let mut extensions = self.get_extensions().borrow_mut();\n        extensions.can_register = false;\n    }\n\n    pub fn run_template(\n        &mut self,\n        name: &str,\n        task: &ChompTaskMaybeTemplatedJs,\n    ) -> Result<Vec<ChompTaskMaybeTemplatedJs>> {\n        let template = {\n            let extensions = self.get_extensions().borrow();\n            match extensions.templates.get(name) {\n                Some(tpl) => Ok(tpl.clone()),\n                None => {\n                    if name == \"babel\"\n                        || name == \"cargo\"\n                        || name == \"jspm\"\n                        || name == \"npm\"\n                        || name == \"prettier\"\n                        || name == \"svelte\"\n                        || name == \"swc\"\n                    {\n                        if self.has_extensions {\n                            Err(anyhow!(\"Template '{}' has not been registered. To include the core template, add \\x1b[1m'chomp@0.1:{}'\\x1b[0m to the extensions list:\\x1b[36m\\n\\n  extensions = [..., 'chomp@0.1:{}']\\n\\n\\x1b[0min the \\x1b[1mchompfile.toml\\x1b[0m.\", &name, &name, &name))\n                        } else {\n                            Err(anyhow!(\"Template '{}' has not been registered. To include the core template, add:\\x1b[36m\\n\\n  extensions = ['chomp@0.1:{}']\\n\\n\\x1b[0mto the \\x1b[1mchompfile.toml\\x1b[0m.\", &name, &name))\n                        }\n                    } else {\n                        Err(anyhow!(\"Template '{}' has not been registered. Make sure it is included in the \\x1b[1mchompfile.toml\\x1b[0m extensions.\", &name))\n                    }\n                }\n            }\n        }?;\n        let cb = template.open(&mut self.isolate);\n\n        let mut handle_scope = self.handle_scope();\n        let tc_scope = &mut v8::TryCatch::new(&mut handle_scope);\n\n        let this = v8::undefined(tc_scope).into();\n        let args: Vec<v8::Local<v8::Value>> =\n            vec![to_v8(tc_scope, task).expect(\"Unable to serialize template params\")];\n        let result = match cb.call(tc_scope, this, args.as_slice()) {\n            Some(result) => result,\n            None => return Err(v8_exception(tc_scope)),\n        };\n        let task: Vec<ChompTaskMaybeTemplatedJs> = from_v8(tc_scope, result)\n            .expect(\"Unable to deserialize template task list due to invalid structure\");\n        Ok(task)\n    }\n\n    pub fn has_batchers(&self) -> bool {\n        !self.get_extensions().borrow().batchers.is_empty()\n    }\n\n    pub fn run_batcher(\n        &mut self,\n        idx: usize,\n        batch: &HashSet<&CmdOp>,\n        running: &HashSet<&BatchCmd>,\n    ) -> Result<(BatcherResult, Option<usize>)> {\n        let (name, batcher, batchers_len) = {\n            let extensions = self.get_extensions().borrow();\n            let (name, batcher) = extensions.batchers[idx].clone();\n            (name, batcher, extensions.batchers.len())\n        };\n        let cb = batcher.open(&mut self.isolate);\n\n        let mut handle_scope = self.handle_scope();\n        let tc_scope = &mut v8::TryCatch::new(&mut handle_scope);\n\n        let this = v8::undefined(tc_scope).into();\n        let args: Vec<v8::Local<v8::Value>> = vec![\n            to_v8(tc_scope, batch).expect(\"Unable to serialize batcher call\"),\n            to_v8(tc_scope, running).expect(\"Unable to serialize batcher call\"),\n        ];\n\n        let result = match cb.call(tc_scope, this, args.as_slice()) {\n            Some(result) => result,\n            None => return Err(v8_exception(tc_scope)),\n        };\n\n        let result: Option<BatcherResult> = from_v8(tc_scope, result).unwrap_or_else(|_| panic!(\"Unable to deserialize batch for {} due to invalid structure\",\n            name));\n        let next = if idx < batchers_len - 1 {\n            Some(idx + 1)\n        } else {\n            None\n        };\n        Ok((\n            result.unwrap_or(BatcherResult {\n                defer: None,\n                exec: None,\n                completion_map: None,\n            }),\n            next,\n        ))\n    }\n}\n\nfn v8_exception(scope: &mut v8::TryCatch<v8::HandleScope>) -> Error {\n    let exception = scope.exception().unwrap();\n    if is_instance_of_error(scope, exception) {\n        let exception: v8::Local<v8::Object> = exception.try_into().unwrap();\n\n        let stack = get_property(scope, exception, \"stack\");\n        let stack: Option<v8::Local<v8::String>> = stack.and_then(|s| s.try_into().ok());\n        let stack = stack.map(|s| s.to_rust_string_lossy(scope));\n        let err_str = stack.unwrap();\n        if let Some(rest) = err_str.strip_prefix(\"Error: \") {\n            anyhow!(\"{}\", rest)\n        } else if let Some(rest) = err_str.strip_prefix(\"TypeError: \") {\n            anyhow!(\"TypeError {}\", rest)\n        } else if let Some(rest) = err_str.strip_prefix(\"SyntaxError: \") {\n            anyhow!(\"SyntaxError {}\", rest)\n        } else if let Some(rest) = err_str.strip_prefix(\"ReferenceError: \") {\n            anyhow!(\"ReferenceError {}\", rest)\n        } else {\n            anyhow!(\"{}\", &err_str)\n        }\n    } else {\n        anyhow!(\"JS error: {}\", exception.to_rust_string_lossy(scope))\n    }\n}\n\nfn get_property<'a>(\n    scope: &mut v8::HandleScope<'a>,\n    object: v8::Local<v8::Object>,\n    key: &str,\n) -> Option<v8::Local<'a, v8::Value>> {\n    let key = v8::String::new(scope, key).unwrap();\n    object.get(scope, key.into())\n}\n\nfn is_instance_of_error<'s>(scope: &mut v8::HandleScope<'s>, value: v8::Local<v8::Value>) -> bool {\n    if !value.is_object() {\n        return false;\n    }\n    let message = v8::String::empty(scope);\n    let error_prototype = v8::Exception::error(scope, message)\n        .to_object(scope)\n        .unwrap()\n        .get_prototype(scope)\n        .unwrap();\n    let mut maybe_prototype = value.to_object(scope).unwrap().get_prototype(scope);\n    while let Some(prototype) = maybe_prototype {\n        if prototype.strict_equals(error_prototype) {\n            return true;\n        }\n        maybe_prototype = prototype\n            .to_object(scope)\n            .and_then(|o| o.get_prototype(scope));\n    }\n    false\n}\n"
  },
  {
    "path": "src/http_client.rs",
    "content": "// Chomp Task Runner\n// Copyright (C) 2022  Guy Bedford\n\n// This program is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n\n// This program is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n// GNU General Public License for more details.\n\n// You should have received a copy of the GNU General Public License\n// along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nuse anyhow::{anyhow, Result};\nuse dirs::home_dir;\nuse http_body_util::{BodyExt, Empty};\nuse hyper::body::Bytes;\nuse hyper::Uri;\nuse hyper_tls::HttpsConnector;\nuse hyper_util::client::legacy::{connect::HttpConnector, Client};\nuse hyper_util::rt::TokioExecutor;\nuse sha2::{Digest, Sha256};\nuse std::path::PathBuf;\nuse tokio::fs;\n\nfn chomp_cache_dir() -> PathBuf {\n    let mut path = home_dir().unwrap();\n    path.push(\".chomp\");\n    path.push(\"cache\");\n    path\n}\n\npub async fn clear_cache() -> std::io::Result<()> {\n    match fs::remove_dir_all(chomp_cache_dir()).await {\n        Ok(()) => Ok(()),\n        Err(e) => match e.kind() {\n            std::io::ErrorKind::NotFound => Ok(()),\n            _ => Err(e),\n        },\n    }\n}\n\npub async fn prep_cache() -> Result<()> {\n    let _ = fs::create_dir_all(chomp_cache_dir()).await;\n    Ok(())\n}\n\n#[inline(always)]\nfn u4_to_hex_char(c: u8) -> char {\n    (if c < 10 { c + 48 } else { c + 87 } as char)\n}\n\npub fn hash(input: &[u8]) -> String {\n    let mut hasher = Sha256::new();\n    hasher.update(input);\n    let result = hasher.finalize();\n    let mut out_hash = String::with_capacity(64);\n    for c in result {\n        out_hash.push(u4_to_hex_char(c & 0xF));\n        out_hash.push(u4_to_hex_char(c >> 4));\n    }\n    out_hash\n}\n\nasync fn from_cache(cache_key: &str) -> Option<String> {\n    let mut path = chomp_cache_dir();\n    path.push(cache_key);\n    match fs::read_to_string(&path).await {\n        Ok(cached) => Some(cached),\n        Err(e) => match e.kind() {\n            std::io::ErrorKind::NotFound => None,\n            _ => panic!(\"File error {}\", e),\n        },\n    }\n}\n\nasync fn write_cache(cache_key: &str, source: &str) -> Result<()> {\n    let mut path = chomp_cache_dir();\n    path.push(cache_key);\n    fs::write(&path, source).await?;\n    Ok(())\n}\n\npub async fn fetch_uri_cached(uri_str: &str, uri: Uri) -> Result<String> {\n    let hash = hash(uri_str.as_bytes());\n    if let Some(cached) = from_cache(&hash).await {\n        return Ok(cached);\n    }\n\n    println!(\"\\x1b[34;1mFetch\\x1b[0m {}\", &uri_str);\n    let https = HttpsConnector::new();\n    let client: Client<HttpsConnector<HttpConnector>, Empty<Bytes>> =\n        Client::builder(TokioExecutor::new()).build(https);\n\n    let res = client.get(uri).await?;\n    if res.status() != 200 {\n        return Err(anyhow!(\"{} for extension URL {}\", res.status(), uri_str));\n    }\n\n    let body_bytes = res.into_body().collect().await?.to_bytes();\n    let result = String::from_utf8(body_bytes.to_vec()).unwrap();\n    write_cache(&hash, &result).await?;\n    Ok(result)\n}\n"
  },
  {
    "path": "src/main.rs",
    "content": "// Chomp Task Runner\n// Copyright (C) 2022  Guy Bedford\n\n// This program is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n\n// This program is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n// GNU General Public License for more details.\n\n// You should have received a copy of the GNU General Public License\n// along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\n#![allow(clippy::type_complexity, clippy::too_many_arguments)]\n\nextern crate clap;\n#[macro_use]\nextern crate lazy_static;\nuse crate::chompfile::ChompTaskMaybeTemplated;\nuse crate::chompfile::Chompfile;\nuse crate::extensions::expand_template_tasks;\nuse crate::extensions::init_js_platform;\nuse crate::extensions::ExtensionEnvironment;\nuse crate::task::Runner;\nuse anyhow::{anyhow, Result};\nuse clap::{Arg, ArgAction, Command};\nuse std::collections::{BTreeMap, HashMap, HashSet};\nuse std::fs;\nuse std::path::Path;\nextern crate num_cpus;\nuse crate::engines::replace_env_vars_static;\nuse hyper::Uri;\nuse std::env;\nuse std::fs::canonicalize;\nuse tokio::sync::mpsc::unbounded_channel;\n\nmod ansi_windows;\nmod chompfile;\nmod engines;\nmod extensions;\nmod http_client;\nmod server;\nmod task;\n\nuse std::path::PathBuf;\n\nconst CHOMP_CORE: &str = \"https://ga.jspm.io/npm:@chompbuild/extensions@0.1.31/\";\n\nconst CHOMP_INIT: &str = r#\"version = 0.1\n\n[[task]]\nname = 'build'\nrun = 'echo \\\"Build script goes here\\\"'\n\"#;\n\nconst CHOMP_EMPTY: &str = \"version = 0.1\\n\";\n\nfn uri_parse(uri_str: &str) -> Option<Uri> {\n    let uri = uri_str.parse::<Uri>().ok()?;\n    uri.scheme_str()?;\n    Some(uri)\n}\n\n#[tokio::main]\nasync fn main() -> Result<()> {\n    #[cfg(not(debug_assertions))]\n    let version = \"0.3.0\";\n    #[cfg(debug_assertions)]\n    let version = \"0.3.0-debug\";\n    let matches = Command::new(\"Chomp\")\n        .version(version)\n        .arg(\n            Arg::new(\"watch\")\n                .short('w')\n                .long(\"watch\")\n                .help(\"Watch the input files for changes\")\n                .action(ArgAction::SetTrue),\n        )\n        .arg(\n            Arg::new(\"serve\")\n                .short('s')\n                .long(\"serve\")\n                .help(\"Run a local dev server\")\n                .action(ArgAction::SetTrue),\n        )\n        .arg(\n            Arg::new(\"server-root\")\n                .short('R')\n                .long(\"server-root\")\n                .help(\"Server root path\"),\n        )\n        .arg(\n            Arg::new(\"port\")\n                .short('p')\n                .long(\"port\")\n                .value_name(\"PORT\")\n                .help(\"Custom port to serve\"),\n        )\n        .arg(\n            Arg::new(\"jobs\")\n                .short('j')\n                .long(\"jobs\")\n                .value_name(\"N\")\n                .value_parser(clap::value_parser!(usize))\n                .help(\"Maximum number of jobs to run in parallel\"),\n        )\n        .arg(\n            Arg::new(\"config\")\n                .short('c')\n                .long(\"config\")\n                .value_name(\"CONFIG\")\n                .default_value(\"chompfile.toml\")\n                .help(\"Custom chompfile path\"),\n        )\n        .arg(\n            Arg::new(\"list\")\n                .short('l')\n                .long(\"list\")\n                .help(\"List the available chompfile tasks\")\n                .action(ArgAction::SetTrue),\n        )\n        .arg(\n            Arg::new(\"format\")\n                .short('F')\n                .long(\"format\")\n                .help(\"Format and save the chompfile.toml\")\n                .action(ArgAction::SetTrue),\n        )\n        .arg(\n            Arg::new(\"eject_templates\")\n                .long(\"eject\")\n                .help(\"Ejects templates into tasks saving the rewritten chompfile.toml\")\n                .action(ArgAction::SetTrue),\n        )\n        .arg(\n            Arg::new(\"init\")\n                .short('i')\n                .long(\"init\")\n                .help(\"Initialize a new chompfile.toml if it does not exist\")\n                .action(ArgAction::SetTrue),\n        )\n        .arg(\n            Arg::new(\"import_scripts\")\n                .short('I')\n                .long(\"import-scripts\")\n                .help(\"Import from npm \\\"scripts\\\" into the chompfile.toml\")\n                .action(ArgAction::SetTrue),\n        )\n        .arg(\n            Arg::new(\"clear_cache\")\n                .short('C')\n                .long(\"clear-cache\")\n                .help(\"Clear URL extension cache\")\n                .action(ArgAction::SetTrue),\n        )\n        .arg(\n            Arg::new(\"rerun\")\n                .short('r')\n                .long(\"rerun\")\n                .help(\"Rerun the target tasks even if cached\")\n                .action(ArgAction::SetTrue),\n        )\n        .arg(\n            Arg::new(\"force\")\n                .short('f')\n                .long(\"force\")\n                .help(\"Force rebuild targets\")\n                .action(ArgAction::SetTrue),\n        )\n        .arg(\n            Arg::new(\"target\")\n                .value_name(\"TARGET\")\n                .help(\"Generate a target or list of targets\")\n                .action(ArgAction::Append),\n        )\n        .arg(\n            Arg::new(\"arg\")\n                .last(true)\n                .value_name(\"ARGS\")\n                .help(\"Custom task args\")\n                .action(ArgAction::Append),\n        )\n        .get_matches();\n\n    #[cfg(target_os = \"windows\")]\n    match ansi_windows::enable_ansi_support() {\n        Ok(()) => {}\n        Err(_) => {\n            // TODO: handling disabling of ansi codes\n        }\n    };\n\n    let mut targets: Vec<String> = Vec::new();\n    let mut use_default_target = true;\n    if let Some(target) = matches.get_many::<String>(\"target\") {\n        for item in target {\n            targets.push(item.to_string());\n        }\n    }\n\n    let cfg_path = Path::new(matches.get_one::<String>(\"config\").unwrap());\n    let cfg_dir = cfg_path.parent().unwrap().to_str().unwrap();\n    let mut cfg_file = canonicalize(if cfg_dir.is_empty() { \".\" } else { cfg_dir }).unwrap();\n    cfg_file.push(cfg_path.file_name().unwrap());\n\n    let mut created = false;\n    let chompfile_source = {\n        let is_dir: bool = match fs::metadata(&cfg_file) {\n            Ok(meta) => meta.is_dir(),\n            Err(_) => false,\n        };\n        if is_dir {\n            cfg_file.push(\"chompfile.toml\");\n        }\n        match fs::read_to_string(&cfg_file) {\n            Ok(source) => source,\n            Err(_) => {\n                if matches.get_flag(\"init\") {\n                    created = true;\n                    if matches.get_flag(\"import_scripts\") {\n                        String::from(CHOMP_EMPTY)\n                    } else {\n                        String::from(CHOMP_INIT)\n                    }\n                } else {\n                    if matches.get_flag(\"serve\") {\n                        String::from(CHOMP_EMPTY)\n                    } else {\n                        return Err(anyhow!(\n                            \"Unable to load the Chomp configuration {}. Pass the \\x1b[1m--init\\x1b[0m flag to create one, or try:\\n\\n\\x1b[36mchomp --init --import-scripts\\x1b[0m\\n\\nto create one and import from existing package.json scripts.\",\n                            &cfg_file.to_str().unwrap()\n                        ));\n                    }\n                }\n            }\n        }\n    };\n    let mut chompfile: Chompfile = toml::from_str(&chompfile_source)?;\n    if chompfile.version != 0.1 {\n        return Err(anyhow!(\n            \"Invalid chompfile version {}, only 0.1 is supported\",\n            chompfile.version\n        ));\n    }\n\n    let cwd = {\n        let mut parent: PathBuf = PathBuf::from(cfg_file.parent().unwrap());\n        if parent.to_str().unwrap().is_empty() {\n            parent = env::current_dir()?;\n        }\n        let unc_path = match canonicalize(&parent) {\n            Ok(path) => path,\n            Err(_) => {\n                return Err(anyhow!(\n                    \"Unable to load the Chomp configuration {}.\\nMake sure it exists in the current directory, or use --config to set a custom path.\",\n                    &cfg_file.to_str().unwrap()\n                ));\n            }\n        };\n        let unc_str = unc_path.to_str().unwrap();\n        if unc_str.starts_with(r\"\\\\?\\\") {\n            PathBuf::from(String::from(&unc_path.to_str().unwrap()[4..]))\n        } else {\n            unc_path\n        }\n    };\n    assert!(env::set_current_dir(&cwd).is_ok());\n\n    if matches.get_flag(\"clear_cache\") {\n        http_client::clear_cache().await?;\n        println!(\"\\x1b[1;32m√\\x1b[0m Cleared remote URL extension cache.\");\n        if targets.is_empty() {\n            return Ok(());\n        }\n    }\n\n    init_js_platform();\n\n    let pool_size = match matches.get_one::<usize>(\"jobs\") {\n        Some(&jobs) => jobs,\n        None => num_cpus::get(),\n    };\n\n    let mut global_env = BTreeMap::new();\n    for (key, value) in env::vars() {\n        global_env.insert(key.to_uppercase(), value);\n    }\n    for (key, value) in &chompfile.env {\n        global_env.insert(\n            key.to_uppercase(),\n            replace_env_vars_static(value, &global_env),\n        );\n    }\n    if matches.get_flag(\"eject_templates\") {\n        global_env.insert(\"CHOMP_EJECT\".to_string(), \"1\".to_string());\n    }\n    global_env.insert(\"CHOMP_POOL_SIZE\".to_string(), pool_size.to_string());\n    // extend global env with the chompfile env as well\n    for (key, value) in &chompfile.env_default {\n        if !global_env.contains_key(&key.to_uppercase()) {\n            global_env.insert(\n                key.to_uppercase(),\n                replace_env_vars_static(value, &global_env),\n            );\n        }\n    }\n\n    let mut extension_env = ExtensionEnvironment::new(&global_env);\n\n    http_client::prep_cache().await?;\n    let mut extension_set: HashSet<String> = HashSet::new();\n    let mut extensions = chompfile.extensions.clone();\n    let mut i = 0;\n    while i < extensions.len() {\n        if extensions[i].starts_with(\"chomp:\") {\n            return Err(anyhow!(\"Chomp core extensions must be versioned - try \\x1b[36m'chomp@0.1:{}'\\x1b[0m instead\", &extensions[i][6..]));\n        }\n        let ext = if extensions[i].starts_with(\"chomp@0.1:\") {\n            let mut s: String = match global_env.get(\"CHOMP_CORE\") {\n                Some(path) => String::from(path),\n                None => String::from(CHOMP_CORE),\n            };\n            if !s.ends_with(\"/\") && !s.ends_with(\"\\\\\") {\n                s.push('/');\n            }\n            s.push_str(&extensions[i][10..]);\n            s.push_str(\".js\");\n            s\n        } else {\n            extensions[i].clone()\n        };\n        let (canonical, extension_source) = match uri_parse(ext.as_ref()) {\n            Some(uri) => {\n                if !extension_set.contains(&ext) {\n                    extension_set.insert(ext.to_string());\n                    (\n                        extension_set.get(&ext).unwrap(),\n                        Some(http_client::fetch_uri_cached(&ext, uri).await?),\n                    )\n                } else {\n                    (extension_set.get(&ext).unwrap(), None)\n                }\n            }\n            None => {\n                let canonical_str: String = match canonicalize(&ext) {\n                    Ok(canonical) => canonical.to_str().unwrap().replace(\"\\\\\", \"/\"),\n                    Err(_) => {\n                        return Err(anyhow!(\"Unable to read extension file '{}'.\", &ext));\n                    }\n                };\n                if !extension_set.contains(&canonical_str) {\n                    extension_set.insert(canonical_str.to_string());\n                    (\n                        extension_set.get(&canonical_str).unwrap(),\n                        Some(fs::read_to_string(&ext)?),\n                    )\n                } else {\n                    (extension_set.get(&canonical_str).unwrap(), None)\n                }\n            }\n        };\n        if let Some(extension_source) = extension_source {\n            if let Some(mut new_includes) = extension_env.add_extension(&extension_source, canonical)? {\n                for ext in new_includes.drain(..) {\n                    // relative includes are relative to the parent\n                    if let Some(rest) = ext.strip_prefix(\"./\") {\n                        let mut resolved_str =\n                            canonical[0..canonical.rfind(\"/\").unwrap() + 1].to_string();\n                        resolved_str.push_str(rest);\n                        extensions.push(resolved_str);\n                    } else {\n                        extensions.push(ext);\n                    }\n                }\n            }\n        }\n        i += 1;\n    }\n    extension_env.seal_extensions();\n\n    // channel for watch events\n    let (watch_event_sender, watch_event_receiver) = unbounded_channel();\n    // channel for adding new files to watcher\n    let (watch_sender, watch_receiver) = unbounded_channel();\n    let mut serve_options = chompfile.server.clone();\n    {\n        if let Some(root) = matches.get_one::<String>(\"server-root\") {\n            serve_options.root = root.to_string();\n        }\n        if let Some(port) = matches.get_one::<String>(\"port\") {\n            serve_options.port = port.parse::<u16>().unwrap();\n        }\n        if matches.get_flag(\"serve\") {\n            use_default_target = false;\n            tokio::spawn(server::serve(\n                serve_options,\n                watch_event_receiver,\n                watch_sender,\n            ));\n        }\n    }\n\n    let mut args: Vec<String> = Vec::new();\n    if let Some(arg) = matches.get_many::<String>(\"arg\") {\n        for item in arg {\n            args.push(item.to_string());\n        }\n    }\n\n    if matches.get_flag(\"import_scripts\") {\n        if matches.get_flag(\"eject_templates\") {\n            return Err(anyhow!(\n                \"Cannot use --import-scripts and --eject-templates together.\"\n            ));\n        }\n        let mut script_tasks = 0;\n        let pjson_source = match fs::read_to_string(\"package.json\") {\n            Ok(source) => source,\n            Err(_) => {\n                return Err(anyhow!(\n                    \"No package.json to import found in the current project directory.\"\n                ));\n            }\n        };\n\n        let pjson: serde_json::Value = serde_json::from_str(&pjson_source)?;\n        match &pjson[\"scripts\"] {\n            serde_json::Value::Object(scripts) => {\n                for (name, val) in scripts.iter() {\n                    if let serde_json::Value::String(run) = &val {\n                        script_tasks += 1;\n                        let mut task = ChompTaskMaybeTemplated::new();\n                        task.name = Some(name.to_string());\n                        task.run = Some(run.to_string());\n                        chompfile.task.push(task);\n                    }\n                }\n            }\n            _ => return Err(anyhow!(\"Unexpected \\\"scripts\\\" type in package.json.\")),\n        };\n        fs::write(&cfg_file, toml::to_string_pretty(&chompfile)?)?;\n        println!(\n            \"\\x1b[1;32m√\\x1b[0m \\x1b[1m{}\\x1b[0m {}.\",\n            cfg_file.to_str().unwrap(),\n            if created {\n                format!(\n                    \"created with {} package.json script tasks imported\",\n                    script_tasks\n                )\n            } else {\n                format!(\n                    \"updated with {} package.json script tasks imported\",\n                    script_tasks\n                )\n            }\n        );\n        return Ok(());\n    }\n\n    let cwd_str = cwd.to_string_lossy().replace('\\\\', \"/\");\n    let (mut has_templates, mut template_tasks) =\n        expand_template_tasks(&chompfile, &mut extension_env, &cwd_str)?;\n    chompfile.task = Vec::new();\n    for task in extension_env.get_tasks().drain(..) {\n        has_templates = true;\n        chompfile.task.push(task.into());\n    }\n    chompfile.task.append(&mut template_tasks);\n\n    if matches.get_flag(\"list\") {\n        if !targets.is_empty() {\n            return Err(anyhow!(\"--list does not take any arguments.\"));\n        }\n        if matches.get_flag(\"eject_templates\")\n            || matches.get_flag(\"format\")\n            || matches.get_flag(\"init\")\n        {\n            return Err(anyhow!(\n                \"Cannot use --list with --eject-templates, --format or --init.\"\n            ));\n        }\n        for task in &chompfile.task {\n            if let Some(name) = &task.name {\n                let matches_some_target = if !targets.is_empty() {\n                    let mut matches_some_target = false;\n                    for target in &targets {\n                        if name.starts_with(target) {\n                            matches_some_target = true;\n                        }\n                    }\n                    matches_some_target\n                } else {\n                    true\n                };\n                if matches_some_target {\n                    println!(\" \\x1b[1m▪\\x1b[0m {}\", name);\n                }\n            }\n        }\n        return Ok(());\n    }\n\n    if matches.get_flag(\"format\") || matches.get_flag(\"eject_templates\") || matches.get_flag(\"init\")\n    {\n        use_default_target = false;\n        if matches.get_flag(\"eject_templates\") {\n            if !has_templates {\n                return Err(anyhow!(\n                    \"\\x1b[1m{}\\x1b[0m has no templates to eject\",\n                    cfg_file.to_str().unwrap()\n                ));\n            }\n            chompfile.extensions = Vec::new();\n            chompfile.template_options = HashMap::new();\n        }\n\n        fs::write(&cfg_file, toml::to_string_pretty(&chompfile)?)?;\n        if matches.get_flag(\"eject_templates\") {\n            println!(\n                \"\\x1b[1;32m√\\x1b[0m \\x1b[1m{}\\x1b[0m template tasks ejected.\",\n                cfg_file.to_str().unwrap()\n            );\n        } else {\n            println!(\n                \"\\x1b[1;32m√\\x1b[0m \\x1b[1m{}\\x1b[0m {}.\",\n                cfg_file.to_str().unwrap(),\n                if created { \"created\" } else { \"updated\" }\n            );\n        }\n        if matches.get_flag(\"eject_templates\") || targets.is_empty() {\n            return Ok(());\n        }\n    }\n\n    let targets = if targets.is_empty() && use_default_target {\n        vec![chompfile\n            .default_task\n            .to_owned()\n            .unwrap_or(String::from(\"build\"))]\n    } else {\n        targets\n    };\n\n    let mut runner = Runner::new(\n        &chompfile,\n        &mut extension_env,\n        pool_size,\n        matches.get_flag(\"serve\") || matches.get_flag(\"watch\"),\n    )?;\n    let ok = runner\n        .run(\n            task::RunOptions {\n                watch: matches.get_flag(\"serve\") || matches.get_flag(\"watch\"),\n                force: matches.get_flag(\"force\"),\n                rerun: matches.get_flag(\"rerun\"),\n                args: if !args.is_empty() { Some(args) } else { None },\n                pool_size,\n                targets,\n                cfg_file,\n            },\n            watch_event_sender,\n            watch_receiver,\n        )\n        .await?;\n\n    if !ok {\n        eprintln!(\"Unable to complete all tasks.\");\n    }\n\n    std::process::exit(if ok { 0 } else { 1 });\n}\n"
  },
  {
    "path": "src/server.rs",
    "content": "// Chomp Task Runner\n// Copyright (C) 2022  Guy Bedford\n\n// This program is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n\n// This program is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n// GNU General Public License for more details.\n\n// You should have received a copy of the GNU General Public License\n// along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\n// const websocket = new WebSocket('ws://localhost:5776/watch'); websocket.onmessage = evt => console.log(evt.data);\n\nuse crate::chompfile::ServerOptions;\nuse crate::task::WatchEvent;\nuse bytes::Bytes;\nuse futures::{future, FutureExt, StreamExt};\nuse hyper::http::{header, Response, StatusCode};\nuse percent_encoding::percent_decode_str;\nuse std::collections::BTreeMap;\nuse std::path::PathBuf;\nuse std::sync::Arc;\nuse tokio::fs;\nuse tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};\nuse tokio::sync::{mpsc, RwLock};\nuse tokio_stream::wrappers::UnboundedReceiverStream;\nuse warp::ws::{Message, WebSocket, Ws};\nuse warp::Filter;\n\ntype ResponseBody = Bytes;\n\nasync fn client_connection(ws: WebSocket, state: State) {\n    let (sender, mut receiver) = ws.split();\n    let (client_sender, client_rcv) = mpsc::unbounded_channel();\n    let client_rcv = UnboundedReceiverStream::new(client_rcv);\n    tokio::task::spawn(client_rcv.forward(sender).map(|result| {\n        if let Err(e) = result {\n            eprintln!(\"error sending websocket msg: {}\", e);\n        }\n    }));\n    client_sender.send(Ok(Message::text(\"Connected\"))).unwrap();\n    let id = {\n        let clients_vec = &mut state.write().await.clients;\n        let id = if !clients_vec.is_empty() {\n            clients_vec.last().unwrap().id + 1\n        } else {\n            1\n        };\n        let client = Client {\n            sender: client_sender,\n            id,\n        };\n        clients_vec.push(client);\n        id\n    };\n    while let Some(body) = receiver.next().await {\n        let message = match body {\n            Ok(msg) => msg,\n            Err(e) => {\n                eprintln!(\"error reading message on websocket: {}\", e);\n                break;\n            }\n        };\n        match message.to_str() {\n            Ok(msg) => {\n                println!(\"got message {}\", msg);\n            }\n            _ => {\n                // println!(\"got non string message\");\n            }\n        }\n    }\n    {\n        let clients_vec = &mut state.write().await.clients;\n        let idx = clients_vec\n            .iter()\n            .enumerate()\n            .find(|(_, client)| client.id == id)\n            .unwrap()\n            .0;\n        clients_vec.remove(idx);\n    }\n}\n\npub struct Client {\n    sender: mpsc::UnboundedSender<std::result::Result<Message, warp::Error>>,\n    id: u32,\n}\n\npub struct StateStruct {\n    clients: Vec<Client>,\n    file_hashes: BTreeMap<String, String>,\n}\n\nimpl StateStruct {\n    fn new() -> StateStruct {\n        StateStruct {\n            clients: Vec::new(),\n            file_hashes: BTreeMap::new(),\n        }\n    }\n}\n\npub type State = Arc<RwLock<StateStruct>>;\n\npub enum FileEvent {\n    WatchFile(PathBuf),\n}\n\nasync fn check_watcher(mut rx: UnboundedReceiver<WatchEvent>, root: &PathBuf, state: State) {\n    loop {\n        if let Some(path) = rx.recv().await {\n            let path_str = match path.strip_prefix(root) {\n                Ok(path) => path.to_str().unwrap(),\n                Err(_) => continue,\n            };\n            let _ = revalidate(&path, path_str, state.clone(), true).await;\n        }\n    }\n}\n\nasync fn revalidate(\n    path: &PathBuf,\n    path_str: &str,\n    state: State,\n    broadcast_updates: bool,\n) -> (Option<String>, bool) {\n    let source = match fs::read(path).await {\n        Ok(src) => src,\n        Err(_) => return (None, true),\n    };\n    let hash = crate::http_client::hash(&source[0..]);\n    let mut state = state.write().await;\n    if let Some(existing_hash) = state.file_hashes.get(path_str) {\n        if hash.eq(existing_hash) {\n            return (Some(hash), false);\n        }\n    }\n    state\n        .file_hashes\n        .insert(path_str.to_string(), hash.to_string());\n    if broadcast_updates {\n        for client in state.clients.iter() {\n            client\n                .sender\n                .send(Ok(Message::text(path_str.replace('\\\\', \"/\"))))\n                .expect(\"error sending websocket\");\n        }\n    }\n    (Some(hash), true)\n}\n\nfn not_found(resource: &str) -> Response<ResponseBody> {\n    Response::builder()\n        .status(StatusCode::NOT_FOUND)\n        .header(\n            header::CONTENT_TYPE,\n            header::HeaderValue::from_str(\"text/plain\").unwrap(),\n        )\n        .body(Bytes::from(format!(\"\\\"{}\\\" Not Found\", resource)))\n        .unwrap()\n}\n\nasync fn file_serve(path: &PathBuf, root: &PathBuf, hash: Option<String>) -> Response<ResponseBody> {\n    if let Ok(contents) = fs::read(path).await {\n        let mut res = Response::new(Bytes::from(contents));\n        let guess = mime_guess::from_path(path);\n        if let Some(mime) = guess.first() {\n            let headers_mut = res.headers_mut();\n            headers_mut.insert(\n                header::CONTENT_TYPE,\n                header::HeaderValue::from_str(mime.essence_str()).unwrap(),\n            );\n            headers_mut.insert(\n                header::ETAG,\n                header::HeaderValue::from_str(&hash.unwrap()).unwrap(),\n            );\n            headers_mut.insert(\n                header::CACHE_CONTROL,\n                header::HeaderValue::from_str(\"must-revalidate\").unwrap(),\n            );\n        }\n        return res;\n    }\n    not_found(\n        &path\n            .strip_prefix(root)\n            .expect(\"unexpected path\")\n            .to_str()\n            .unwrap()\n            .replace('\\\\', \"/\"),\n    )\n}\n\n// TODO: gloss\nasync fn index_page(path: &mut PathBuf, root: &PathBuf) -> Option<Response<ResponseBody>> {\n    path.push(\"index.html\");\n    match fs::metadata(&path).await {\n        Ok(_) => {}\n        Err(_) => {\n            path.pop();\n            let mut entries = std::fs::read_dir(&path)\n                .unwrap()\n                .map(|res| res.map(|e| e.path()))\n                .collect::<Result<Vec<_>, std::io::Error>>()\n                .unwrap();\n            entries.sort();\n            let mut listing = String::from(\"<!doctype html><body><ul>\");\n            for entry in entries {\n                let name = entry\n                    .strip_prefix(&path)\n                    .unwrap()\n                    .to_string_lossy()\n                    .replace('\\\\', \"/\");\n                let relpath = entry\n                    .strip_prefix(root)\n                    .unwrap()\n                    .to_string_lossy()\n                    .replace('\\\\', \"/\");\n                let item = format!(\"<li><a href=\\\"{}\\\">{}</a></li>\", relpath, name);\n                listing.push_str(&item);\n            }\n            listing.push_str(\"</ul>\");\n            let mut res = Response::new(Bytes::from(listing));\n            *res.status_mut() = StatusCode::OK;\n            res.headers_mut().insert(\n                header::CONTENT_TYPE,\n                header::HeaderValue::from_str(\"text/html\").unwrap(),\n            );\n            return Some(res);\n        }\n    };\n    None\n}\n\npub async fn serve(\n    opts: ServerOptions,\n    watch_receiver: UnboundedReceiver<WatchEvent>,\n    watch_sender: UnboundedSender<FileEvent>,\n) {\n    let state: State = Arc::new(RwLock::new(StateStruct::new()));\n    let watcher_state = state.clone();\n    let state_clone = state.clone();\n    let root = match fs::canonicalize(&opts.root).await {\n        Ok(canonical) => canonical,\n        Err(_) => {\n            eprintln!(\"Unable to find the root server path {}\", &opts.root);\n            return;\n        }\n    };\n    let root_str = root.to_str().unwrap();\n    let root = if let Some(rest) = root_str.strip_prefix(r\"\\\\?\\\") {\n        PathBuf::from(String::from(rest))\n    } else {\n        root\n    };\n    let watcher_root = root.clone();\n    let static_assets = warp::path::tail()\n        .and(warp::any().map(move || root.clone()))\n        .and(warp::any().map(move || state.clone()))\n        .and(warp::any().map(move || watch_sender.clone()))\n        .and(warp::filters::header::optional::<String>(\"if-none-match\"))\n        .then(\n            |path: warp::path::Tail,\n             root: PathBuf,\n             state: State,\n             sender: UnboundedSender<FileEvent>,\n             validate_hash: Option<String>| async move {\n                let subpath = percent_decode_str(path.as_str())\n                    .decode_utf8_lossy()\n                    .into_owned();\n                let mut path = PathBuf::from(&root);\n                path.push(&subpath);\n\n                let is_dir = match fs::metadata(&path).await {\n                    Ok(metadata) => metadata.is_dir(),\n                    Err(_) => {\n                        if !path.ends_with(\".html\") {\n                            path.set_extension(\"html\");\n                            match fs::metadata(&path).await {\n                                Ok(metadata) => metadata.is_dir(),\n                                Err(_) => false,\n                            }\n                        } else {\n                            false\n                        }\n                    }\n                };\n                if is_dir {\n                    if let Some(res) = index_page(&mut path, &root).await {\n                        return res;\n                    }\n                }\n                let (hash, add_watch) = revalidate(&path, &subpath, state, false).await;\n                if add_watch {\n                    let _ = sender.send(FileEvent::WatchFile(path.clone())).is_ok();\n                }\n                let (cached, etag) = match hash {\n                    Some(hash) => match validate_hash {\n                        Some(validate_hash) => (validate_hash == hash, Some(hash)),\n                        None => (false, Some(hash)),\n                    },\n                    None => (false, None),\n                };\n                if cached {\n                    let mut res = Response::new(Bytes::new());\n                    *res.status_mut() = StatusCode::NOT_MODIFIED;\n                    res\n                } else {\n                    file_serve(&path, &root, etag).await\n                }\n            },\n        );\n\n    let websocket = warp::path(\"watch\")\n        .and(warp::ws())\n        .and(warp::any().map(move || state_clone.clone()))\n        .map(|ws: Ws, state: State| ws.on_upgrade(move |socket| client_connection(socket, state)));\n\n    let routes = websocket\n        .or(static_assets)\n        .with(warp::cors().allow_any_origin())\n        .boxed();\n\n    println!(\n        \"Serving \\x1b[1m{}\\x1b[0m on \\x1b[36mhttp://localhost:{}\\x1b[0m...\",\n        opts.root, opts.port\n    );\n    future::join(\n        check_watcher(watch_receiver, &watcher_root, watcher_state),\n        warp::serve(routes).run(([127, 0, 0, 1], opts.port)),\n    )\n    .await;\n}\n"
  },
  {
    "path": "src/task.rs",
    "content": "// Chomp Task Runner\n// Copyright (C) 2022  Guy Bedford\n\n// This program is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n\n// This program is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n// GNU General Public License for more details.\n\n// You should have received a copy of the GNU General Public License\n// along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nuse crate::chompfile::{\n    resolve_path, ChompTaskMaybeTemplated, Chompfile, InvalidationCheck, TaskDisplay,\n    ValidationCheck, WatchInvalidation,\n};\nuse crate::engines::CmdPool;\nuse crate::server::FileEvent;\nuse crate::ExtensionEnvironment;\nuse async_recursion::async_recursion;\nuse capturing_glob::{glob, Pattern};\nuse futures::future::Shared;\nuse futures::future::{select_all, Future, FutureExt};\nuse notify_debouncer_mini::{new_debouncer, DebounceEventResult};\nuse pathdiff::diff_paths;\nuse std::collections::BTreeMap;\nuse std::collections::HashMap;\nuse std::collections::HashSet;\nuse std::env::current_dir;\nuse std::fs::canonicalize;\nuse std::io::ErrorKind::NotFound;\nuse std::io::Write;\nuse std::path::Path;\nuse std::path::PathBuf;\nuse std::pin::Pin;\nuse std::sync::mpsc::{Receiver, TryRecvError};\nuse std::time::{Duration, SystemTime, UNIX_EPOCH};\nuse tokio::sync::mpsc::UnboundedReceiver;\nuse tokio::sync::mpsc::UnboundedSender;\nextern crate notify;\n\n// Path-only event from the file watcher. The notify-debouncer-mini debouncer collapses\n// rapid filesystem events down to a single per-path notification, so the kind is irrelevant\n// to chomp — we just need to know which path changed.\npub type WatchEvent = PathBuf;\n\nuse crate::engines::replace_env_vars_static;\nuse crate::engines::ExecState;\nuse anyhow::{anyhow, Result};\nuse derivative::Derivative;\nuse futures::executor;\nuse notify::{RecursiveMode, Watcher};\nuse std::sync::mpsc::channel;\nuse tokio::fs;\nuse tokio::time;\n\n#[derive(Debug)]\npub struct Task<'a> {\n    name: Option<String>,\n    targets: Vec<String>,\n    deps: Vec<String>,\n    env: BTreeMap<String, String>,\n    chomp_task: &'a ChompTaskMaybeTemplated,\n}\n\n#[allow(dead_code)]\npub struct RunOptions {\n    pub args: Option<Vec<String>>,\n    pub cfg_file: PathBuf,\n    pub pool_size: usize,\n    pub targets: Vec<String>,\n    pub watch: bool,\n    pub rerun: bool,\n    pub force: bool,\n}\n\n#[derive(Hash, Eq, PartialEq, Debug, Clone, Copy)]\nenum JobState {\n    Sentinel,\n    Uninitialized,\n    Initialized,\n    Checking,\n    Pending,\n    Running,\n    Fresh,\n    Failed,\n}\n\n#[derive(Derivative)]\n#[derivative(Debug)]\nstruct Job {\n    interpolate: Option<String>,\n    task: usize,\n    deps: Vec<usize>,\n    parents: Vec<usize>,\n    live: bool,\n    state: JobState,\n    mtime: Option<Duration>,\n    #[derivative(Debug = \"ignore\")]\n    mtime_future: Option<Shared<Pin<Box<dyn Future<Output = Option<Duration>>>>>>,\n    targets: Vec<String>,\n    cmd_num: Option<usize>,\n}\n\n#[derive(Debug)]\nenum Node {\n    Job(Job),\n    File(File),\n}\n\n#[derive(Hash, Eq, PartialEq, Debug, Clone)]\nenum FileState {\n    Uninitialized,\n    Initialized,\n    Checking,\n    Found,\n    NotFound,\n}\n\n#[derive(Derivative)]\n#[derivative(Debug)]\nstruct File {\n    name: String,\n    parents: Vec<usize>,\n    state: FileState,\n    mtime: Option<Duration>,\n    #[derivative(Debug = \"ignore\")]\n    mtime_future: Option<Shared<Pin<Box<dyn Future<Output = Option<Duration>>>>>>,\n}\n\nimpl File {\n    fn new(name: String) -> File {\n        File {\n            name,\n            mtime: None,\n            parents: Vec::new(),\n            state: FileState::Uninitialized,\n            mtime_future: None,\n        }\n    }\n\n    fn init(&mut self, watcher: Option<&mut dyn Watcher>) {\n        self.state = FileState::Initialized;\n        if let Some(watcher) = watcher {\n            #[cfg(target_os = \"windows\")]\n            let name = self.name.replace('/', \"\\\\\");\n            #[cfg(not(target_os = \"windows\"))]\n            let name = &self.name;\n            match watcher.watch(Path::new(&name), RecursiveMode::Recursive) {\n                Ok(_) => {}\n                Err(_) => {\n                    // eprintln!(\"Unable to watch {}\", self.name);\n                }\n            };\n        }\n    }\n}\n\nfn find_interpolate(s: &str) -> Result<Option<(usize, bool)>> {\n    match s.find(\"##\") {\n        Some(idx) => {\n            if s.find('#').unwrap() != idx || s[idx + 2..].find('#').is_some() {\n                return Err(anyhow!(\"Multiple interpolates in '{}' not supported\", s));\n            }\n            Ok(Some((idx, true)))\n        }\n        None => match s.find('#') {\n            Some(idx) => {\n                if s[idx + 1..].find('#').is_some() {\n                    return Err(anyhow!(\"Multiple interpolates in '{}' not supported\", s));\n                }\n                Ok(Some((idx, false)))\n            }\n            None => Ok(None),\n        },\n    }\n}\n\nfn get_interpolate_match(interpolate: &str, path: &str) -> String {\n    let prefix_len = interpolate.find('#').unwrap();\n    let suffix_len = interpolate.len() - interpolate.rfind('#').unwrap() - 1;\n    path[prefix_len..path.len() - suffix_len].to_string()\n}\n\nfn check_interpolate_exclude(task: &Task, path: &str) -> bool {\n    // If the interpolated dependency matches its own task's target glob space, then we exclude it\n    // We can enable further custom ignores here in future\n    if let Some(interpolation_target) = task.targets.iter().find(|&t| t.contains('#')) {\n        let target_glob = if interpolation_target.contains(\"##\") {\n            interpolation_target.replace(\"##\", \"(**/*)\")\n        } else {\n            interpolation_target.replace('#', \"(*)\")\n        };\n        if Pattern::new(&target_glob).unwrap().matches(path) {\n            return true;\n        }\n    }\n    false\n}\n\nfn replace_interpolate(s: &str, replacement: &str) -> String {\n    if let Some((_, double)) = find_interpolate(s).unwrap() {\n        if double {\n            s.replace(\"##\", replacement)\n        } else {\n            s.replace('#', replacement)\n        }\n    } else {\n        String::from(s)\n    }\n}\n\npub struct Runner<'a> {\n    // ui: &'a ChompUI,\n    cwd: String,\n    cmd_pool: CmdPool<'a>,\n    chompfile: &'a Chompfile,\n    watch: bool,\n    tasks: Vec<Task<'a>>,\n\n    nodes: Vec<Node>,\n\n    task_jobs: HashMap<String, usize>,\n    file_nodes: HashMap<String, usize>,\n    interpolate_nodes: Vec<usize>,\n}\n\nimpl<'a> Job {\n    fn new(task: usize, interpolate: Option<String>) -> Job {\n        Job {\n            interpolate,\n            task,\n            deps: Vec::new(),\n            live: false,\n            parents: Vec::new(),\n            state: JobState::Uninitialized,\n            targets: Vec::new(),\n            mtime: None,\n            cmd_num: None,\n            mtime_future: None,\n        }\n    }\n\n    fn display_name(&self, tasks: &[Task<'a>], cwd: &str) -> String {\n        let task = &tasks[self.task];\n        let mut skip_relative_path = true;\n        let name = if let Some(interpolate) = self.interpolate.as_ref() {\n            skip_relative_path = false;\n            if !task.targets.is_empty() {\n                match task.targets.iter().find(|&t| t.contains('#')) {\n                    Some(interpolate_target) => replace_interpolate(interpolate_target, interpolate),\n                    None => replace_interpolate(\n                        task.deps.iter().find(|&d| d.contains('#')).unwrap(),\n                        interpolate,\n                    ),\n                }\n            } else {\n                replace_interpolate(\n                    task.deps.iter().find(|&d| d.contains('#')).unwrap(),\n                    interpolate,\n                )\n            }\n        } else if !self.targets.is_empty() {\n            skip_relative_path = false;\n            self.targets.first().unwrap().to_string()\n        } else if let Some(name) = &task.name {\n            format!(\":{}\", name)\n        } else if let Some(run) = &task.chomp_task.run {\n            run.to_string()\n        } else {\n            format!(\"[task {}]\", self.task)\n        };\n\n        if skip_relative_path {\n            name\n        } else {\n            relative_path(&name, cwd)\n        }\n    }\n}\n\n#[derive(Hash, Eq, PartialEq, Debug, Clone)]\nenum JobOrFileState {\n    Job(JobState),\n    File(FileState),\n}\n\n#[derive(Hash, Eq, PartialEq, Debug, Clone)]\nstruct StateTransition {\n    node_num: usize,\n    cmd_num: Option<usize>,\n    state: JobOrFileState,\n}\n\nimpl StateTransition {\n    fn from_job(node_num: usize, state: JobState, cmd_num: Option<usize>) -> Self {\n        StateTransition {\n            node_num,\n            cmd_num,\n            state: JobOrFileState::Job(state),\n        }\n    }\n    fn from_file(node_num: usize, state: FileState, cmd_num: Option<usize>) -> Self {\n        StateTransition {\n            node_num,\n            cmd_num,\n            state: JobOrFileState::File(state),\n        }\n    }\n}\n\n#[derive(Debug)]\nstruct QueuedStateTransitions {\n    state_transitions: HashSet<StateTransition>,\n}\n\nimpl QueuedStateTransitions {\n    fn new() -> Self {\n        Self {\n            state_transitions: HashSet::new(),\n        }\n    }\n    fn insert_job(\n        &mut self,\n        node_num: usize,\n        state: JobState,\n        cmd_num: Option<usize>,\n    ) -> Option<StateTransition> {\n        let transition = StateTransition::from_job(node_num, state, cmd_num);\n        if self.state_transitions.insert(transition.clone()) {\n            Some(transition)\n        } else {\n            None\n        }\n    }\n    fn insert_file(\n        &mut self,\n        node_num: usize,\n        state: FileState,\n        cmd_num: Option<usize>,\n    ) -> Option<StateTransition> {\n        let transition = StateTransition::from_file(node_num, state, cmd_num);\n        if self.state_transitions.insert(transition.clone()) {\n            Some(transition)\n        } else {\n            None\n        }\n    }\n    fn remove_job(&mut self, node_num: usize, state: JobState, cmd_num: Option<usize>) -> bool {\n        let transition = StateTransition::from_job(node_num, state, cmd_num);\n        self.state_transitions.remove(&transition)\n    }\n}\n\n// None = NotFound\npub async fn check_target_mtimes(targets: Vec<String>, default_latest: bool) -> Option<Duration> {\n    if targets.is_empty() {\n        if default_latest {\n            return Some(now());\n        } else {\n            return None;\n        }\n    }\n    let mut futures = Vec::new();\n    for target in &targets {\n        let target_path = Path::new(target);\n        futures.push(\n            async move {\n                match fs::metadata(target_path).await {\n                    Ok(n) => Some(\n                        n.modified()\n                            .expect(\"No modified implementation\")\n                            .duration_since(UNIX_EPOCH)\n                            .unwrap(),\n                    ),\n                    Err(e) => match e.kind() {\n                        NotFound => None,\n                        _ => panic!(\"Unknown file error\"),\n                    },\n                }\n            }\n            .boxed_local(),\n        );\n    }\n    let mut has_missing = false;\n    let mut last_mtime = None;\n    while !futures.is_empty() {\n        let (mtime, _, new_futures) = select_all(futures).await;\n        futures = new_futures;\n        if mtime.is_none() {\n            has_missing = true;\n            last_mtime = None;\n        } else if !has_missing && mtime > last_mtime {\n            last_mtime = mtime;\n        }\n    }\n    last_mtime\n}\n\nfn has_glob_chars(s: &str) -> bool {\n    s.contains('(') || s.contains('[') || s.contains('?') || s.contains('*')\n}\n\nfn now() -> std::time::Duration {\n    SystemTime::now().duration_since(UNIX_EPOCH).unwrap()\n}\n\n// On Windows, we need to explicitly redefine wanted system-defined\n// env vars since these are specifically promoted to local variables\n// for the powershell exec\n#[cfg(target_os = \"windows\")]\nfn create_task_env(\n    task: &ChompTaskMaybeTemplated,\n    chompfile: &Chompfile,\n    replacements: bool,\n) -> BTreeMap<String, String> {\n    let mut env = BTreeMap::new();\n    for (item, value) in &chompfile.env {\n        env.insert(\n            item.to_uppercase(),\n            if replacements {\n                replace_env_vars_static(value, &env)\n            } else {\n                value.to_string()\n            },\n        );\n    }\n    for (item, value) in &chompfile.env_default {\n        if !env.contains_key(item) {\n            if let Some(val) = std::env::var_os(item) {\n                env.insert(item.to_uppercase(), String::from(val.to_str().unwrap()));\n            } else {\n                env.insert(\n                    item.to_uppercase(),\n                    if replacements {\n                        replace_env_vars_static(value, &env)\n                    } else {\n                        value.to_string()\n                    },\n                );\n            }\n        }\n    }\n    if let Some(ref task_env) = task.env {\n        for (item, value) in task_env {\n            env.insert(\n                item.to_uppercase(),\n                if replacements {\n                    replace_env_vars_static(value, &env)\n                } else {\n                    value.to_string()\n                },\n            );\n        }\n    }\n    if let Some(ref task_env_default) = task.env_default {\n        for (item, value) in task_env_default {\n            if !env.contains_key(item) {\n                if let Some(val) = std::env::var_os(item) {\n                    env.insert(item.to_uppercase(), String::from(val.to_str().unwrap()));\n                } else {\n                    env.insert(\n                        item.to_uppercase(),\n                        if replacements {\n                            replace_env_vars_static(value, &env)\n                        } else {\n                            value.to_string()\n                        },\n                    );\n                }\n            }\n        }\n    }\n    env\n}\n\n#[cfg(not(target_os = \"windows\"))]\nfn create_task_env<'a>(\n    task: &ChompTaskMaybeTemplated,\n    chompfile: &'a Chompfile,\n    replacements: bool,\n) -> BTreeMap<String, String> {\n    let mut env = BTreeMap::new();\n    for (item, value) in &chompfile.env {\n        env.insert(\n            item.to_uppercase(),\n            if replacements {\n                replace_env_vars_static(value, &env)\n            } else {\n                value.to_string()\n            },\n        );\n    }\n    for (item, value) in &chompfile.env_default {\n        if !env.contains_key(item) && std::env::var_os(item).is_none() {\n            env.insert(\n                item.to_uppercase(),\n                if replacements {\n                    replace_env_vars_static(value, &env)\n                } else {\n                    value.to_string()\n                },\n            );\n        }\n    }\n    if let Some(ref task_env) = task.env {\n        for (item, value) in task_env {\n            env.insert(\n                item.to_uppercase(),\n                if replacements {\n                    replace_env_vars_static(value, &env)\n                } else {\n                    value.to_string()\n                },\n            );\n        }\n    }\n    if let Some(ref task_env_default) = task.env_default {\n        for (item, value) in task_env_default {\n            if !env.contains_key(item) && std::env::var_os(item).is_none() {\n                env.insert(\n                    item.to_uppercase(),\n                    if replacements {\n                        replace_env_vars_static(value, &env)\n                    } else {\n                        value.to_string()\n                    },\n                );\n            }\n        }\n    }\n    env\n}\n\nimpl<'a> Runner<'a> {\n    pub fn new(\n        // ui: &'a ChompUI,\n        chompfile: &'a Chompfile,\n        extension_env: &'a mut ExtensionEnvironment,\n        pool_size: usize,\n        watch: bool,\n    ) -> Result<Runner<'a>> {\n        let cwd_buf = current_dir()?;\n        let cwd = cwd_buf.to_str().unwrap().replace('\\\\', \"/\");\n\n        let cmd_pool: CmdPool = CmdPool::new(pool_size, String::from(&cwd), extension_env);\n        let mut runner = Runner {\n            watch,\n            // ui,\n            cwd: String::from(&cwd),\n            cmd_pool,\n            chompfile,\n            nodes: Vec::new(),\n            tasks: Vec::new(),\n            task_jobs: HashMap::new(),\n            file_nodes: HashMap::new(),\n            interpolate_nodes: Vec::new(),\n        };\n\n        for task in &runner.chompfile.task {\n            let targets = task.targets_vec(&cwd)?;\n            let deps = task.deps_vec(chompfile, &cwd)?;\n            let env = create_task_env(task, chompfile, task.env_replace.unwrap_or(true));\n            let task = Task {\n                name: task.name.clone(),\n                targets,\n                deps,\n                chomp_task: task,\n                env,\n            };\n\n            runner.tasks.push(task);\n            runner.add_job(runner.tasks.len() - 1, None)?;\n        }\n\n        Ok(runner)\n    }\n\n    fn add_job(&mut self, task_num: usize, interpolate: Option<String>) -> Result<(usize, bool)> {\n        let num: usize = self.nodes.len();\n        let task = &self.tasks[task_num];\n\n        // A task is interpolation-parameterised if `#` appears anywhere it can fan out:\n        // deps, targets, or name. Previously only deps were checked, so a task like\n        // `name = 'build:#'` with no `#` deps was never registered as an interpolation\n        // root and `chomp build:foo` couldn't reach it (#183).\n        let is_interpolate_target = task.deps.iter().any(|d| d.contains('#'))\n            || task.targets.iter().any(|t| t.contains('#'))\n            || task.name.as_deref().is_some_and(|n| n.contains('#'));\n\n        // map target name\n        if let Some(ref name) = task.name {\n            match &interpolate {\n                None => {\n                    let name = if is_interpolate_target && name.contains('#') {\n                        // interpolates support \"#\" in the name as well\n                        // which is treated as blank for the all case\n                        replace_interpolate(name, \"\")\n                    } else {\n                        name.to_string()\n                    };\n                    self.task_jobs.entry(name).or_insert(num);\n                }\n                Some(interpolate) if name.contains('#') => {\n                    // interpolate individual names only expanded when using \"#\" in the name.\n                    // Reuse an existing job for this (task, interpolate) pair — without this\n                    // dedup, tasks that have an interpolated name but no target to register\n                    // in file_nodes would create a new node on every visit, recursing forever\n                    // when their dep is itself the target of another interpolation task (#183).\n                    let name = replace_interpolate(name, interpolate);\n                    if let Some(&existing) = self.task_jobs.get(&name) {\n                        return Ok((existing, false));\n                    }\n                    self.task_jobs.insert(name, num);\n                }\n                Some(_) => {}\n            }\n        }\n\n        // map interpolation for primary interpolation job\n        if is_interpolate_target && interpolate.is_none() {\n            self.interpolate_nodes.push(num);\n        }\n\n        let mut job = Job::new(task_num, interpolate.clone());\n\n        // map target file as file node\n        let task_targets = task.targets.clone();\n        if !is_interpolate_target || interpolate.is_some() {\n            for target in task_targets.iter() {\n                let file_target = match &interpolate {\n                    Some(interpolate) => {\n                        if !target.contains('#') {\n                            continue;\n                        }\n                        replace_interpolate(target, interpolate)\n                    }\n                    None => target.to_string(),\n                };\n                match self.file_nodes.get(&file_target) {\n                    Some(&target_num) => {\n                        if self.nodes.get(target_num).is_none() {\n                            self.nodes.push(Node::Job(job));\n                            return Ok((num, true));\n                        }\n\n                        match &self.nodes[target_num] {\n                            Node::Job(_) => {\n                                // duplicate job for same file -> first wins (skip)\n                                return Ok((target_num, false));\n                            }\n                            Node::File(file) => {\n                                // replacing previous file node with interpolate job node -> upgrade the attachments\n                                self.file_nodes.insert(file_target, target_num);\n                                let parents = file.parents.clone();\n                                for parent in parents {\n                                    let parent_job = self.get_job_mut(parent).unwrap();\n                                    let idx = parent_job\n                                        .deps\n                                        .iter()\n                                        .enumerate()\n                                        .find(|(_, &d)| d == target_num)\n                                        .unwrap()\n                                        .0;\n                                    parent_job.deps[idx] = target_num;\n                                    job.parents.push(parent);\n                                }\n                            }\n                        }\n                    }\n                    None => {\n                        self.file_nodes.insert(file_target, num);\n                    }\n                }\n            }\n        }\n\n        self.nodes.push(Node::Job(job));\n        Ok((num, true))\n    }\n\n    fn add_file(&mut self, file: String) -> Result<usize> {\n        let file2 = file.to_string();\n        Ok(match self.file_nodes.get(&file2) {\n            Some(&num) => num,\n            None => {\n                let num = self.nodes.len();\n                self.nodes.push(Node::File(File::new(file)));\n                self.file_nodes.insert(file2, num);\n                num\n            }\n        })\n    }\n\n    #[inline]\n    fn get_job(&self, num: usize) -> Option<&Job> {\n        match self.nodes[num] {\n            Node::Job(ref job) => Some(job),\n            _ => None,\n        }\n    }\n\n    #[inline]\n    fn get_job_mut(&mut self, num: usize) -> Option<&mut Job> {\n        match self.nodes[num] {\n            Node::Job(ref mut job) => Some(job),\n            _ => None,\n        }\n    }\n\n    #[inline]\n    fn get_file_mut(&mut self, num: usize) -> Option<&mut File> {\n        match self.nodes[num] {\n            Node::File(ref mut file) => Some(file),\n            _ => None,\n        }\n    }\n\n    fn mark_complete(\n        &mut self,\n        job_num: usize,\n        mtime: Option<Duration>,\n        cmd_time: Option<Duration>,\n        failed: bool,\n    ) {\n        {\n            let job = self.get_job_mut(job_num).unwrap();\n            if let Some(mtime) = mtime {\n                job.mtime = Some(mtime);\n            }\n            job.state = if failed {\n                JobState::Failed\n            } else {\n                JobState::Fresh\n            };\n        }\n        let job = self.get_job(job_num).unwrap();\n        let task = &self.tasks[job.task];\n        if failed\n            || matches!(\n                task.chomp_task.display,\n                Some(TaskDisplay::InitStatus)\n                    | Some(TaskDisplay::StatusOnly)\n                    | Some(TaskDisplay::Dot)\n                    | None\n            )\n            || self.chompfile.echo\n        {\n            let mut name = job.display_name(&self.tasks, &self.cwd);\n            let primary = job.parents.is_empty();\n            if primary {\n                let mut name_bold = String::from(\"\\x1b[1m\");\n                name_bold.push_str(&name);\n                name_bold.push_str(\"\\x1b[0m\");\n                name = name_bold;\n            }\n            if matches!(task.chomp_task.display, Some(TaskDisplay::Dot)) {\n                if failed {\n                    print!(\"\\x1b[1;31m.\\x1b[0m\");\n                } else if mtime.is_some() || cmd_time.is_some() {\n                    print!(\"\\x1b[1;32m.\\x1b[0m\");\n                } else {\n                    print!(\"\\x1b[1m●\\x1b[0m\");\n                }\n                std::io::stdout().flush().unwrap();\n            } else if let Some(cmd_time) = cmd_time {\n                if failed {\n                    println!(\n                        \"\\x1b[1;31mx\\x1b[0m {} \\x1b[34m[{:?}]\\x1b[0m\",\n                        name, cmd_time\n                    );\n                } else {\n                    println!(\n                        \"\\x1b[1;32m√\\x1b[0m {} \\x1b[34m[{:?}]\\x1b[0m\",\n                        name, cmd_time\n                    );\n                }\n            } else {\n                if failed {\n                    println!(\"\\x1b[1;31mx\\x1b[0m {}\", name);\n                } else if mtime.is_some() {\n                    println!(\"\\x1b[1;32m√\\x1b[0m {}\", name);\n                } else if task.deps.is_empty() {\n                    println!(\"\\x1b[1m●\\x1b[0m {} \\x1b[34m[exists]\\x1b[0m\", name);\n                } else {\n                    println!(\"\\x1b[1m●\\x1b[0m {} \\x1b[34m[cached]\\x1b[0m\", name);\n                }\n            }\n        }\n        {\n            let job = self.get_job_mut(job_num).unwrap();\n            job.cmd_num = None;\n        }\n    }\n\n    fn invalidate_job(\n        &mut self,\n        job_num: usize,\n        queued: &mut QueuedStateTransitions,\n        redrives: &mut HashSet<usize>,\n    ) -> Result<()> {\n        let job = self.get_job(job_num).unwrap();\n        let job = match job.state {\n            JobState::Failed | JobState::Fresh => {\n                let task = &self.tasks[job.task];\n                if matches!(\n                    task.chomp_task.watch_invalidation,\n                    Some(WatchInvalidation::SkipRunning)\n                ) {\n                    if let Some(mtime) = job.mtime {\n                        if mtime > now() - Duration::from_secs(1) {\n                            return Ok(());\n                        }\n                    }\n                }\n                let job = self.get_job_mut(job_num).unwrap();\n                job.state = JobState::Pending;\n                job\n            }\n            JobState::Running => {\n                if let Some(cmd_num) = job.cmd_num {\n                    // Could possibly consider a JobState::MaybeTerminate\n                    // as a kind of Pending analog which may or may not rerun\n                    queued.remove_job(job_num, JobState::Running, Some(cmd_num));\n                    let display_name = job.display_name(&self.tasks, &self.cwd);\n                    let task = &self.tasks[job.task];\n                    if matches!(\n                        task.chomp_task.watch_invalidation,\n                        Some(WatchInvalidation::SkipRunning)\n                    ) {\n                        let job = self.get_job_mut(job_num).unwrap();\n                        job.state = JobState::Fresh;\n                        return Ok(());\n                    }\n                    self.cmd_pool.terminate(cmd_num, &display_name);\n                }\n                let job = self.get_job_mut(job_num).unwrap();\n                job.mtime = Some(now() - Duration::from_secs(1));\n                job.state = JobState::Pending;\n                job\n            }\n            _ => self.get_job_mut(job_num).unwrap(),\n        };\n        if !job.parents.is_empty() {\n            for parent in job.parents.clone() {\n                if parent == job_num {\n                    continue;\n                }\n                self.invalidate_job(parent, queued, redrives)?;\n            }\n        }\n        redrives.insert(job_num);\n        Ok(())\n    }\n\n    fn invalidate_path(\n        &mut self,\n        path: &Path,\n        queued: &mut QueuedStateTransitions,\n        redrives: &mut HashSet<usize>,\n    ) -> Result<bool> {\n        let path_str = path.to_string_lossy().replace('\\\\', \"/\");\n        match self.file_nodes.get(&path_str) {\n            Some(&node_num) => match self.nodes[node_num] {\n                Node::Job(_) => {\n                    self.invalidate_job(node_num, queued, redrives)?;\n                    Ok(true)\n                }\n                Node::File(ref mut file) => {\n                    file.mtime = Some(now());\n                    for parent in file.parents.clone() {\n                        self.invalidate_job(parent, queued, redrives)?;\n                    }\n                    Ok(true)\n                }\n            },\n            None => Ok(false),\n        }\n    }\n\n    fn expand_job_deps(&self, job_num: usize, deps: &mut Vec<String>) {\n        let job = self.get_job(job_num).unwrap();\n        for &dep in job.deps.iter() {\n            match &self.nodes[dep] {\n                Node::Job(job) => {\n                    if job.interpolate.is_none() {\n                        let task = &self.tasks[job.task];\n                        let has_interpolation =\n                            task.deps.iter().find(|&d| d.contains('#')).is_some();\n                        if has_interpolation {\n                            self.expand_job_deps(dep, deps);\n                        }\n                    }\n                    for target in job.targets.iter() {\n                        if deps.iter().find(|&dep| dep == target).is_none() {\n                            deps.push(target.to_string());\n                        }\n                    }\n                }\n                Node::File(file) => {\n                    let name = &file.name;\n                    if deps.iter().find(|&dep| dep == name).is_none() {\n                        deps.push(name.to_string());\n                    }\n                }\n            };\n        }\n    }\n\n    fn run_job(\n        &mut self,\n        job_num: usize,\n        force: bool,\n    ) -> Option<(usize, Pin<Box<dyn Future<Output = StateTransition> + 'a>>)> {\n        let job = self.get_job(job_num).unwrap();\n        if job.state != JobState::Pending {\n            panic!(\"Expected pending job\");\n        }\n        let task = &self.tasks[job.task];\n        // CMD Exec\n        if task.chomp_task.run.is_none() {\n            self.mark_complete(job_num, Some(now()), None, false);\n            return None;\n        }\n        // the interpolation template itself is not run\n        if job.interpolate.is_none() {\n            let has_interpolation = task.deps.iter().find(|&d| d.contains('#')).is_some();\n            if has_interpolation {\n                self.mark_complete(job_num, Some(now()), None, false);\n                return None;\n            }\n        }\n        // If we have an mtime, check if we need to do work\n        if let Some(mtime) = job.mtime {\n            let can_skip = task.chomp_task.args.is_none()\n                && match task.chomp_task.invalidation.unwrap_or_default() {\n                    InvalidationCheck::NotFound => true,\n                    InvalidationCheck::Always => {\n                        if !force\n                            && (matches!(\n                                task.chomp_task.display,\n                                Some(TaskDisplay::InitStatus) | Some(TaskDisplay::InitOnly) | None\n                            ) || self.chompfile.echo)\n                        {\n                            println!(\n                                \"  \\x1b[1m{}\\x1b[0m invalidated\",\n                                job.display_name(&self.tasks, &self.cwd),\n                            );\n                        }\n                        false\n                    }\n                    InvalidationCheck::Mtime => {\n                        if force {\n                            false\n                        } else {\n                            let mut dep_change = false;\n                            for &dep in job.deps.iter() {\n                                dep_change = match &self.nodes[dep] {\n                                    Node::Job(dep) => {\n                                        let invalidated = match &self.tasks[dep.task]\n                                            .chomp_task\n                                            .invalidation\n                                            .unwrap_or_default()\n                                        {\n                                            InvalidationCheck::NotFound\n                                            | InvalidationCheck::Always\n                                            | InvalidationCheck::Mtime => match dep.mtime {\n                                                Some(dep_mtime) => dep_mtime > mtime,\n                                                None => true,\n                                            },\n                                        };\n                                        if invalidated\n                                            && (matches!(\n                                                task.chomp_task.display,\n                                                Some(TaskDisplay::InitStatus)\n                                                    | Some(TaskDisplay::InitOnly)\n                                                    | None\n                                            ) || self.chompfile.echo)\n                                        {\n                                            println!(\n                                                \"  \\x1b[1m{}\\x1b[0m invalidated by {}\",\n                                                job.display_name(&self.tasks, &self.cwd),\n                                                dep.display_name(&self.tasks, &self.cwd)\n                                            );\n                                        }\n                                        invalidated\n                                    }\n                                    Node::File(dep) => {\n                                        let invalidated = match dep.mtime {\n                                            Some(dep_mtime) => dep_mtime > mtime,\n                                            None => true,\n                                        };\n                                        if invalidated\n                                            && (matches!(\n                                                task.chomp_task.display,\n                                                Some(TaskDisplay::InitStatus)\n                                                    | Some(TaskDisplay::InitOnly)\n                                                    | None\n                                            ) || self.chompfile.echo)\n                                        {\n                                            println!(\n                                                \"  \\x1b[1m{}\\x1b[0m invalidated by {}\",\n                                                job.display_name(&self.tasks, &self.cwd),\n                                                dep.name\n                                            );\n                                        }\n                                        invalidated\n                                    }\n                                };\n                                if dep_change {\n                                    break;\n                                }\n                            }\n                            !dep_change\n                        }\n                    }\n                };\n            if can_skip {\n                self.mark_complete(job_num, None, None, false);\n                return None;\n            }\n        }\n\n        let run = task.chomp_task.run.as_ref().unwrap();\n        let mut env = task.env.clone();\n        if let Some(interpolate) = &job.interpolate {\n            env.insert(\"MATCH\".to_string(), interpolate.to_string());\n        }\n        let target_index = if job.interpolate.is_some() {\n            match task\n                .targets\n                .iter()\n                .enumerate()\n                .find(|(_, d)| d.contains('#'))\n            {\n                Some(mtch) => mtch.0,\n                None => 0,\n            }\n        } else {\n            0\n        };\n        let target = if task.targets.is_empty() {\n            \"\".to_string()\n        } else if let Some(interpolate) = &job.interpolate {\n            replace_interpolate(&task.targets[target_index], interpolate)\n        } else {\n            task.targets[target_index].clone()\n        };\n\n        let mut targets = String::new();\n        for (idx, t) in task.targets.iter().enumerate() {\n            if idx > 0 {\n                targets.push(':');\n            }\n            if idx == target_index {\n                targets.push_str(&target);\n            } else {\n                targets.push_str(t);\n            }\n        }\n\n        let mut deps: Vec<String> = if let Some(ref interpolate) = job.interpolate {\n            let interpolate_index = task\n                .deps\n                .iter()\n                .enumerate()\n                .find(|(_, d)| d.contains('#'))\n                .unwrap()\n                .0;\n            vec![replace_interpolate(\n                &task.deps[interpolate_index],\n                interpolate,\n            )]\n        } else {\n            vec![]\n        };\n\n        self.expand_job_deps(job_num, &mut deps);\n\n        // relative target for backward compatibility\n        let relative_target = if !target.is_empty() {\n            relative_path(&target, &self.cwd)\n        } else {\n            \"\".to_string()\n        };\n        env.insert(\"TARGET\".to_string(), relative_target.to_owned());\n\n        let relative_targets = if !targets.is_empty() {\n            relative_path(&targets, &self.cwd)\n        } else {\n            \"\".to_string()\n        };\n        env.insert(\"TARGETS\".to_string(), relative_targets);\n\n        // relative dep for backward compatibility\n        let relative_dep = match deps.first() {\n            Some(first_dep) => relative_path(first_dep, &self.cwd),\n            None => String::new(),\n        };\n        env.insert(\"DEP\".to_string(), relative_dep);\n\n        let mut relative_deps = deps\n            .iter()\n            .map(|d| relative_path(d, &self.cwd))\n            .collect::<Vec<String>>();\n        relative_deps.sort();\n        env.insert(\"DEPS\".to_string(), relative_deps.join(\":\"));\n\n        if let Some(args) = task.chomp_task.args.as_ref() {\n            for arg in args {\n                let k = arg.to_uppercase();\n                env.entry(k).or_insert_with(|| String::from(\"\"));\n            }\n        }\n\n        let targets = job.targets.clone();\n        let engine = task.chomp_task.engine.unwrap_or_default();\n        let env_replace = task.chomp_task.env_replace.unwrap_or(true);\n        let echo = if let Some(echo) = task.chomp_task.echo {\n            echo\n        } else {\n            self.chompfile.echo\n        };\n        let cmd_num = {\n            let stdio = task.chomp_task.stdio.unwrap_or_default();\n            let display_name = if matches!(\n                task.chomp_task.display,\n                Some(TaskDisplay::InitStatus) | Some(TaskDisplay::InitOnly) | None\n            ) || echo\n            {\n                Some(job.display_name(&self.tasks, &self.cwd))\n            } else {\n                None\n            };\n            let cwd = match &task.chomp_task.cwd {\n                Some(cwd) => {\n                    let cwd_path = PathBuf::from(cwd);\n                    let cwd = if Path::is_absolute(&cwd_path) {\n                        cwd_path\n                    } else {\n                        let mut base = PathBuf::from(&self.cwd);\n                        base.push(&cwd_path);\n                        base\n                    };\n                    Some(match canonicalize(&cwd) {\n                        Ok(cwd) => {\n                            let cwd = cwd.to_str().unwrap();\n                            if let Some(rest) = cwd.strip_prefix(r\"\\\\?\\\") {\n                                String::from(rest)\n                            } else {\n                                cwd.to_string()\n                            }\n                        }\n                        Err(_) => {\n                            panic!(\"Unable to resolve task CWD {}\", &cwd.to_str().unwrap());\n                        }\n                    })\n                }\n                None => None,\n            };\n            let cmd_num = self.cmd_pool.batch(\n                display_name,\n                run,\n                targets,\n                env,\n                env_replace,\n                cwd,\n                engine,\n                stdio,\n                echo,\n            );\n            let job = self.get_job_mut(job_num).unwrap();\n            job.state = JobState::Running;\n            job.cmd_num = Some(cmd_num);\n            cmd_num\n        };\n        let exec_future = self.cmd_pool.get_exec_future(cmd_num);\n        Some((\n            cmd_num,\n            async move {\n                let _ = exec_future.await;\n                StateTransition::from_job(job_num, JobState::Running, Some(cmd_num))\n            }\n            .boxed_local(),\n        ))\n    }\n\n    // top-down driver - initiates future starts\n    fn drive_all(\n        &mut self,\n        job_num: usize,\n        force: bool,\n        futures: &mut Vec<Pin<Box<dyn Future<Output = StateTransition> + 'a>>>,\n        queued: &mut QueuedStateTransitions,\n        parent: Option<usize>,\n        watch_listener: UnboundedSender<WatchEvent>,\n    ) -> Result<JobOrFileState> {\n        match self.nodes[job_num] {\n            Node::Job(ref mut job) => {\n                if let Some(parent) = parent {\n                    if job.parents.iter().find(|&&p| p == parent).is_none() {\n                        job.parents.push(parent);\n                    }\n                }\n                if parent.is_none() {\n                    if !job.live {\n                        return Ok(JobOrFileState::Job(job.state));\n                    }\n                } else {\n                    job.live = true;\n                }\n                match job.state {\n                    JobState::Sentinel | JobState::Uninitialized => {\n                        panic!(\"Unexpected uninitialized job {}\", job_num);\n                    }\n                    JobState::Initialized => {\n                        let targets = job.targets.clone();\n                        let mtime_future = async { check_target_mtimes(targets, false).await }\n                            .boxed_local()\n                            .shared();\n                        job.mtime_future = Some(mtime_future.clone());\n                        job.state = JobState::Checking;\n                        let transition = queued\n                            .insert_job(job_num, JobState::Checking, None)\n                            .expect(\"Expected first job check\");\n                        futures.push(\n                            async move {\n                                mtime_future.await;\n                                transition\n                            }\n                            .boxed_local(),\n                        );\n                        Ok(JobOrFileState::Job(JobState::Checking))\n                    }\n                    JobState::Checking => {\n                        let job = self.get_job(job_num).unwrap();\n                        if let Some(transition) =\n                            queued.insert_job(job_num, JobState::Checking, None)\n                        {\n                            let mtime_future = job.mtime_future.as_ref().unwrap().clone();\n                            futures.push(\n                                async move {\n                                    mtime_future.await;\n                                    transition\n                                }\n                                .boxed_local(),\n                            );\n                        }\n                        Ok(JobOrFileState::Job(JobState::Checking))\n                    }\n                    JobState::Pending => {\n                        let mut all_completed = true;\n                        let job = self.get_job(job_num).unwrap();\n                        let serial = self.tasks[job.task].chomp_task.serial.unwrap_or_default();\n                        let deps = job.deps.clone();\n\n                        for dep in deps {\n                            // permit self-builds, arbitrary cycles will stall still though\n                            if dep == job_num {\n                                continue;\n                            }\n                            let dep_state = self.drive_all(\n                                dep,\n                                force,\n                                futures,\n                                queued,\n                                Some(job_num),\n                                watch_listener.clone(),\n                            )?;\n                            match dep_state {\n                                JobOrFileState::Job(JobState::Fresh)\n                                | JobOrFileState::File(FileState::Found) => {}\n                                JobOrFileState::Job(JobState::Failed)\n                                | JobOrFileState::File(FileState::NotFound) => {\n                                    self.mark_complete(job_num, None, None, true);\n                                    let transition = queued\n                                        .insert_job(job_num, JobState::Running, None)\n                                        .unwrap();\n                                    self.drive_completion(\n                                        transition,\n                                        force,\n                                        futures,\n                                        queued,\n                                        watch_listener.clone(),\n                                    )?;\n                                    return Ok(JobOrFileState::Job(JobState::Failed));\n                                }\n                                _ => {\n                                    // Serial only proceeds on a completion result\n                                    if serial {\n                                        return Ok(JobOrFileState::Job(JobState::Pending));\n                                    }\n                                    all_completed = false;\n                                }\n                            }\n                        }\n\n                        // we could have driven this job to completion already...\n                        let job = self.get_job(job_num).unwrap();\n                        if job.state != JobState::Pending {\n                            return Ok(JobOrFileState::Job(job.state));\n                        }\n\n                        // deps all completed -> execute this job\n                        if all_completed {\n                            return match self.run_job(job_num, force) {\n                                Some((cmd_num, future)) => {\n                                    if queued.insert_job(\n                                        job_num,\n                                        JobState::Running,\n                                        Some(cmd_num),\n                                    ).is_some() { futures.push(future) };\n                                    Ok(JobOrFileState::Job(JobState::Running))\n                                }\n                                None => {\n                                    let transition = queued\n                                        .insert_job(job_num, JobState::Running, None)\n                                        .unwrap();\n                                    self.drive_completion(\n                                        transition,\n                                        force,\n                                        futures,\n                                        queued,\n                                        watch_listener,\n                                    )?;\n                                    Ok(JobOrFileState::Job(JobState::Fresh))\n                                }\n                            };\n                        }\n                        Ok(JobOrFileState::Job(JobState::Pending))\n                    }\n                    JobState::Running => {\n                        let job = self.get_job(job_num).unwrap();\n                        let cmd_num = job.cmd_num.unwrap();\n                        if let Some(transition) =\n                            queued.insert_job(job_num, JobState::Running, Some(cmd_num))\n                        {\n                            let future = self.cmd_pool.get_exec_future(cmd_num);\n                            futures.push(\n                                async move {\n                                    let _ = future.await;\n                                    transition\n                                }\n                                .boxed_local(),\n                            );\n                        }\n                        Ok(JobOrFileState::Job(JobState::Running))\n                    }\n                    JobState::Failed => Ok(JobOrFileState::Job(JobState::Failed)),\n                    JobState::Fresh => Ok(JobOrFileState::Job(JobState::Fresh)),\n                }\n            }\n            Node::File(ref mut file) => {\n                if let Some(parent) = parent {\n                    if file.parents.iter().find(|&&p| p == parent).is_none() {\n                        file.parents.push(parent);\n                    }\n                }\n                match file.state {\n                    FileState::Uninitialized => panic!(\"Unexpected file state\"),\n                    FileState::Initialized => {\n                        let name = file.name.to_string();\n                        let mtime_future = async move {\n                            match fs::metadata(&name).await {\n                                Ok(n) => {\n                                    let mtime = n.modified().expect(\"No modified implementation\");\n                                    Some(mtime.duration_since(UNIX_EPOCH).unwrap())\n                                }\n                                Err(e) => match e.kind() {\n                                    NotFound => None,\n                                    _ => panic!(\"Unknown file error for '{}': {:?}\", &name, e),\n                                },\n                            }\n                        }\n                        .boxed_local()\n                        .shared();\n                        file.mtime_future = Some(mtime_future.clone());\n                        file.state = FileState::Checking;\n                        let transition = queued\n                            .insert_file(job_num, FileState::Checking, None)\n                            .expect(\"Expected first file check\");\n                        futures.push(\n                            async move {\n                                mtime_future.await;\n                                transition\n                            }\n                            .boxed_local(),\n                        );\n                        Ok(JobOrFileState::File(FileState::Checking))\n                    }\n                    FileState::Checking => {\n                        if let Some(transition) =\n                            queued.insert_file(job_num, FileState::Checking, None)\n                        {\n                            let future = file.mtime_future.as_ref().unwrap().clone();\n                            futures.push(\n                                async move {\n                                    future.await;\n                                    transition\n                                }\n                                .boxed_local(),\n                            );\n                        }\n                        Ok(JobOrFileState::File(FileState::Checking))\n                    }\n                    FileState::Found => Ok(JobOrFileState::File(FileState::Found)),\n                    FileState::NotFound => {\n                        if !self.watch {\n                            Err(anyhow!(\"File {} not found\", file.name))\n                        } else {\n                            panic!(\"Watching files not yet created is not yet supported, in depending on {}. This should be supported, please post an issue on GitHub!\", file.name);\n                        }\n                    }\n                }\n            }\n        }\n    }\n\n    // bottom-up completer - initiates active deferred future starts\n    fn drive_completion(\n        &mut self,\n        transition: StateTransition,\n        force: bool,\n        futures: &mut Vec<Pin<Box<dyn Future<Output = StateTransition> + 'a>>>,\n        queued: &mut QueuedStateTransitions,\n        watch_listener: UnboundedSender<WatchEvent>,\n    ) -> Result<()> {\n        if !queued.state_transitions.remove(&transition) {\n            return Ok(());\n        }\n        // drives the completion of a state transition to subsequent transitions\n        let node_num = transition.node_num;\n        match transition.state {\n            JobOrFileState::Job(JobState::Checking) => {\n                let job = self.get_job_mut(node_num).unwrap();\n                job.state = JobState::Pending;\n                let mtime_future = job.mtime_future.take().unwrap();\n                // we know it's ready so this isn't blocking\n                let mtime = executor::block_on(mtime_future);\n                job.mtime = mtime;\n                job.mtime_future = None;\n                self.drive_all(node_num, force, futures, queued, None, watch_listener)?;\n                Ok(())\n            }\n            JobOrFileState::Job(JobState::Running) => {\n                // job can complete running without an exec if eg cached\n                let job = self.get_job(node_num).unwrap();\n                let validation = self.tasks[job.task]\n                    .chomp_task\n                    .validation\n                    .unwrap_or_default();\n                if let Some(cmd_num) = job.cmd_num {\n                    let exec_future = self.cmd_pool.get_exec_future(cmd_num);\n                    let (status, mtime, cmd_time) = match executor::block_on(exec_future) {\n                        Ok(result) => result,\n                        Err(err) => return Err(anyhow!(\"Exec error: {:?}\", err)),\n                    };\n                    match status {\n                        ExecState::Completed => {\n                            let job = self.get_job(node_num).unwrap();\n                            for target in &job.targets {\n                                let mut path = PathBuf::from(&self.cwd);\n                                #[cfg(not(target_os = \"windows\"))]\n                                path.push(&target);\n                                #[cfg(target_os = \"windows\")]\n                                path.push(target.replace('/', \"\\\\\"));\n                                watch_listener\n                                    .send(path)\n                                    .expect(\"Unable to send watcher event to server channel\");\n                            }\n                            self.mark_complete(\n                                node_num,\n                                mtime,\n                                Some(cmd_time),\n                                matches!(validation, ValidationCheck::NotOk)\n                                    || matches!(\n                                        validation,\n                                        ValidationCheck::TargetsOnly | ValidationCheck::OkTargets\n                                    ) && mtime.is_none(),\n                            );\n                        }\n                        ExecState::Failed => match validation {\n                            ValidationCheck::NotOk => {\n                                self.mark_complete(node_num, mtime, Some(cmd_time), false)\n                            }\n                            ValidationCheck::OkOnly | ValidationCheck::OkTargets => {\n                                self.mark_complete(node_num, mtime, Some(cmd_time), true)\n                            }\n                            ValidationCheck::None | ValidationCheck::TargetsOnly => self\n                                .mark_complete(\n                                    node_num,\n                                    mtime,\n                                    Some(cmd_time),\n                                    matches!(validation, ValidationCheck::TargetsOnly)\n                                        && mtime.is_none(),\n                                ),\n                        },\n                        ExecState::Terminated => return Ok(()),\n                        _ => panic!(\"Unexpected promise exec state\"),\n                    };\n                }\n                let job = self.get_job(node_num).unwrap();\n                if matches!(job.state, JobState::Fresh | JobState::Failed) {\n                    for parent in job.parents.clone() {\n                        self.drive_all(\n                            parent,\n                            force,\n                            futures,\n                            queued,\n                            None,\n                            watch_listener.clone(),\n                        )?;\n                    }\n                }\n                Ok(())\n            }\n            JobOrFileState::File(FileState::Checking) => {\n                let file = match self.nodes[node_num] {\n                    Node::File(ref mut file) => file,\n                    _ => panic!(\"Expected file\"),\n                };\n                let mtime_future = file.mtime_future.take().unwrap();\n                // we know it's ready so this isn't blocking\n                let mtime = executor::block_on(mtime_future);\n                file.mtime = mtime;\n                file.state = match file.mtime {\n                    Some(_mtime) => FileState::Found,\n                    None => FileState::NotFound,\n                };\n                for parent in file.parents.clone() {\n                    self.drive_all(parent, force, futures, queued, None, watch_listener.clone())?;\n                }\n                Ok(())\n            }\n            _ => panic!(\"Unexpected promise transition state\"),\n        }\n    }\n\n    fn lookup_task(&mut self, task: usize) -> Option<usize> {\n        for (id, node) in self.nodes.iter().enumerate() {\n            let job = match node {\n                Node::File(_) => continue,\n                Node::Job(job) => job,\n            };\n            // find the job for the task or interpolation task parent\n            if job.task != task || job.interpolate.is_some() {\n                continue;\n            }\n            return Some(id);\n        }\n        None\n    }\n\n    async fn lookup_task_name(\n        &mut self,\n        watcher: &mut dyn Watcher,\n        task: &str,\n    ) -> Result<Option<usize>> {\n        if let Some(&job_num) = self.task_jobs.get(task) {\n            return Ok(Some(job_num));\n        }\n        // Check for interpolated task names. Most specific (longest lhs+rhs) wins.\n        let mut best: Option<(usize, String, usize)> = None;\n        for &job_num in &self.interpolate_nodes {\n            let job_task = &self.tasks[self.get_job(job_num).unwrap().task];\n            let name = match &job_task.name {\n                Some(n) => n,\n                None => continue,\n            };\n            let (interpolate_idx, double) = match find_interpolate(name)? {\n                Some(v) => v,\n                None => continue,\n            };\n            let lhs = &name[0..interpolate_idx];\n            let rhs = &name[interpolate_idx + if double { 2 } else { 1 }..];\n            if task.starts_with(lhs) && task.len() > lhs.len() + rhs.len() && task.ends_with(rhs) {\n                let value = task[interpolate_idx..task.len() - rhs.len()].to_string();\n                let specificity = lhs.len() + rhs.len();\n                if best.as_ref().is_none_or(|(_, _, s)| specificity > *s) {\n                    best = Some((job_num, value, specificity));\n                }\n            }\n        }\n        if let Some((parent_job, interpolate, _)) = best {\n            let task_num = self.get_job(parent_job).unwrap().task;\n            let input = self.interpolate_dep_input(task_num, &interpolate);\n            let num = self\n                .expand_interpolate_match(watcher, input.as_deref(), &interpolate, parent_job, task_num)\n                .await?;\n            return Ok(Some(num));\n        }\n        Ok(None)\n    }\n\n    fn get_interpolate_target(&self, interpolate_job: usize) -> Option<&String> {\n        self.tasks[self.get_job(interpolate_job).unwrap().task]\n            .targets\n            .iter()\n            .find(|&target| target.contains('#'))\n    }\n\n    // Scan interpolate_nodes for a task whose interpolated target pattern matches `target`.\n    // Returns (parent_interpolate_job, extracted_interpolate_value). When several patterns\n    // match, the most specific (longest lhs+rhs) wins so e.g. `dst/sub/#/file.js` beats\n    // `dst/#/file.js` for a path under `dst/sub/`.\n    fn match_interpolate_target(&self, target: &str) -> Result<Option<(usize, String)>> {\n        let mut best: Option<(usize, String, usize)> = None;\n        for &job_num in &self.interpolate_nodes {\n            let pattern = match self.get_interpolate_target(job_num) {\n                Some(p) => p,\n                None => continue,\n            };\n            let (interpolate_idx, double) = find_interpolate(pattern)?.unwrap();\n            let lhs = &pattern[0..interpolate_idx];\n            let rhs = &pattern[interpolate_idx + if double { 2 } else { 1 }..];\n            if target.starts_with(lhs)\n                && target.len() > lhs.len() + rhs.len()\n                && target.ends_with(rhs)\n            {\n                let value = target[interpolate_idx..target.len() - rhs.len()].to_string();\n                let specificity = lhs.len() + rhs.len();\n                if best.as_ref().is_none_or(|(_, _, s)| specificity > *s) {\n                    best = Some((job_num, value, specificity));\n                }\n            }\n        }\n        Ok(best.map(|(j, v, _)| (j, v)))\n    }\n\n    // Compute the interpolated dep input for an interpolate task. Returns None when the\n    // task has no `#` in its deps (i.e. it interpolates only on name or target), in which\n    // case expand_interpolate_match runs without setting up an interpolated dep.\n    fn interpolate_dep_input(&self, task_num: usize, interpolate: &str) -> Option<String> {\n        self.tasks[task_num]\n            .deps\n            .iter()\n            .find(|d| d.contains('#'))\n            .map(|d| replace_interpolate(d, interpolate))\n    }\n\n    #[async_recursion(?Send)]\n    async fn lookup_target(\n        &mut self,\n        watcher: &mut dyn Watcher,\n        target: &str,\n        glob_files: bool,\n    ) -> Result<usize> {\n        // First match task by name\n        if target.as_bytes()[0] as char == ':' {\n            return match self.lookup_task_name(watcher, &target[1..]).await? {\n                Some(job_num) => Ok(job_num),\n                None => return Err(anyhow!(\"No {} task found.\", target)),\n            };\n        }\n\n        let resolved_target = &resolve_path(target, self.cwd.as_str());\n\n        // Match by exact file name\n        if let Some(&job_num) = self.file_nodes.get(resolved_target) {\n            return Ok(job_num);\n        }\n        // Then by interpolate target\n        if let Some((parent_job, interpolate)) = self.match_interpolate_target(resolved_target)? {\n            let task_num = self.get_job(parent_job).unwrap().task;\n            let input = self.interpolate_dep_input(task_num, &interpolate);\n            return self\n                .expand_interpolate_match(watcher, input.as_deref(), &interpolate, parent_job, task_num)\n                .await;\n        }\n        // Then by task name (covers interpolate task names too via lookup_task_name)\n        if let Some(job_num) = self.lookup_task_name(watcher, target).await? {\n            return Ok(job_num);\n        }\n        // Otherwise add as a file dependency\n        if glob_files {\n            Ok(self.add_file(String::from(target))?)\n        } else {\n            Err(anyhow!(\"No target task '{}' defined in the Chompfile. \\nRun \\x1b[36mchomp --list\\x1b[0m to see the available named targets.\", target))\n        }\n    }\n\n    #[async_recursion(?Send)]\n    async fn lookup_glob_target(\n        &mut self,\n        watcher: &mut dyn Watcher,\n        target: &str,\n        glob_files: bool,\n    ) -> Result<Vec<usize>> {\n        assert!(has_glob_chars(target));\n        let task_pattern = target.as_bytes()[0] as char == ':';\n        let target = if task_pattern { &target[1..] } else { target };\n        let target_pattern = match Pattern::new(target) {\n            Ok(pattern) => pattern,\n            Err(e) => {\n                return Err(anyhow!(\"Unable to parse pattern {}, {}\", target, e.msg));\n            }\n        };\n\n        // Determine non-glob prefix and suffix of the target\n        let mut target_prefix_len = 0;\n        let mut target_suffix_len = 0;\n        while target_prefix_len < target.len() && !has_glob_chars(&target[0..target_prefix_len + 1])\n        {\n            target_prefix_len += 1;\n        }\n        while target_suffix_len < target.len()\n            && !has_glob_chars(&target[target.len() - target_suffix_len - 1..])\n        {\n            target_suffix_len += 1;\n        }\n        let target_prefix = &target[0..target_prefix_len];\n        let target_suffix = &target[target.len() - target_suffix_len..];\n\n        let mut found = Vec::new();\n\n        // iterate tasks comparing them to the glob\n        if task_pattern {\n            // all interpolate tasks with names matching the non-glob prefix and suffix are then _fully_ expanded\n            let mut expansions = Vec::new();\n            for job_num in &self.interpolate_nodes {\n                let job = self.get_job(*job_num).unwrap();\n                let task_num = job.task;\n                let job_task = &self.tasks[task_num];\n                if let Some(name) = &job_task.name {\n                    if let Some((interpolate_idx, double)) = find_interpolate(name)? {\n                        let lhs = &name[0..interpolate_idx];\n                        let rhs = &name[interpolate_idx + if double { 2 } else { 1 }..];\n\n                        let maybe_intersects = if lhs.len() > target_prefix.len() {\n                            lhs.starts_with(target_prefix)\n                        } else {\n                            target_prefix.starts_with(lhs)\n                        } && if rhs.len() > target_suffix.len() {\n                            rhs.ends_with(target_suffix)\n                        } else {\n                            target_suffix.ends_with(rhs)\n                        };\n\n                        if !maybe_intersects {\n                            continue;\n                        }\n\n                        let interpolate_dep =\n                            job_task.deps.iter().find(|&dep| dep.contains('#')).unwrap();\n                        expansions.push(((interpolate_dep.to_owned()), *job_num, task_num));\n                    }\n                }\n            }\n\n            for (dep, job_num, task_num) in expansions.drain(..) {\n                self.expand_interpolate(watcher, dep, job_num, task_num)\n                    .await?;\n            }\n\n            for (task, &job_num) in &self.task_jobs {\n                if target_pattern.matches(task) {\n                    found.push(job_num);\n                }\n            }\n\n            if found.is_empty() {\n                return Err(anyhow!(\n                    \"No task names found matching the pattern {}\",\n                    target\n                ));\n            }\n        } else {\n            let mut globbed_targets: HashSet<String> = HashSet::new();\n\n            // all interpolates which match that non-glob prefix and suffix are then _fully_ expanded\n            let mut expansions = Vec::new();\n            for job_num in &self.interpolate_nodes {\n                if let Some(interpolate) = self.get_interpolate_target(*job_num) {\n                    let (interpolate_idx, double) = find_interpolate(interpolate).unwrap().unwrap();\n                    let lhs = &interpolate[0..interpolate_idx];\n                    let rhs = &interpolate[interpolate_idx + if double { 2 } else { 1 }..];\n\n                    let maybe_intersects = if lhs.len() > target_prefix.len() {\n                        lhs.starts_with(target_prefix)\n                    } else {\n                        target_prefix.starts_with(lhs)\n                    } && if rhs.len() > target_suffix.len() {\n                        rhs.ends_with(target_suffix)\n                    } else {\n                        target_suffix.ends_with(rhs)\n                    };\n\n                    if !maybe_intersects {\n                        continue;\n                    }\n\n                    let job = self.get_job(*job_num).unwrap();\n                    let task_num = job.task;\n                    let interpolate_dep = self.tasks[task_num]\n                        .deps\n                        .iter()\n                        .find(|&dep| dep.contains('#'))\n                        .unwrap();\n                    expansions.push((interpolate_dep.to_owned(), *job_num, task_num));\n                }\n            }\n\n            for (dep, job_num, task_num) in expansions.drain(..) {\n                self.expand_interpolate(watcher, dep, job_num, task_num)\n                    .await?;\n            }\n\n            // this picks up both static file targets and interpolates expanded above\n            for (file, &job_num) in &self.file_nodes {\n                if target_pattern.matches(file) {\n                    found.push(job_num);\n                    globbed_targets.insert(String::from(file));\n                }\n            }\n\n            // finally we do file system globbing, with defined files above overriding file system matches\n            if glob_files {\n                for entry in glob(target).expect(\"Failed to read glob pattern\") {\n                    match entry {\n                        Ok(entry) => {\n                            let dep_path =\n                                String::from(entry.path().to_str().unwrap()).replace('\\\\', \"/\");\n                            if !globbed_targets.contains(&dep_path) {\n                                let job_num = self.add_file(dep_path.to_string())?;\n                                found.push(job_num);\n                                globbed_targets.insert(dep_path);\n                            }\n                        }\n                        Err(e) => {\n                            eprintln!(\"{:?}\", e);\n                            return Err(anyhow!(\"GLOB ERROR\"));\n                        }\n                    }\n                }\n            }\n\n            if found.is_empty() {\n                return Err(anyhow!(\"No files or target paths found matching the pattern '{}'.\\nTo glob task names, use the task prefix character:\\n\\n  \\x1b[36mchomp :{}\\x1b[0m\\n\", target, target));\n            }\n        }\n\n        Ok(found)\n    }\n\n    // DFS cycle check from `root`. Build DAGs must be acyclic to be unrolled, and\n    // drive_all blindly recurses into job.deps with only a self-edge guard, so an\n    // indirect cycle (e.g. two tasks each claiming the same target) blows the stack\n    // at execution time. Detect it once after expansion with a tri-colour DFS so we\n    // surface a clear error instead.\n    fn check_acyclic(&self, root: usize) -> Result<()> {\n        // (node, dep_index) — the dep_index is the next dep to visit.\n        let mut stack: Vec<(usize, usize)> = vec![(root, 0)];\n        let mut on_stack: HashSet<usize> = HashSet::new();\n        let mut done: HashSet<usize> = HashSet::new();\n        on_stack.insert(root);\n        while let Some(&(node, idx)) = stack.last() {\n            let deps: &[usize] = match &self.nodes[node] {\n                Node::Job(j) => &j.deps,\n                Node::File(_) => &[],\n            };\n            if idx >= deps.len() {\n                stack.pop();\n                on_stack.remove(&node);\n                done.insert(node);\n                continue;\n            }\n            let dep = deps[idx];\n            stack.last_mut().unwrap().1 = idx + 1;\n            if dep == node || done.contains(&dep) {\n                continue;\n            }\n            if !on_stack.insert(dep) {\n                let cycle: Vec<String> = stack\n                    .iter()\n                    .skip_while(|(n, _)| *n != dep)\n                    .map(|(n, _)| self.node_display(*n))\n                    .chain(std::iter::once(self.node_display(dep)))\n                    .collect();\n                return Err(anyhow!(\n                    \"Circular dependency detected:\\n  {}\",\n                    cycle.join(\"\\n  → \")\n                ));\n            }\n            stack.push((dep, 0));\n        }\n        Ok(())\n    }\n\n    fn node_display(&self, node: usize) -> String {\n        match &self.nodes[node] {\n            Node::Job(j) => j.display_name(&self.tasks, &self.cwd),\n            Node::File(f) => f.name.clone(),\n        }\n    }\n\n    #[async_recursion(?Send)]\n    async fn expand_target(\n        &mut self,\n        watcher: &mut dyn Watcher,\n        target: &str,\n        glob_files: bool,\n        drives: Option<usize>,\n    ) -> Result<Vec<usize>> {\n        let job_nums = if !has_glob_chars(target) {\n            vec![self.lookup_target(watcher, target, glob_files).await?]\n        } else {\n            self.lookup_glob_target(watcher, target, glob_files).await?\n        };\n        for &job_num in job_nums.iter() {\n            self.expand_job(watcher, job_num, drives).await?;\n        }\n        Ok(job_nums)\n    }\n\n    // expand out the full job graph for the given targets\n    #[async_recursion(?Send)]\n    async fn expand_job(\n        &mut self,\n        watcher: &mut dyn Watcher,\n        job_num: usize,\n        parent: Option<usize>,\n    ) -> Result<()> {\n        if let Some(parent) = parent {\n            let deps = &mut self.get_job_mut(parent).unwrap().deps;\n            if deps.iter().find(|&&d| d == job_num).is_some() {\n                return Ok(());\n            }\n            deps.push(job_num);\n        }\n\n        match self.nodes[job_num] {\n            Node::Job(ref mut job) => {\n                if !matches!(job.state, JobState::Uninitialized) {\n                    if let Some(parent) = parent {\n                        job.parents.push(parent);\n                    }\n                    return Ok(());\n                }\n                let mut is_interpolate = None;\n                let mut double_interpolate = false;\n                let display_name = job.display_name(&self.tasks, &self.cwd);\n\n                let task_num = job.task;\n                let task = &self.tasks[job.task];\n                let mut job_targets = Vec::new();\n                for target in task.targets.iter() {\n                    if has_glob_chars(target) {\n                        return Err(anyhow!(\"Error processing target '{}' in task {} - glob characters are not supported\", &target, &display_name));\n                    }\n                    if target.contains('#') {\n                        if is_interpolate.is_some() {\n                            return Err(anyhow!(\"Error processing target '{}' in task {} - can only have a single interpolation target per task\", &target, &display_name));\n                        }\n                        is_interpolate = Some(target.clone());\n                        double_interpolate = target.contains(\"##\");\n                    }\n                    job_targets.push(target.to_string());\n                }\n                if task.chomp_task.args.is_some() && is_interpolate.is_some() {\n                    return Err(anyhow!(\n                        \"Invalid task {} - cannot apply args to interpolate tasks.\",\n                        &display_name\n                    ));\n                }\n                if is_interpolate.is_none() {\n                    job.targets = job_targets;\n                }\n\n                job.state = JobState::Initialized;\n\n                let mut expanded_interpolate = false;\n                let mut dep_double_interpolate = false;\n                let task_id = job.task;\n                let deps = task.deps.clone();\n                for dep in deps {\n                    if dep.contains('#') {\n                        if has_glob_chars(&dep) {\n                            return Err(anyhow!(\"Error processing dep '{}' in task {} - glob deps are not supported in interpolates\", &dep, &display_name));\n                        }\n                        if expanded_interpolate {\n                            return Err(anyhow!(\"Error processing dep '{}' in task {} - only one interpolated deps is allowed\", &dep, &display_name));\n                        }\n                        dep_double_interpolate = dep.contains(\"##\");\n                        self.expand_interpolate(watcher, dep, job_num, task_num)\n                            .await?;\n                        expanded_interpolate = true;\n                    } else if dep.starts_with('&') {\n                        if dep == \"&next\" {\n                            if task_id + 1 >= self.tasks.len() {\n                                return Err(anyhow!(\n                                    \"No next task to reference for dep '&next' in task {}\",\n                                    &display_name\n                                ));\n                            }\n                            let dep_num = self.lookup_task(task_id + 1).unwrap();\n                            self.expand_job(watcher, dep_num, Some(job_num)).await?;\n                        } else if dep == \"&prev\" {\n                            if task_id == 0 {\n                                return Err(anyhow!(\n                                    \"No previous task to reference for dep '&prev' in task {}\",\n                                    &display_name\n                                ));\n                            }\n                            let dep_num = self.lookup_task(task_id - 1).unwrap();\n                            self.expand_job(watcher, dep_num, Some(job_num)).await?;\n                        } else {\n                            return Err(anyhow!(\n                                \"Invalid task reference '{}' in task {}\",\n                                &dep,\n                                &display_name\n                            ));\n                        }\n                    } else {\n                        self.expand_target(watcher, &dep, true, Some(job_num))\n                            .await?;\n                    }\n                }\n\n                if let Some(target) = is_interpolate {\n                    if !expanded_interpolate {\n                        return Err(anyhow!(\n                            \"Task {} defines an interpolation target {} without an interpolation dep\",\n                            &display_name,\n                            target\n                        ));\n                    }\n                    if dep_double_interpolate != double_interpolate {\n                        return Err(anyhow!(\n                            \"Task {} defines a {} interpolate target {} but with a {} interpolation dep. Dependency interpolation must use a '{}' interpolate to match.\",\n                            &display_name,\n                            if double_interpolate { \"double\" } else { \"single\" },\n                            target,\n                            if double_interpolate { \"single\" } else { \"double\" },\n                            if double_interpolate { \"##\" } else { \"#\" }\n                        ));\n                    }\n                }\n            }\n            Node::File(ref mut file) => {\n                file.init(if self.watch { Some(watcher) } else { None });\n            }\n        }\n        Ok(())\n    }\n\n    async fn expand_interpolate(\n        &mut self,\n        watcher: &mut dyn Watcher,\n        dep: String,\n        parent_job: usize,\n        parent_task: usize,\n    ) -> Result<()> {\n        let (interpolate_idx, double) = find_interpolate(&dep)?.unwrap();\n        let mut glob_target = String::new();\n        glob_target.push_str(&dep[0..interpolate_idx]);\n        if double {\n            if !glob_target.starts_with(\"##\")\n                && !glob_target.ends_with('/')\n                && !glob_target.ends_with('\\\\')\n            {\n                return Err(anyhow!(\"Unable to apply deep globbing to interpolate {}. Deep globbing interpolates are only supported for full paths with '##' immediately following a separator position.\", &dep));\n            }\n            glob_target.push_str(\"(**/*)\");\n        } else {\n            glob_target.push_str(\"(*)\");\n        }\n        glob_target.push_str(&dep[interpolate_idx + if double { 2 } else { 1 }..]);\n        for entry in\n            glob(&glob_target).unwrap_or_else(|_| panic!(\"Failed to read glob pattern {}\", &glob_target))\n        {\n            match entry {\n                Ok(entry) => {\n                    let dep_path = entry.path().to_str().unwrap().replace('\\\\', \"/\");\n                    let interpolate = &dep_path[interpolate_idx\n                        ..dep_path.len() + interpolate_idx + if double { 2 } else { 1 }\n                            - dep.len()];\n\n                    let task = &self.tasks[parent_task];\n                    if check_interpolate_exclude(task, &dep_path) {\n                        return Ok(());\n                    }\n\n                    self.expand_interpolate_match(\n                        watcher,\n                        Some(&dep_path),\n                        interpolate,\n                        parent_job,\n                        parent_task,\n                    )\n                    .await?;\n                }\n                Err(e) => {\n                    eprintln!(\"{:?}\", e);\n                    return Err(anyhow!(\"GLOB ERROR\"));\n                }\n            }\n        }\n        Ok(())\n    }\n\n    #[async_recursion(?Send)]\n    async fn expand_interpolate_match(\n        &mut self,\n        watcher: &mut dyn Watcher,\n        dep_path: Option<&str>,\n        interpolate: &str,\n        parent_job: usize,\n        parent_task: usize,\n    ) -> Result<usize> {\n        let watch = self.watch;\n        let task = &self.tasks[parent_task];\n        let targets = task.targets.clone();\n        let (job_num, new_job) = self.add_job(parent_task, Some(String::from(interpolate)))?;\n\n        // Already defined -> skip\n        if !new_job {\n            return Ok(job_num);\n        }\n\n        if let Some(dep_path) = dep_path {\n            let dep_num = if let Some(&existing) = self.file_nodes.get(dep_path) {\n                match self.nodes[existing] {\n                    Node::File(ref mut file) => file.parents.push(job_num),\n                    Node::Job(ref mut job) => job.parents.push(job_num),\n                }\n                existing\n            } else if let Some((producer_parent, producer_interpolate)) =\n                self.match_interpolate_target(dep_path)?\n            {\n                // dep_path is the interpolated target of another task — drive that task to\n                // produce it instead of treating dep_path as a missing static file (#183).\n                let producer_task = self.get_job(producer_parent).unwrap().task;\n                let producer_input =\n                    self.interpolate_dep_input(producer_task, &producer_interpolate);\n                let producer_job = self\n                    .expand_interpolate_match(\n                        watcher,\n                        producer_input.as_deref(),\n                        &producer_interpolate,\n                        producer_parent,\n                        producer_task,\n                    )\n                    .await?;\n                if let Node::Job(ref mut job) = self.nodes[producer_job] {\n                    if job.parents.iter().find(|&&p| p == job_num).is_none() {\n                        job.parents.push(job_num);\n                    }\n                }\n                producer_job\n            } else {\n                let dep_num = self.add_file(dep_path.to_string())?;\n                let file = self.get_file_mut(dep_num).unwrap();\n                file.parents.push(job_num);\n                file.init(if watch { Some(watcher) } else { None });\n                dep_num\n            };\n\n            let job = self.get_job_mut(job_num).unwrap();\n            job.deps.push(dep_num);\n        }\n        // just because an interpolate is expanded, does not mean it is live\n        let job = self.get_job_mut(job_num).unwrap();\n        job.state = JobState::Initialized;\n\n        let mut expansions = Vec::new();\n\n        for parent_target in targets {\n            let expanded_target = replace_interpolate(&parent_target, interpolate);\n            // If the interpolation target is itself an interpolation source, then drive that\n            // Note: this should also apply to wildcard dependency expansions as well!\n            if parent_target.contains(\"#\") || parent_target.contains(\"##\") {\n                for job_num in &self.interpolate_nodes {\n                    let job = self.get_job(*job_num).unwrap();\n                    let task_num = job.task;\n                    let job_task = &self.tasks[task_num];\n                    if let Some(interpolation_dep) = job_task.deps.iter().find(|&t| t.contains('#'))\n                    {\n                        let dep_glob = if interpolation_dep.contains(\"##\") {\n                            interpolation_dep.replace(\"##\", \"(**/*)\")\n                        } else {\n                            interpolation_dep.replace('#', \"(*)\")\n                        };\n                        if Pattern::new(&dep_glob).unwrap().matches(&expanded_target)\n                            && !check_interpolate_exclude(job_task, &expanded_target) {\n                                let interpolate =\n                                    get_interpolate_match(interpolation_dep, &expanded_target);\n                                let input = replace_interpolate(interpolation_dep, &interpolate);\n                                expansions.push((\n                                    input.to_owned(),\n                                    interpolate,\n                                    *job_num,\n                                    task_num,\n                                ));\n                            }\n                    }\n                }\n                let job = self.get_job_mut(job_num).unwrap();\n                job.targets.push(expanded_target);\n            }\n        }\n        let parent = self.get_job_mut(parent_job).unwrap();\n        parent.deps.push(job_num);\n        for (dep, interpolate, parent_job, parent_task) in expansions.drain(..) {\n            self.expand_interpolate_match(\n                watcher,\n                Some(&dep),\n                &interpolate,\n                parent_job,\n                parent_task,\n            )\n            .await?;\n        }\n\n        // non-interpolation parent interpolation template deps are child deps\n        let parent_task_deps = self.tasks[parent_task].deps.clone();\n        for dep in parent_task_deps {\n            if !dep.contains('#') {\n                self.expand_target(watcher, &dep, true, Some(job_num))\n                    .await?;\n            }\n        }\n\n        Ok(job_num)\n    }\n\n    // find the job for the target, and drive its completion\n    async fn drive_jobs(\n        &mut self,\n        watcher: &mut dyn Watcher,\n        jobs: &HashSet<usize>,\n        force: bool,\n        rx: Receiver<WatchEvent>,\n        watch_listener: UnboundedSender<WatchEvent>,\n        mut writer: UnboundedReceiver<FileEvent>,\n    ) -> Result<()> {\n        let mut futures: Vec<Pin<Box<dyn Future<Output = StateTransition> + 'a>>> = Vec::new();\n\n        let mut queued = QueuedStateTransitions::new();\n\n        // first try named target, then fall back to file name check\n        for &job_num in jobs {\n            // if a job, make it live\n            if let Some(ref mut job) = self.get_job_mut(job_num) {\n                job.live = true;\n            }\n            self.drive_all(\n                job_num,\n                force,\n                &mut futures,\n                &mut queued,\n                None,\n                watch_listener.clone(),\n            )?;\n        }\n        if self.watch {\n            futures.push(Runner::watcher_interval().boxed_local());\n        }\n        while !futures.is_empty() {\n            let (transition, _idx, new_futures) = select_all(futures).await;\n            futures = new_futures;\n            match transition.state {\n                // Sentinel value used to enforce watcher task looping\n                JobOrFileState::Job(JobState::Sentinel) => {\n                    let mut redrives = HashSet::new();\n                    while self\n                        .check_watcher(\n                            watcher,\n                            &rx,\n                            watch_listener.clone(),\n                            &mut writer,\n                            &mut queued,\n                            &mut redrives,\n                        )\n                        .await?\n                    {}\n                    for job_num in redrives {\n                        self.drive_all(\n                            job_num,\n                            false,\n                            &mut futures,\n                            &mut queued,\n                            None,\n                            watch_listener.clone(),\n                        )?;\n                    }\n                    futures.push(Runner::watcher_interval().boxed_local());\n                }\n                _ => {\n                    self.drive_completion(\n                        transition,\n                        force,\n                        &mut futures,\n                        &mut queued,\n                        watch_listener.clone(),\n                    )?;\n                }\n            }\n        }\n        Ok(())\n    }\n\n    async fn watcher_interval() -> StateTransition {\n        time::sleep(Duration::from_millis(50)).await;\n        StateTransition {\n            node_num: 0,\n            cmd_num: None,\n            state: JobOrFileState::Job(JobState::Sentinel),\n        }\n    }\n\n    async fn check_watcher(\n        &mut self,\n        watcher: &mut dyn Watcher,\n        rx: &Receiver<WatchEvent>,\n        watch_listener: UnboundedSender<WatchEvent>,\n        writer: &mut UnboundedReceiver<FileEvent>,\n        queued: &mut QueuedStateTransitions,\n        redrives: &mut HashSet<usize>,\n    ) -> Result<bool> {\n        let mut keep_checking = true;\n        while keep_checking {\n            match writer.try_recv() {\n                Ok(FileEvent::WatchFile(path)) => {\n                    let subpath = path\n                        .strip_prefix(&self.cwd)\n                        .expect(\"Internal error: Invalid path to watch\");\n                    let normalized_target = subpath.to_str().unwrap().replace('\\\\', \"/\");\n                    let jobs = self\n                        .expand_target(watcher, &normalized_target, true, None)\n                        .await?;\n                    for job_num in jobs {\n                        // server watcher can actually create new live jobs\n                        if let Some(ref mut job) = self.get_job_mut(job_num) {\n                            job.live = true;\n                            redrives.insert(job_num);\n                        }\n                    }\n                }\n                Err(tokio::sync::mpsc::error::TryRecvError::Empty) => keep_checking = false,\n                Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {\n                    panic!(\"Server file channel disconnected\")\n                }\n            };\n        }\n        let path = match rx.try_recv() {\n            Ok(path) => path,\n            Err(TryRecvError::Empty) => {\n                return Ok(false);\n            }\n            Err(TryRecvError::Disconnected) => panic!(\"Watcher disconnected\"),\n        };\n        let result = self.invalidate_path(&path, queued, redrives);\n        watch_listener\n            .send(path)\n            .expect(\"Unable to send watcher event to server channel\");\n        result\n    }\n\n    pub async fn run(\n        &mut self,\n        opts: RunOptions,\n        watch_listener: UnboundedSender<WatchEvent>,\n        watch_writer: UnboundedReceiver<FileEvent>,\n    ) -> Result<bool> {\n        let (tx, rx) = channel();\n        let mut debouncer = new_debouncer(\n            Duration::from_millis(250),\n            move |result: DebounceEventResult| match result {\n                Ok(events) => {\n                    for event in events {\n                        let _ = tx.send(event.path);\n                    }\n                }\n                Err(errors) => panic!(\"Watcher errors: {:?}\", errors),\n            },\n        )\n        .unwrap();\n        let mut job_nums = HashSet::new();\n        for target in opts.targets {\n            let jobs = self\n                .expand_target(debouncer.watcher(), &target, false, None)\n                .await?;\n            for job in jobs {\n                if opts.rerun {\n                    let job = self.get_job_mut(job).unwrap();\n                    job.mtime = None;\n                    job.state = JobState::Pending;\n                }\n                job_nums.insert(job);\n            }\n        }\n        for &job_num in &job_nums {\n            self.check_acyclic(job_num)?;\n        }\n        // When running with arguments, mutate the task environment to include the arguments\n        // Arguments tasks cannot be cached\n        if let Some(args) = opts.args {\n            if job_nums.len() > 1 {\n                return Err(anyhow!(\n                    \"Custom args are only supported when running a single command.\"\n                ));\n            }\n            let &job_num = job_nums.iter().next().unwrap();\n            let task_num = self.get_job(job_num).unwrap().task;\n            let task = &mut self.tasks[task_num];\n            let task_args_len = match &task.chomp_task.args {\n                Some(args) => args.len(),\n                None => {\n                    return Err(anyhow!(\n                        \"Task \\x1b[1m{}\\x1b[0m doesn't take any arguments.\",\n                        self.get_job(job_num)\n                            .unwrap()\n                            .display_name(&self.tasks, &self.cwd)\n                    ));\n                }\n            };\n            if task_args_len < args.len() {\n                return Err(anyhow!(\n                    \"Task \\x1b[1m{}\\x1b[0m only takes {} arguments, while {} were provided.\",\n                    self.get_job(job_num)\n                        .unwrap()\n                        .display_name(&self.tasks, &self.cwd),\n                    task_args_len,\n                    args.len()\n                ));\n            }\n            let task_args = task.chomp_task.args.as_ref().unwrap();\n            for (i, arg) in args.iter().enumerate() {\n                task.env.insert(task_args[i].to_uppercase(), arg.clone());\n            }\n        }\n\n        self.drive_jobs(\n            debouncer.watcher(),\n            &job_nums,\n            opts.force,\n            rx,\n            watch_listener,\n            watch_writer,\n        )\n        .await?;\n        // if all jobs completed successfully, exit code is 0, otherwise its an error\n        let mut all_ok = true;\n        for &job_num in job_nums.iter() {\n            let job = self.get_job(job_num).unwrap();\n            if !matches!(job.state, JobState::Fresh) {\n                all_ok = false;\n                break;\n            }\n        }\n\n        Ok(all_ok)\n    }\n}\n\npub fn relative_path(name: &str, cwd: &str) -> String {\n    let path = diff_paths(Path::new(&name), Path::new(cwd))\n        .unwrap()\n        .to_string_lossy()\n        .to_string();\n\n    if path.contains('\\\\') {\n        path.replace('\\\\', \"/\")\n    } else {\n        path\n    }\n}\n"
  },
  {
    "path": "test/chompfile.toml",
    "content": "version = 0.1\nextensions = ['chomp@0.1:assert', 'chomp@0.1:npm']\ndefault-task = 'test'\n\n[env]\nVAL = 'C'\n\n[env-default]\nDEFAULT = '${{ VAL }}H'\n\n[[task]]\nname = 'test'\nserial = true\ndeps = ['test:clean', 'test:run']\n\n[[task]]\nname = 'test:clean'\ndisplay = 'status-only'\nstdio = 'none'\nvalidation = 'none'\nrun = 'rm -r output'\n\n[[task]]\nname = 'test:run'\ndisplay = 'none'\ndeps = [':test[0-9]*']\n\n# -- Test --\n[[task]]\nname = 'test1'\ndisplay = 'none'\nrun = '../target/debug/chomp output/test1.txt'\n\n[[task]]\nname = 'test-chomp-path-arg'\ndisplay = 'none'\ntarget = 'output/test1.txt'\nrun = 'echo \"Chomp Chomp\" > $TARGET'\ntemplate = 'assert'\n[task.template-options]\nexpect-equals = 'Chomp Chomp'\n\n# -- Test --\n[[task]]\nname = 'test2'\ndisplay = 'none'\ntarget = 'output/test2.txt'\nrun = '''\n  ${{ECHO}} \"$VAR $ANOTHER\" > $TARGET\n'''\ntemplate = 'assert'\n[task.env]\nVAR = 'Chomp ${{ DEFAULT }}'\nECHO = 'echo'\n[task.env-default]\nANOTHER = '${{VAR}} ${{UNKNOWN}} ${{ VAR }} ${{--INVALID--}} $NOREPLACE ${{ DEFAULT }}'\n[task.template-options]\nexpect-equals = 'Chomp CH Chomp CH  Chomp CH  $NOREPLACE CH'\n\n# -- Test --\n[[task]]\nname = 'test3'\ndisplay = 'none'\ntarget = 'output/test3.js'\ndeps = ['fixtures/app.js', 'install:swc']\nengine = 'node'\nrun = '''\n  import swc from '@swc/core';\n  import { readFileSync, writeFileSync } from 'fs';\n  import { basename } from 'path';\n\n  const input = readFileSync(process.env.DEP, 'utf8');\n\n  const { code, map } = await swc.transform(input, {\n    filename: process.env.DEP,\n    sourceMaps: true,\n    jsc: {\n      parser: {\n        syntax: \"typescript\",\n      },\n      transform: {},\n    },\n  });\n\n  writeFileSync(process.env.TARGET, code + '\\n//# sourceMappingURL=' + basename(process.env.TARGET) + '.map');\n  writeFileSync(process.env.TARGET + '.map', JSON.stringify(map));\n'''\ntemplate = 'assert'\n[task.template-options]\nexpect-equals = '''export var p = 5;\n\n//# sourceMappingURL=test3.js.map\n'''\n\n[[task]]\nname = 'install:swc'\ndisplay = 'none'\ntemplate = 'npm'\n[task.template-options]\npackages = ['@swc/core', '@swc/cli']\nauto-install = true\n\n# -- Test --\n[[task]]\nname = 'test4'\ndisplay = 'none'\ntargets = ['./output/unittest.txt']\ndeps = [':unittest:ok-*']\ntemplate = 'assert'\n[task.template-options]\nexpect-equals = '''UNIT OK\n'''\n\n[[task]]\ntarget = 'output'\nvalidation = 'targets-only'\nrun = 'mkdir output'\n\n[[task]]\nname = 'unittest:#'\ndisplay = 'none'\nstdio = 'stderr-only'\ndeps = ['./unit/#.mjs', 'output']\nrun = 'node $DEP'\n[task.template-options]\nexpect-equals = '''UNIT OK\n'''\n\n# -- Test --\n[[task]]\nname = 'test5'\nstdio = 'none'\ndisplay = 'none'\ndeps = ['./output/lib/**/*.js', 'install:rollup']\ntarget = 'output/dist/app.js'\nrun = 'rollup output/lib/app.js -d output/dist -m'\ntemplate = 'assert'\n[task.template-options]\nexpect-equals = '''var dep = 'dep';\n\nconsole.log(dep);\nvar p = 5;\n\nexport { p };\n//# sourceMappingURL=app.js.map\n'''\n\n[[task]]\nname = 'install:rollup'\ndisplay = 'none'\ntemplate = 'npm'\n[task.template-options]\npackages = ['rollup']\nauto-install = true\n\n[[task]]\nname = 'build:swc'\ndisplay = 'none'\nstdio = 'stderr-only'\ntarget = './output/lib/##.js'\ndeps = ['./fixtures/src/##.ts', 'install:swc']\nrun = 'swc $DEP -o $TARGET --source-maps'\n\n# -- Test --\n[[task]]\nname = 'test6'\ntarget = './output/deps.txt'\ndeps = ['./fixtures/src/**/*.ts', 'build:swc']\nrun = 'echo \"$DEPS\" > output/deps.txt'\ntemplate = 'assert'\n[task.template-options]\nexpect-pattern = 'fixtures/src/app.ts:fixtures/src/dep.ts:output/lib/app.js:output/lib/dep.js'\n\n# -- Test --\n[[task]]\nname = 'test7'\nvalidation = 'not-ok'\nrun = '''\n  FAIL\n  echo \"THIS SHOULD NOT LOG\"\n'''\n\n# -- Test (#183) --\n# A task whose interpolated dep is the interpolated target of another task should\n# trigger the producer to create the file rather than failing with \"File not found\".\n# A named-only interpolation task (no `#` in deps) should also be reachable as a\n# plain dep reference.\n[[task]]\nname = 'test8'\nserial = true\ndeps = ['t8:clean', 't8:assert']\n\n[[task]]\nname = 't8:clean'\ndisplay = 'none'\nstdio = 'none'\nvalidation = 'none'\nrun = 'rm -r output/dist output/test8-one.txt output/test8-two.txt'\n\n# Each assert task pulls a `t8:build:#` instance by NAME with NO `:` prefix —\n# exercises lookup_task_name resolving an interpolated-name task as a plain dep\n# (the previous behaviour treated it as a missing file).\n[[task]]\nname = 't8:assert'\ndisplay = 'none'\ndeps = ['t8:assert-one', 't8:assert-two']\n\n[[task]]\nname = 't8:assert-one'\ndisplay = 'none'\ntarget = './output/test8-one.txt'\ndeps = ['t8:build:one']\nrun = 'cp output/dist/one/config.yml $TARGET'\ntemplate = 'assert'\n[task.template-options]\nexpect-equals = '''name: one\n'''\n\n[[task]]\nname = 't8:assert-two'\ndisplay = 'none'\ntarget = './output/test8-two.txt'\ndeps = ['t8:build:two']\nrun = 'cp output/dist/two/config.yml $TARGET'\ntemplate = 'assert'\n[task.template-options]\nexpect-equals = '''name: two\n'''\n\n# `t8:build:#` is reached as a plain dep (no `:` prefix) by the assert tasks\n# above. Its interpolated dep is itself the interpolated target of the cp task\n# below — that must trigger the cp task to produce the file rather than erroring\n# with \"File not found\".\n[[task]]\nname = 't8:build:#'\ndisplay = 'none'\ndep = './output/dist/#/config.yml'\n\n[[task]]\ntarget = './output/dist/#/config.yml'\ndep = './fixtures/many/#/config.yml'\nrun = 'cp $DEP $TARGET'\n"
  },
  {
    "path": "test/fixtures/app.js",
    "content": "export var p = 5;\n"
  },
  {
    "path": "test/fixtures/many/one/config.yml",
    "content": "name: one\n"
  },
  {
    "path": "test/fixtures/many/two/config.yml",
    "content": "name: two\n"
  },
  {
    "path": "test/fixtures/src/app.ts",
    "content": "import { dep } from './dep.js';\n\nconsole.log(dep);\n\nexport var p: number = 5;\n"
  },
  {
    "path": "test/fixtures/src/dep.ts",
    "content": "export const dep: string = 'dep';\n"
  },
  {
    "path": "test/unit/ok-node.mjs",
    "content": "import { writeFileSync } from 'fs';\n\nwriteFileSync('output/unittest.txt', 'UNIT OK');\n\nconsole.log('THIS SHOULD NEVER DISPLAY WHEN RUNNING TESTS');\n"
  }
]