diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..2b97c3d --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.{md,yml,json,json5,toml}] +indent_size = 2 + +[*.luau] +indent_style = tab diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..91291e6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,147 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: -D warnings + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + name: Format and lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: Swatinem/rust-cache@v2 + - run: cargo fmt --all --check + - run: cargo clippy --all-targets + + build: + name: Build (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v5 + - uses: Swatinem/rust-cache@v2 + - run: cargo build --release + + docs: + name: Documentation + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: Swatinem/rust-cache@v2 + - uses: astral-sh/setup-uv@v7 + - run: cargo build --release + + # The catalogue lives in the binary. A committed page that disagrees with it is + # worse than no page, so regenerating and diffing is the whole check. + - run: ./target/release/pcmp explain --format markdown > docs/diagnostics.md + - run: git diff --exit-code docs/diagnostics.md + + # `--strict` turns a broken link or a dead anchor into a failure. Most of those + # anchors are the diagnostic codes, so renaming a code without fixing what points at + # it fails here rather than shipping. + - run: uvx zensical build --strict + + behaviour: + name: Behaviour + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: Swatinem/rust-cache@v2 + - run: cargo build --release + + # A fixture in a heredoc rather than in the repository: this exercises the binary + # a user gets, and leaves nothing behind to keep in step with the code. + - name: Build a fixture, lock it, and reproduce it + run: | + set -euxo pipefail + pcmp="$GITHUB_WORKSPACE/target/release/pcmp" + mkdir -p /tmp/fixture/src && cd /tmp/fixture + + cat > src/init.luau <<'LUAU' + --!strict + local VERSION: string = PCMP_VERSION + if DEBUG then print("stripped") end + return { version = VERSION, retries = RETRIES } + LUAU + + cat > pcmp.luau <<'MANIFEST' + return { + vars = { name = "fixture", version = "v1.0.0", retries = 3, built = pcmp.now() }, + profiles = { + release = { + entry = "src/init.luau", + output = "dist/{name}.luau", + define = { DEBUG = false }, + header = { "-- {name} {version} ({built})" }, + darklua = { + generator = "dense", + rules = { + "compute_expression", + "remove_unused_if_branch", + "remove_types", + "remove_comments", + }, + }, + }, + }, + } + MANIFEST + + # This manifest reads the clock and no lock records the answer, which is the + # one thing `--strict` is there to catch. + ! "$pcmp" check --strict + + "$pcmp" build --lock + test -f pcmp.lock + "$pcmp" check --strict + + # The property the whole tool exists for. A different second, a timestamp + # baked into the header, and the artifacts still match the lock. + sleep 1 + "$pcmp" build --frozen + + # The honest inverse, with the clock live again. A manifest that asks the time + # is a different build each second, so this must not report a cache hit. + sleep 1 + "$pcmp" build --json | jq -e '[.tasks[] | select(.status == "cached")] | length == 0' + + # Everything below pins the clock, so each check varies one thing. Without the + # pin, `pcmp.now()` alone would explain every rebuild and prove nothing. + export SOURCE_DATE_EPOCH=1700000000 + + # The pinned instant is now the only one, so the first build settles the cache + # and the second finds nothing to do. + "$pcmp" build + "$pcmp" build --json | jq -e '[.tasks[] | select(.status != "cached")] | length == 0' + + # An artifact edited by hand is noticed, not trusted. Nothing else moved, so a + # rebuild here can only come from the artifact digest. + echo "-- tampered" > dist/fixture.luau + "$pcmp" build --json | jq -e '[.tasks[] | select(.status != "built")] | length == 0' + ! grep -q "tampered" dist/fixture.luau + + audit: + name: Advisories + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + # A prebuilt binary rather than `cargo install`: building cargo-audit from source + # resolves its dependencies to their latest versions, whose MSRV moves ahead of the + # toolchain this repository pins, and the build fails on a dependency nobody chose. + - uses: taiki-e/install-action@v2 + with: + tool: cargo-audit + - run: cargo audit diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml deleted file mode 100644 index 7edb9c1..0000000 --- a/.github/workflows/publish-docs.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: Publish Docs -on: - push: - branches: - - main - workflow_dispatch: - -permissions: - contents: write - -jobs: - deploy: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Configure Git Credentials - run: | - git config user.name github-actions[bot] - git config user.email 41898282+github-actions[bot]@users.noreply.github.com - - - uses: actions/setup-python@v5 - with: - python-version: 3.x - - - name: Set Cache ID - id: cache-id - run: echo "id=$(date --utc '+%V')" >> $GITHUB_OUTPUT - - - uses: actions/cache@v4 - with: - key: mkdocs-material-${{ steps.cache-id.outputs.id }} - path: .cache - restore-keys: | - mkdocs-material- - - - run: pip install mkdocs-material - - run: pip install -r requirements.txt - - run: mkdocs gh-deploy --force diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c8a1434 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,68 @@ +name: Release + +on: + push: + tags: ["v*"] + workflow_dispatch: + +permissions: + contents: write + +jobs: + build: + name: ${{ matrix.target }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - { os: ubuntu-latest, target: x86_64-unknown-linux-gnu, name: linux-x86_64 } + - { os: ubuntu-latest, target: aarch64-unknown-linux-gnu, name: linux-aarch64 } + - { os: macos-latest, target: x86_64-apple-darwin, name: macos-x86_64 } + - { os: macos-latest, target: aarch64-apple-darwin, name: macos-aarch64 } + - { os: windows-latest, target: x86_64-pc-windows-msvc, name: windows-x86_64 } + steps: + - uses: actions/checkout@v5 + - uses: Swatinem/rust-cache@v2 + - run: rustup target add ${{ matrix.target }} + - uses: taiki-e/install-action@v2 + if: matrix.target == 'aarch64-unknown-linux-gnu' + with: + tool: cross + - name: Build + shell: bash + run: | + if [ "${{ matrix.target }}" = "aarch64-unknown-linux-gnu" ]; then + cross build --release --locked --target ${{ matrix.target }} + else + cargo build --release --locked --target ${{ matrix.target }} + fi + - name: Package + shell: bash + run: | + set -eu + staging="pcmp-${{ github.ref_name }}-${{ matrix.name }}" + mkdir -p "$staging" + if [ "${{ matrix.os }}" = "windows-latest" ]; then + cp "target/${{ matrix.target }}/release/pcmp.exe" "$staging/" + else + cp "target/${{ matrix.target }}/release/pcmp" "$staging/" + fi + cp README.md LICENSE "$staging/" + zip -r "$staging.zip" "$staging" + - uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.name }} + path: "*.zip" + + publish: + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + with: + merge-multiple: true + - uses: softprops/action-gh-release@v2 + with: + files: "*.zip" + generate_release_notes: true diff --git a/.gitignore b/.gitignore index 23e11db..74bf607 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ -generated/ -.env -pipeline/.pcmp.local.json \ No newline at end of file +target/ +.pcmp/ +dist/ +site/ +.cache/ diff --git a/.markdownlint.json b/.markdownlint.json deleted file mode 100644 index bb7813a..0000000 --- a/.markdownlint.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "MD033": false, - "MD013": false, - "first-line-h1": false, - "MD029": false -} \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..f23fb1a --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1771 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "borsh" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88b7ea17d208c4193f2c1e6de3c35fe71f98c96982d5ced308bdcc749ff6e1f" +dependencies = [ + "bytes", + "cfg_aliases", +] + +[[package]] +name = "bstr" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "camino" +version = "1.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "clap" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", + "terminal_size", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "const_format" +version = "0.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" +dependencies = [ + "const_format_proc_macros", + "konst", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "ctrlc" +version = "3.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" +dependencies = [ + "dispatch2", + "nix", + "windows-sys 0.61.2", +] + +[[package]] +name = "darklua" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7345394f4d5ea10b0fbe2bf19b32a2a138cd5efe11c6e6b400dae5fafcb07271" +dependencies = [ + "anstyle", + "base64", + "bstr", + "clap", + "ctrlc", + "durationfmt", + "elsa", + "env_logger", + "flate2", + "full_moon", + "indexmap 2.14.0", + "json5", + "log", + "notify", + "notify-debouncer-full", + "num-traits", + "pathdiff", + "petgraph", + "regex", + "serde", + "serde_json", + "serde_yaml", + "toml", + "wax", + "web-time", + "xxhash-rust", + "zstd", +] + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror", +] + +[[package]] +name = "derive_more" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.1", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "durationfmt" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "232860130f75128af6852aef5105ee3813539258f2ce74d0f0ec2dca9b69e22d" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "elsa" +version = "1.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9abf33c656a7256451ebb7d0082c5a471820c31269e49d807c538c252352186e" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "env_filter" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "file-id" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1fc6a637b6dc58414714eddd9170ff187ecb0933d4c7024d1abbd23a3cc26e9" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "full_moon" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a083d6f598bbe9d70e694f2a5cf78f0354678a8c13528da2df6467b089586404" +dependencies = [ + "bytecount", + "cfg-if", + "derive_more", + "full_moon_derive", + "paste", + "serde", + "smol_str", +] + +[[package]] +name = "full_moon_derive" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99b4bd12ce56927d1dc5478d21528ea8c4b93ca85ff8f8043b6a5351a2a3c6f7" +dependencies = [ + "indexmap 1.9.3", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "inotify" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8" +dependencies = [ + "bitflags 2.13.1", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "json5" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "733a844dbd6fef128e98cb4487b887cb55454d92cd9994b1bafe004fabbe670c" +dependencies = [ + "serde", + "ucd-trie", +] + +[[package]] +name = "konst" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" +dependencies = [ + "konst_macro_rules", +] + +[[package]] +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + +[[package]] +name = "kqueue" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.13.1", + "libc", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lua-src" +version = "550.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e836dc8ae16806c9bdcf42003a88da27d163433e3f9684c52f0301258004a4fb" +dependencies = [ + "cc", +] + +[[package]] +name = "luajit-src" +version = "210.6.6+707c12b" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a86cc925d4053d0526ae7f5bc765dbd0d7a5d1a63d43974f4966cb349ca63295" +dependencies = [ + "cc", + "which", +] + +[[package]] +name = "luau0-src" +version = "0.18.3+luau709" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e73f7d752fe2ef0a41f132b83d0be154c6ae0ed84967b4df80a16901866be075" +dependencies = [ + "cc", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mlua" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccd36acfa49ce6ee56d1307a061dd302c564eee757e6e4cd67eb4f7204846fab" +dependencies = [ + "bstr", + "either", + "erased-serde", + "libc", + "mlua-sys", + "num-traits", + "parking_lot", + "rustc-hash", + "rustversion", + "serde", + "serde-value", +] + +[[package]] +name = "mlua-sys" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f1c3a7fc7580227ece249fd90aa2fa3b39eb2b49d3aec5e103b3e85f2c3dfc8" +dependencies = [ + "cc", + "cfg-if", + "libc", + "lua-src", + "luajit-src", + "luau0-src", + "pkg-config", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.13.1", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-debouncer-full" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c02b49179cfebc9932238d04d6079912d26de0379328872846118a0fa0dbb302" +dependencies = [ + "file-id", + "log", + "notify", + "notify-types", + "walkdir", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap 2.14.0", + "serde", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "pori" +version = "0.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a63d338dec139f56dacc692ca63ad35a6be6a797442479b55acd611d79e906" +dependencies = [ + "nom", +] + +[[package]] +name = "portable-atomic" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "procmp" +version = "4.0.0" +dependencies = [ + "blake3", + "camino", + "clap", + "darklua", + "json5", + "mlua", + "notify", + "notify-debouncer-full", + "schemars", + "serde", + "serde_json", + "toml", + "wax", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.3", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "indexmap 2.14.0", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.14.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "smol_str" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523" +dependencies = [ + "borsh", + "serde_core", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "toml" +version = "1.1.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wax" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f8cbf8125142b9b30321ac8721f54c52fbcd6659f76cf863d5e2e38c07a3d7b" +dependencies = [ + "const_format", + "itertools", + "nom", + "pori", + "regex", + "thiserror", + "walkdir", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "which" +version = "8.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f3ef584124b911bcc3875c2f1472e80f24361ceb789bd1c62b3e9a3df9ff43c" +dependencies = [ + "libc", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "xxhash-rust" +version = "0.8.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..18d66a9 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,82 @@ +[package] +name = "procmp" +description = "Multi-target build composition for Luau projects" +version = "4.0.0" +edition = "2024" +rust-version = "1.90" +license = "MIT" +repository = "https://github.com/Proton-Utilities/ProCMP" +keywords = ["luau", "darklua", "bundler", "roblox", "build"] +categories = ["command-line-utilities", "development-tools::build-utils"] +exclude = ["docs/", ".github/", "zensical.toml", "rust-toolchain.toml", "rustfmt.toml"] + +[[bin]] +name = "pcmp" +path = "src/main.rs" + +[dependencies] +blake3 = "1" +camino = { version = "1", features = ["serde1"] } +clap = { version = "4", features = ["derive", "string", "wrap_help"] } +# Pinned exactly, and mirrored by `procmp::DARKLUA`. Bump both. A darklua patch release +# can change emitted bytes, and the version is part of every cache key. +darklua = "=0.19.0" +json5 = "1" +mlua = { version = "0.11", features = ["luau", "vendored", "serialize"] } +notify = "8" +notify-debouncer-full = "0.7" +schemars = "1" +serde = { version = "1", features = ["derive"] } +# `preserve_order` is load-bearing. darklua takes the first matching loader pattern and +# accepts loaders only as a JSON map — its deserialiser has no `visit_seq`, and +# `Configuration::add_loader` cannot be called from outside because its `Loader` parameter +# is not re-exported. So loader order survives only because this map preserves insertion +# order. `plan::canonical` therefore sorts explicitly rather than relying on the map. +serde_json = { version = "1", features = ["preserve_order"] } +toml = "1" +wax = "0.7" + +[profile.release] +lto = "thin" +strip = true +codegen-units = 1 + +# `panic = "abort"` is absent on purpose: mlua unwinds across the C boundary. + +[profile.dev.package."*"] +opt-level = 2 + +[lints.rust] +unsafe_code = "forbid" +missing_debug_implementations = "warn" +# `unreachable_pub` is deliberately absent. There is one crate and no library target, so +# nothing is reachable from outside at all, and the lint would fire on every item that +# crosses a module boundary. The public surface was shrunk by deleting the library, which +# is a stronger guarantee than any spelling of `pub`. + +[lints.clippy] +pedantic = { level = "deny", priority = -1 } +nursery = { level = "deny", priority = -1 } + +# restriction, chosen individually — never as a group +unwrap_used = "deny" +expect_used = "deny" +panic = "deny" +todo = "deny" +unimplemented = "deny" +indexing_slicing = "deny" +dbg_macro = "deny" +# `println!` panics on a closed pipe, which `pcmp plan | head` produces routinely. +# Only `cli::render` writes to a stream, and it carries the one `#[allow]`. +print_stdout = "deny" +print_stderr = "deny" +self_named_module_files = "deny" + +# allowed, each for a stated reason +missing_errors_doc = "allow" # an `# Errors` section restating the return type is noise +missing_panics_doc = "allow" # `panic` and `unwrap_used` are denied, so there are none +must_use_candidate = "allow" # would decorate every accessor +similar_names = "allow" # `diag`/`diags`, `task`/`tasks` are the right names +option_if_let_else = "allow" # regularly produces worse code than the match it replaces +missing_const_for_fn = "allow" # churns on every signature change for no gain here +derive_partial_eq_without_eq = "allow" # serde-facing types diff --git a/LICENSE b/LICENSE index ae4d1ea..9c0c20a 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 Proton Utilities +Copyright (c) 2026 Proton Utilities Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 21bf57d..4576dbc 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,27 @@ -# ProCMP +
Build one Luau source tree into several artifacts at once, from a single manifest.
+ + +--- + +```sh +rokit add Proton-Utilities/ProCMP +pcmp init +pcmp build +``` + +Links in [darklua](https://darklua.com) as a library. ## Documentation -View the documentation to learn how to use ProCMP: +[proton-utilities.github.io/ProCMP](https://proton-utilities.github.io/ProCMP/), or [docs/](docs/) in this repository. + +## License -**[→ Documentation](https://proton-utilities.github.io/ProCMP)** \ No newline at end of file +MIT, see [LICENSE](LICENSE). diff --git a/TODO.md b/TODO.md deleted file mode 100644 index fb6d9f9..0000000 --- a/TODO.md +++ /dev/null @@ -1,10 +0,0 @@ -# TODO - -- __COMPOSER.loadFile and other file system features -- Make readme lest abstract -- bug: build date artifact macro works outside of a build frsme -- bug: pcmp init configs not being semantic/not workng -- refactor: prettify output json files for pcmp init, switch to a file based approach rather than in-code data -- fix the opening files only working for multi-open -- add more macros for useful data like getting current build varaint etc -- add script (luau) support and/or custom command support. Maybe they can use flags for certain build-time events, like pre and post (deploy, build, etc) diff --git a/aftman.toml b/aftman.toml deleted file mode 100644 index af2faaa..0000000 --- a/aftman.toml +++ /dev/null @@ -1,3 +0,0 @@ -[tools] -darklua = "seaofvoices/darklua@0.18.0" -lune = "lune-org/lune@0.10.4" \ No newline at end of file diff --git a/assets/debugPreview.gif b/assets/debugPreview.gif deleted file mode 100644 index 7d4ec26..0000000 Binary files a/assets/debugPreview.gif and /dev/null differ diff --git a/assets/releasePreview.gif b/assets/releasePreview.gif deleted file mode 100644 index 0756926..0000000 Binary files a/assets/releasePreview.gif and /dev/null differ diff --git a/docs/.nav.yml b/docs/.nav.yml deleted file mode 100644 index b710ac8..0000000 --- a/docs/.nav.yml +++ /dev/null @@ -1,11 +0,0 @@ -nav: - - index.md - - Getting Started: - - getting-started.md - - init.md - - Guides: - - local-config.md - - composer-markers.md - - deployment.md - - Reference: - - config-reference.md diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 0000000..bdff583 --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,124 @@ +--- +description: Selecting tasks, reading a build, and what an exit code means. +--- + +# CLI + +`pcmp help` lists every command and flag, and `pcmp explain` describes any diagnostic you see.
+
+## Selecting tasks
+
+```sh
+pcmp build # everything
+pcmp build release # one profile
+pcmp build 'dist[flavour=min,target=roblox]' # one task
+pcmp build dist --axis target=roblox # a slice
+```
+
+`--axis` repeats. There is no wildcard, and a selection matching nothing is [`no-such-task`](diagnostics.md#no-such-task).
+
+## Reading a build
+
+```console
+$ pcmp build
+plan 9b35cd36a0d9
+
+ built dev dist/dev/app.luau
+ cached release dist/release/app.luau
+
+1 built, 1 cached, 0 failed
+```
+
+A task is skipped when its configuration, its inputs and its artifacts are all unchanged. Editing an artifact by hand counts, so the next build restores it.
+
+```console
+$ pcmp plan --why
+ stale dev dist/dev/app.luau a source file changed
+ fresh release dist/release/app.luau
+```
+
+## Watching
+
+```console
+$ pcmp watch
+watching /home/you/app
+plan 9b35cd36a0d9
+
+ built dev dist/dev/app.luau
+
+1 built, 0 cached, 0 failed
+```
+
+Each `sources` root gets a `watching` line of its own.
+
+!!! warning "Editing the manifest does not take effect"
+
+ `watch` reads the manifest once, at startup. An edit there is reported on stderr and otherwise ignored until you restart.
+
+## Reproducing a build
+
+```lua
+vars = { version = pcmp.envOr("VERSION", "dev"), built = pcmp.now() }
+```
+
+A manifest like this changes between runs. Record what it read, then build from the record.
+
+```sh
+pcmp build --lock
+pcmp build --frozen # fails if anything differs
+```
+
+A frozen build answers the manifest from `pcmp.lock`, so `pcmp.now()` returns the pinned instant and closes with `2 reproduced, 0 differing`.
+
+Commit `pcmp.lock`. Until it exists, `pcmp check` reports [`unrecorded-reading`](diagnostics.md#unrecorded-reading).
+
+### Pinning the clock
+
+`pcmp.now()` answers differently every second, so a manifest that calls it never hits the cache.
+
+=== "--now"
+
+ ```sh
+ pcmp build --now 2026-01-01T00:00:00Z
+ ```
+
+=== "SOURCE_DATE_EPOCH"
+
+ ```sh
+ SOURCE_DATE_EPOCH=1767225600 pcmp build
+ ```
+
+`--now` wins over `SOURCE_DATE_EPOCH`, and a frozen build wins over both.
+
+## Machine-readable output
+
+```sh
+pcmp build --json | jq '.tasks[] | select(.status == "failed")'
+pcmp check --json | jq '[.[] | select(.severity == "error")] | length'
+```
+
+Answers go to stdout and failures go to stderr, so `pcmp build > report.json` still tells you why it did not work. Under `--json` both go to stdout.
+
+??? note "The diagnostic shape"
+
+ ```json
+ {
+ "code": "missing-output",
+ "severity": "error",
+ "at": "profiles.release",
+ "message": "no `output` after inheritance",
+ "help": "a template, e.g. \"dist/{profile}/app.luau\"",
+ "source": null
+ }
+ ```
+
+ `at` is the manifest key, such as `profiles.release.darklua.rules[2]`. `source` carries the operating system's own message when something outside `pcmp` refused.
+
+## Exit codes
+
+| Code | Meaning |
+| --- | --- |
+| `0` | success |
+| `1` | a task failed, or a `--frozen` build did not reproduce |
+| `2` | the command line or the manifest could not be read |
+| `3` | `pcmp check` found something |
diff --git a/docs/composer-markers.md b/docs/composer-markers.md
deleted file mode 100644
index 87ff38c..0000000
--- a/docs/composer-markers.md
+++ /dev/null
@@ -1,95 +0,0 @@
-# Frames and Composer Markers
-
-Frames are Luau files that contain composer markers, which are build-time macros to replace chunks of code with build data.
-
----
-
-## Built-in markers
-
-| Marker | Replaced with | Type |
-|--------|--------------|------|
-| `__COMPOSER.Insert(__COMPOSER.build)` | Full compiled source code | `string` |
-| `__COMPOSER.Insert(__COMPOSER.genDate)` | ISO 8601 build date (quoted) | `"YYYY-MM-DD"` |
-| `__COMPOSER.Insert(__COMPOSER.cfg)` | Build config name (quoted) | `"Release"` |
-| `__COMPOSER.Insert(__COMPOSER.vers)` | Version string (quoted) | `"v1.2.3"` |
-
----
-
-## Writing a frame
-
-A frame is a plain `.luau` file. Use markers as expressions — they are replaced with raw Luau values (strings are already quoted with `%q`).
-
-```lua title="pipeline/frames/release.luau"
---!nolint
---!nocheck
---!native
---!optimize 2
-
---[[
- Generated by ProCMP — do not edit manually.
-]]
-
-_P = {
- genDate = __COMPOSER.Insert(__COMPOSER.genDate),
- cfg = __COMPOSER.Insert(__COMPOSER.cfg),
- vers = __COMPOSER.Insert(__COMPOSER.vers),
-}
-
-__COMPOSER.Insert(__COMPOSER.build)
-```
-
-After composition, `__COMPOSER.Insert(__COMPOSER.vers)` becomes `"v1.2.3"`.
-
----
-
-## Runtime access
-
-The `_P` table is available at runtime in your built script:
-
-```lua
-if _P.cfg == "Debug" then
- warn("[DEBUG BUILD]")
-end
-
-print("Version:", _P.vers)
-print("Built:", _P.genDate)
-```
-
-You can rename `_P` to anything. If you use [luau-lsp](https://github.com/JohnnyMorganz/luau-lsp), add type definitions to silence warnings:
-
-```lua title=".globals/pcmp.d.luau"
-declare __COMPOSER: {
- Insert: (text: string) -> string,
- genDate: string,
- build: string,
- cfg: string,
- vers: string,
-}
-
-declare _P: { [any]: any }
-```
-
----
-
-## Multiple frames
-
-You can have different frames for different configs. The `frame` field is per-config in `.pcmp.json`:
-
-```json
-{
- "buildConfigs": [
- {
- "name": "Debug",
- "frame": "pipeline/frames/debug.luau",
- ...
- },
- {
- "name": "Release",
- "frame": "pipeline/frames/release.luau",
- ...
- }
- ]
-}
-```
-
-A common pattern is a debug frame with no optimisation flags and extra logging headers, and a release frame with `--!native --!optimize 2` and a clean header.
diff --git a/docs/config-reference.md b/docs/config-reference.md
deleted file mode 100644
index 990026e..0000000
--- a/docs/config-reference.md
+++ /dev/null
@@ -1,228 +0,0 @@
-Full reference for `.pcmp.json`, `.pcmp.local.json`, and the CLI.
-
----
-
-## Command-Line Interface
-
-ProCMP supports both config-based and configless builds via the CLI.
-
-### Subcommands
-
-| Subcommand | Description |
-| ---------- | ------------------------------------------------- |
-| `build` | Run a configless build with flags |
-| `init` | Scaffold pipeline files and personal local config |
-
-### `build` flags
-
-| Flag | Description |
-| --------------------- | ----------------------------------- |
-| `-i, --input ` | Source entry point |
-| `-o, --output ` | Output file path |
-| `-f, --frame ` | Path to the frame file |
-| `-c, --config ` | Path to the darklua config |
-| `-n, --name ` | Optional display name for the build |
-
-### Example
-
-```bash
-pcmp build -i src/init.luau -o generated/out.luau -f pipeline/frames/release.luau -c pipeline/darklua/stable.json
-```
-
----
-
-## `.pcmp.json` — Shared cfg
-
-This file is committed to source control and shared across all team members.
-
-### Fields
-
-| Field | Type | Default | Description |
-| -------------------- | --------------------------------------- | ------------ | ----------------------------------------------------------- |
-| `envFile` | `string` | `".env"` | Path to the env file for reading tokens |
-| `openComposedOutput` | [`OpenFileConfig`](#openfileconfig) | — | Open composed output after each build (prefer local config) |
-| `releaseNotesEditor` | [`OpenFileConfig`](#openfileconfig) | — | Editor for release notes (prefer local config) |
-| `deployment` | [`DeploymentConfig`](#deploymentconfig) | — | GitHub deployment settings |
-| `buildConfigs` | [`BuildConfig[]`](#buildconfig) | **required** | Array of named build configurations |
-
----
-
-### `BuildConfig`
-
-| Field | Type | Default | Description |
-| -------------------- | ----------------------------------- | ------------------- | ------------------------------------------------------- |
-| `name` | `string` | **required** | Display name shown in the selection prompt |
-| `input` | `string` | — | Source entry point (mutually exclusive with `inputDir`) |
-| `inputDir` | `string` | — | Scan a directory for `.luau`/`.lua` files |
-| `output` | `string` | — | Output file path (single-file mode) |
-| `outputDir` | `string` | — | Output directory (directory mode) |
-| `frame` | `string` | **required** | Path to the frame file |
-| `darkluaConfig` | `string` | **required** | Path to the darklua config |
-| `promptVersion` | `boolean` | `false` | Prompt for a version string before building |
-| `versionFormat` | `string` | `"^v%d+%.%d+%.%d+"` | Lua pattern the version string must match |
-| `promptDeploy` | `boolean` | `false` | Prompt to deploy after building |
-| `prerelease` | `boolean` | `false` | Mark the GitHub release as a pre-release |
-| `openComposedOutput` | [`OpenFileConfig`](#openfileconfig) | — | Per-config output opener (overrides global) |
-| `variants` | [`BuildVariant[]`](#buildvariant) | — | Additional builds that inherit this config |
-
-#### Directory mode (`inputDir`)
-
-When `inputDir` is set, ProCMP scans the directory for `.luau` and `.lua` files and builds each one independently. Useful for test suites.
-
-```json
-{
- "name": "Tests",
- "inputDir": "tests",
- "outputDir": "generated/tests",
- "frame": "pipeline/frames/release.luau",
- "darkluaConfig": "pipeline/darklua/unminified.json"
-}
-```
-
-#### Single-file mode (`input`)
-
-Standard mode: one source file → one output file.
-
-```json
-{
- "name": "Release",
- "input": "src/init.luau",
- "output": "generated/release/myScript.luau",
- "frame": "pipeline/frames/release.luau",
- "darkluaConfig": "pipeline/darklua/stable.json"
-}
-```
-
----
-
-### `BuildVariant`
-
-Variants inherit all fields from their parent `BuildConfig` and override any fields you specify. They are built in sequence after the parent.
-
-| Field | Type | Description |
-| --------------- | -------- | ---------------------------- |
-| `name` | `string` | Display name for the variant |
-| `input` | `string` | Override source entry point |
-| `output` | `string` | Override output path |
-| `frame` | `string` | Override frame |
-| `darkluaConfig` | `string` | Override darklua config |
-
-```json
-{
- "name": "Release",
- "input": "src/init.luau",
- "output": "generated/release/myScript.luau",
- "frame": "pipeline/frames/release.luau",
- "darkluaConfig": "pipeline/darklua/stable.json",
- "variants": [
- {
- "name": "Release-Unminified",
- "darkluaConfig": "pipeline/darklua/unminified.json",
- "output": "generated/release/myScript-unminified.luau"
- }
- ]
-}
-```
-
----
-
-### `DeploymentConfig`
-
-```json
-{
- "deployment": {
- "github": {
- "owner": "your-username",
- "repo": "your-repo",
- "tokenEnvVar": "GITHUB_TOKEN"
- }
- }
-}
-```
-
-| Field | Type | Default | Description |
-| -------------------- | -------- | ---------------- | ----------------------------------- |
-| `github.owner` | `string` | **required** | GitHub username or organisation |
-| `github.repo` | `string` | **required** | Repository name |
-| `github.tokenEnvVar` | `string` | `"GITHUB_TOKEN"` | Env var name to read the token from |
-
----
-
-### `OpenFileConfig`
-
-Used for `openComposedOutput` and `releaseNotesEditor`.
-
-| Field | Type | Description |
-| --------- | ---------- | --------------------------------------------------- |
-| `command` | `string` | Executable to run |
-| `args` | `string[]` | Arguments — `{file}` is replaced with the file path |
-
-```json
-{
- "command": "code",
- "args": ["--wait", "{file}"]
-}
-```
-
----
-
-## `.pcmp.local.json` — Local config
-
-Personal preferences
-
-| Field | Type | Description |
-| -------------------- | ----------------------------------- | ------------------------------ |
-| `envFile` | `string` | Override env file path |
-| `openComposedOutput` | [`OpenFileConfig`](#openfileconfig) | Your editor for output preview |
-| `releaseNotesEditor` | [`OpenFileConfig`](#openfileconfig) | Your editor for release notes |
-
-See [Local Config](local-config.md) for full documentation.
-
----
-
-## Full example
-
-```json title="pipeline/.pcmp.json"
-{
- "deployment": {
- "github": {
- "owner": "your-name",
- "repo": "your-repo",
- "tokenEnvVar": "GITHUB_TOKEN"
- }
- },
- "buildConfigs": [
- {
- "name": "Tests",
- "inputDir": "tests",
- "outputDir": "generated/tests",
- "frame": "pipeline/frames/release.luau",
- "darkluaConfig": "pipeline/darklua/unminified.json"
- },
- {
- "name": "Debug",
- "input": "src/init.luau",
- "output": "generated/debug/myScript.luau",
- "frame": "pipeline/frames/debug.luau",
- "darkluaConfig": "pipeline/darklua/unminified.json"
- },
- {
- "name": "Release",
- "input": "src/init.luau",
- "output": "generated/release/myScript.luau",
- "frame": "pipeline/frames/release.luau",
- "darkluaConfig": "pipeline/darklua/stable.json",
- "promptVersion": true,
- "versionFormat": "^v%d+%.%d+%.%d+",
- "promptDeploy": true,
- "variants": [
- {
- "name": "Release-Unminified",
- "darkluaConfig": "pipeline/darklua/unminified.json",
- "output": "generated/release/myScript-unminified.luau"
- }
- ]
- }
- ]
-}
-```
diff --git a/docs/darklua.md b/docs/darklua.md
new file mode 100644
index 0000000..9bc5a72
--- /dev/null
+++ b/docs/darklua.md
@@ -0,0 +1,90 @@
+---
+description: darklua's own configuration, passed through untouched.
+---
+
+# darklua
+
+A profile's `darklua` block goes to darklua unchanged, so anything the linked version supports is available. The full option list is at [darklua.com/docs/config](https://darklua.com/docs/config/).
+
+```json5 title="pcmp.json5"
+darklua: {
+ generator: { name: "dense", column_span: 120 },
+ apply_to_files: ["src/**"],
+ skip_files: ["**/*.test.luau"],
+ lua_extension: "luau",
+
+ bundle: {
+ require_mode: { name: "luau", aliases: { pkg: "./Packages" } },
+ excludes: ["@lune/**"],
+ },
+
+ rules: [
+ "compute_expression",
+ { rule: "convert_require", current: "path", target: "roblox" },
+ ],
+}
+```
+
+`apply_to_files` and `skip_files` match a path relative to the entry, so `src/**` matches nothing when the entry is already `src/init.luau`. A filter matching nothing gives you [`no-output`](diagnostics.md#no-output).
+
+## Generators
+
+| Generator | Output |
+| --- | --- |
+| `retain_lines` | keeps the original line structure, and is darklua's default |
+| `dense` | compact, one long line per statement run |
+| `readable` | reformatted and indented |
+
+`dense` and `readable` take a `column_span`, either as an object or on their own as a string.
+
+## Rules
+
+| Written | Result |
+| --- | --- |
+| `rules` omitted | darklua applies its own defaults |
+| `rules: []` | bundle and generate, transform nothing |
+| `rules: [a, b]` | exactly these, in this order |
+
+In a Luau manifest, `rules = {}` is the empty list. Luau cannot tell an empty list from an
+empty map, so `pcmp` reads `{}` as a list at `rules`, `apply_to_files`, `skip_files`,
+`excludes` and `globals`. Everywhere else it stays a map.
+
+A rule is a bare name, or an object with a `rule` key and that rule's own settings. Each `define` becomes an `inject_global_value` rule in front of whatever you wrote.
+
+```console
+$ pcmp plan release
+darklua
+ {
+ "bundle": { "require_mode": "luau" },
+ "generator": "dense",
+ "rules": [
+ { "rule": "inject_global_value", "identifier": "DEBUG", "value": false },
+ { "rule": "inject_global_value", "identifier": "PCMP_NAME", "value": "app" },
+ "compute_expression"
+ ]
+ }
+```
+
+`pcmp plan ` prints the result as a valid `.darklua.json`.
+
+## Rule order
+
+Two orderings are checked, both of them darklua's own.
+
+| Code | What it catches |
+| --- | --- |
+| [`fold-before-inject`](diagnostics.md#fold-before-inject) | `compute_expression` before `inject_global_value`, so the define does nothing |
+| [`branch-before-fold`](diagnostics.md#branch-before-fold) | `remove_unused_if_branch` before `compute_expression`, so the branch survives |
+
+## Merging
+
+| Inherited value | What a child does to it |
+| --- | --- |
+| object | merges recursively |
+| array | replaces outright |
+| scalar | replaces |
+| `null` | unsets the inherited key |
+
+```json5
+unbundled: { extends: "release", darklua: { bundle: null } }
+```
diff --git a/docs/deployment.md b/docs/deployment.md
deleted file mode 100644
index 579fd0f..0000000
--- a/docs/deployment.md
+++ /dev/null
@@ -1,63 +0,0 @@
-# Deployment
-
-## Prerequisites
-
-- A GitHub repository
-- A GitHub token with the right permissions (see below)
-
----
-
-## Token setup
-
-Add your token to an `.env` file at your project root:
-
-```dotenv title=".env"
-GITHUB_TOKEN="ghp_..."
-```
-
-!!! caution
- Never commit your `.env` file. Make sure it is in `.gitignore`.
-
-### Fine-grained PAT (recommended)
-
-When creating the token at [github.com/settings/tokens](https://github.com/settings/tokens), grant:
-
-| Permission | Access |
-|------------|--------|
-| `Contents` | Read and write |
-| `Metadata` | Read-only |
-
-### Classic PAT
-
-Grant the `repo` scope.
-
----
-
-## Config
-
-Add a `deployment` section to your `.pcmp.json`:
-
-```json
-{
- "deployment": {
- "github": {
- "owner": "your-username",
- "repo": "your-repo",
- "tokenEnvVar": "GITHUB_TOKEN"
- }
- }
-}
-```
-
-`tokenEnvVar` defaults to `GITHUB_TOKEN` if omitted. Set it to a different name if you use multiple tokens.
-
-Enable deploy on a build config with `promptDeploy: true`:
-
-```json
-{
- "name": "Release",
- "promptDeploy": true,
- "prerelease": false,
- ...
-}
-```
\ No newline at end of file
diff --git a/docs/diagnostics.md b/docs/diagnostics.md
new file mode 100644
index 0000000..1eb676b
--- /dev/null
+++ b/docs/diagnostics.md
@@ -0,0 +1,353 @@
+---
+description: Every code pcmp can report, and what to do about it.
+---
+
+# Diagnostics
+
+A code never changes meaning, so it is safe to grep for.
+
+```sh
+pcmp explain missing-output
+```
+
+## Reading the command line
+
+An unknown flag or an unknown value is rejected separately, with a usage message.
+
+### bad-argument
+
+`error`, exits `2`
+
+An argument's value is not the shape its flag takes. `--env`, `--var`, `--define` and
+`--axis` each take `KEY=VALUE`, `--now` takes an RFC 3339 instant in UTC to the second,
+and `pcmp explain` takes a code from the list `pcmp explain` prints.
+
+## Reading the manifest
+
+Nothing is built when one of these fires.
+
+### no-manifest
+
+`error`, exits `2`
+
+No manifest in the working directory or any directory above it. Run `pcmp init` to write
+one, or point at an existing manifest with `-m`.
+
+### unknown-format
+
+`error`, exits `2`
+
+The extension picks the format, so a JSON manifest called `pcmp.conf` is not read.
+Supported: json5, json, jsonc, toml, luau.
+
+### unreadable
+
+`error`, exits `2`
+
+The operating system refused a read. Its own message follows.
+
+### syntax
+
+`error`, exits `2`
+
+The manifest is not valid in the format its extension declares. The parser's own message
+follows, with a line and column where it reports one.
+
+### not-a-table
+
+`error`, exits `2`
+
+A Luau manifest must evaluate to a table. The usual cause is a missing `return`.
+
+### eval
+
+`error`, exits `2`
+
+A Luau manifest raised an error while evaluating. Its traceback follows.
+
+### budget
+
+`error`, exits `2`
+
+A Luau manifest exceeded its evaluation budget or its memory limit. The usual cause is a
+loop whose condition never becomes false.
+
+### unset-env
+
+`error`, exits `2`
+
+`pcmp.env(name)` was called for a variable that is set neither by `--env` nor in the
+process environment. Pass `--env NAME=VALUE`, or use `pcmp.envOr(name, fallback)` when a
+default is acceptable.
+
+## Resolving the plan
+
+Every profile is checked before the run gives up, so one edit can fix several at once.
+
+### unknown-base
+
+`error`, exits `2`
+
+`extends` names something that is in neither `templates` nor `profiles`. The help line
+lists what is available.
+
+### cyclic-extends
+
+`error`, exits `2`
+
+An `extends` chain returns to a profile it already passed through. The help line lists the
+cycle.
+
+### name-collision
+
+`error`, exits `2`
+
+The same name appears in both `templates` and `profiles`. `extends` looks in both, so a
+name belongs to one of them. Rename one.
+
+### bad-name
+
+`error`, exits `2`
+
+A profile or template name contains `[`, `]`, `,` or `=`. Those characters delimit a task
+identifier such as `dist[target=roblox]`. Rename it.
+
+### missing-entry
+
+`error`, exits `2`
+
+Neither the profile nor anything it extends declares an `entry`, which is a file to
+bundle or a directory to process as a tree.
+
+### missing-output
+
+`error`, exits `2`
+
+Neither the profile nor anything it extends declares an `output`, which is a template and
+so may vary by profile or axis: "dist/{profile}/app.luau".
+
+### bad-template
+
+`error`, exits `2`
+
+A template names a token that is not a var, not an axis and not `{profile}`, or leaves a
+`{` unclosed, or expands to nothing. The help line lists the tokens you can use. Write
+`{{` and `}}` for a literal brace.
+
+In a path, a token may not expand to a `.` or `..` segment, so a profile named `..` is
+refused. A plain `/` is allowed, and `{outdir}/app.luau` works.
+
+### bad-path
+
+`error`, exits `2`
+
+A path is empty, or climbs above the filesystem root with `..`. Paths resolve against
+the manifest's own directory, never the working directory.
+
+### bad-define
+
+`error`, exits `2`
+
+A define key must be a Luau identifier, so `my-flag` and `end` are refused. A value must
+be finite, and an integer must survive a round trip through an IEEE double, which bounds
+it at 2^53.
+
+### bad-var
+
+`error`, exits `2`
+
+A var name must be a Luau identifier. Two names may also collide, because the constant is
+uppercased, so `channel` and `Channel` both give you `PCMP_CHANNEL`.
+
+### bad-rules
+
+`error`, exits `2`
+
+`darklua.rules` is not a list darklua could read. Each entry is a rule name, or an object
+with a `rule` key and that rule's own settings.
+
+### bad-loader
+
+`error`, exits `2`
+
+A loader names a strategy darklua does not have. Valid: copy, skip, luau, json,
+json_lines, toml, yaml, string, buffer, bytes, and the encoded forms string/base64,
+string/zstd, string/gzip and string/zlib, with buffer and bytes likewise.
+
+### bad-loader-pattern
+
+`error`, exits `2`
+
+A loader's `pattern` is not one darklua accepts. A pattern matches a file's path relative
+to the entry.
+
+### bad-glob
+
+`error`, exits `2`
+
+An `ignore` entry is not a valid glob. Globs match each file's path relative to the root
+it was found under.
+
+### empty-axis
+
+`error`, exits `2`
+
+An axis lists no values, so its profile expands to zero tasks.
+
+### no-tasks
+
+`error`, exits `2`
+
+The manifest declares no profiles, so there is nothing to build. A `templates` entry is
+never built on its own.
+
+## Checking the plan
+
+Found once every task is known, so these name two tasks rather than one.
+
+### output-collision
+
+`error`, exits `2`
+
+Two tasks write to the same path, and whichever finished last would win. Give them
+distinct `output` templates. `{profile}` and every axis are available as tokens.
+
+### output-in-inputs
+
+`error`, exits `2`
+
+A task writes inside a root that a task reads, so the next build would read the artifact
+as a source. Move the output outside every root, or exclude it with `ignore`.
+
+### no-such-task
+
+`error`, exits `2`
+
+No task matched the selection, and the help line lists the ones that exist. A selector is a
+profile name or an exact task identifier, and `--axis KEY=VALUE` filters by coordinate.
+There is no wildcard.
+
+## Building
+
+Reported per task, and one failing task does not stop the others.
+
+### missing-entry-file
+
+`error`, exits `1`
+
+The task's `entry` does not exist. The path resolves against the manifest's directory, not
+the directory you ran from.
+
+### undeclared-input
+
+`error`, exits `1`
+
+darklua asked for a file that is under no root this task declares, and the path it tried
+is in the help line. Add the directory holding it to `sources`.
+
+### darklua-config
+
+`error`, exits `1`
+
+darklua rejected the configuration this task compiles to, which is printed with the error.
+`pcmp plan ` shows the same thing without building.
+
+### process-failed
+
+`error`, exits `1`
+
+darklua reported an error while transforming this task's sources. Its own message
+follows. When the error is a file darklua could not find, the code is
+`undeclared-input` instead.
+
+### no-output
+
+`error`, exits `1`
+
+The task reported no failure and produced nothing. Check `apply_to_files` and `skip_files`,
+which match a path relative to the entry, so `src/**` matches nothing when the entry is
+already `src/init.luau`.
+
+### write-failed
+
+`error`, exits `1`
+
+An artifact could not be written, and the operating system's own message follows. The
+artifact from the previous build is left intact.
+
+### frozen
+
+`error`, exits `1`
+
+A `--frozen` build did not reproduce what `pcmp.lock` records, and the help line names the
+tasks that differ. Either the manifest changed since the lock was written, or a task
+produced different bytes from the same inputs.
+
+## Lints
+
+Reported by `pcmp check`. `--strict` makes the warnings fail too.
+
+### fold-before-inject
+
+`error`, exits `3`
+
+`compute_expression` is listed before `inject_global_value`, so folding runs first and the
+define has no effect. Move every injection ahead of every fold. `pcmp` puts the ones it
+generates first, so this only comes from an `inject_global_value` you wrote.
+
+### branch-before-fold
+
+`error`, exits `3`
+
+`remove_unused_if_branch` is scheduled before `compute_expression`. A branch can only be
+removed once its condition has folded to a constant, so the branch survives.
+
+### unreachable-define
+
+`warning`, exits `3`
+
+A define's identifier appears in none of the task's sources, so nothing is substituted.
+Check the spelling on both sides.
+
+### unrecorded-reading
+
+`warning`, exits `3`
+
+The manifest read the clock or the environment, and no pcmp.lock exists, so nothing
+records what it read. Run `pcmp build --lock`, and `pcmp build --frozen` then reproduces
+the build exactly, timestamps included.
+
+### shadowed-var
+
+`warning`, exits `3`
+
+A var is named `profile`, which `pcmp` sets itself. The built-in wins. Rename yours.
+
+### output-outside-root
+
+`warning`, exits `3`
+
+A task writes outside the manifest's directory. This builds, and is sometimes what you
+want, but `pcmp` is touching files outside the project.
+
+### stale-schema
+
+`warning`, exits `3`
+
+A pcmp.schema.json in the project no longer matches this version of `pcmp`, so your editor
+is completing against the wrong thing. Regenerate it with `pcmp schema`, or delete it.
+
+### unused-template
+
+`warning`, exits `3`
+
+Nothing extends this template, and a template is never built, so it does nothing. Remove
+it, or move it to `profiles`.
+
+### identical-profiles
+
+`warning`, exits `3`
+
+Two profiles resolve to the same task apart from their output. Move what they share into a
+template, or give one of them an axis.
+
diff --git a/docs/getting-started.md b/docs/getting-started.md
deleted file mode 100644
index ebe02fd..0000000
--- a/docs/getting-started.md
+++ /dev/null
@@ -1,90 +0,0 @@
-# Getting Started
-
-## Prerequisites
-
-ProCMP depends on one tool:
-
-- [**darklua**](https://github.com/seaofvoices/darklua) — bundler and minifier
-
-Install via [Aftman](https://github.com/LPGhatguy/aftman) (recommended):
-
-```toml title="aftman.toml"
-[tools]
-darklua = "Stefanuk12/darklua@0.17.4"
-ProCMP = "Proton-Utilities/ProCMP@3.0.1"
-```
-
-Then run:
-
-```sh
-aftman install
-```
-
----
-
-### 1. Build using CLI (Configless)
-
-If you don't want to set up a configuration file yet, you can run a build directly:
-
-```bash
-pcmp build -i src/init.luau -o generated/out.luau -f pipeline/frames/release.luau -c pipeline/darklua/stable.json
-```
-
-### 2. Scaffold a new project
-
-The fastest way to get started is `pcmp init`:
-
-```sh
-pcmp init
-```
-
-This launches an interactive wizard that generates:
-
-- `pipeline/.pcmp.json` — shared pipeline config (commit this)
-- `pipeline/.pcmp.local.json` — your personal editor preferences (gitignored)
-- `pipeline/frames/` — frame templates
-- `pipeline/darklua/` — darklua configs (stable + unminified)
-- `.env.example` — token template
-- Updated `.gitignore`
-
-See the full walkthrough in [pcmp init](init.md).
-
----
-
-## Running the pipeline
-
-```sh
-pcmp pipeline/.pcmp.json
-```
-
-You'll be prompted to:
-
-1. **Select a build configuration** (e.g. Debug, Beta, Release)
-2. **Enter a version** (if `promptVersion: true`) — must match `versionFormat`
-3. **Confirm public distribution** (if `promptDeploy: true`)
-
-### CLI flags
-
-| Flag | Description |
-| ----------------------- | --------------------------------------- |
-| `pcmp ` | Run pipeline with the given config file |
-| `pcmp init` | Interactive project scaffolder |
-| `pcmp --help` / `-h` | Show help |
-| `pcmp --version` / `-V` | Print version string |
-
----
-
-## Your first build
-
-After running `pcmp init`, try a Debug build:
-
-```sh
-pcmp pipeline/.pcmp.json
-# → Select: Debug
-# → Build complete: generated/debug/myScript.luau
-```
-
-The output file contains your composed source, inserted into the frame with all composer markers replaced.
-
-!!! note "Editor preview"
-If you set an editor in `pcmp init`, the composed output will open automatically after each build. This is stored in your gitignored `.pcmp.local.json`.
diff --git a/docs/index.md b/docs/index.md
index ce3a80b..72bc8e0 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -1,26 +1,108 @@
+---
+description: One Luau source tree, many build targets, from a single manifest.
+---
+
# ProCMP
-**Build composition and release pipeline for Luau projects.**
+ProCMP builds one Luau source tree into several artifacts at once. Each artifact comes from a named profile, and every profile lives in one manifest file. [darklua](https://darklua.com) is linked in as a library, so `pcmp` is one binary with no second tool to install.
-ProCMP is a powerful CLI tool that bundles your source with [darklua](https://github.com/seaofvoices/darklua) using build configurations, injects it into a frame with macros, and deploys it to Github releases. All in one mighty package.
+## Install
----
+=== "rokit"
+
+ ```sh
+ rokit add Proton-Utilities/ProCMP
+ ```
+
+=== "aftman"
+
+ ```sh
+ aftman add Proton-Utilities/ProCMP
+ ```
+
+=== "cargo"
-## Quick Start
+ ```sh
+ cargo install --locked --git https://github.com/Proton-Utilities/ProCMP
+ ```
-```sh
-# Install via Aftman
-aftman add seaofvoices/darklua
-aftman add Proton-Utilities/ProCMP
+Add `.pcmp/` to your `.gitignore`.
-# Scaffold a new project
-pcmp init
+## Your first build
-# Run the pipeline
-pcmp pipeline/.pcmp.json
+```console
+$ pcmp init
+created pcmp.json5
+next pcmp plan
+
+$ pcmp build --var version=v1.0.0
+plan 9b35cd36a0d9
+
+ built dev dist/dev/app.luau
+ built release dist/release/app.luau
+
+2 built, 0 cached, 0 failed
+
+$ pcmp build
+0 built, 2 cached, 0 failed
```
----
+## What a profile changes
+
+```lua title="src/init.luau"
+--!strict
+local VERSION: string = PCMP_VERSION
+
+if DEBUG then
+ print("verbose telemetry")
+end
+
+return VERSION
+```
+
+=== "release"
+
+ ```lua title="dist/release/app.luau"
+ -- app v1.0.0
+ local a='v1.0.0'return a
+ ```
+
+=== "dev"
+
+ ```lua title="dist/dev/app.luau"
+ local VERSION: string = 'v1.0.0'
+
+ if true then
+ print('verbose telemetry')
+ end
+
+ return VERSION
+ ```
+
+`release` sets `DEBUG` to `false` and asks darklua to fold and strip. `dev` sets it to `true` and asks for nothing, so a stack trace still points at your source. Both are a few lines of [manifest](manifest.md).
+
+## Editor completion
+
+=== "Data manifest"
+
+ ```sh
+ pcmp schema > pcmp.schema.json
+ ```
+
+ ```json5 title="pcmp.json5"
+ { $schema: "./pcmp.schema.json" }
+ ```
+
+=== "Luau manifest"
+
+ ```sh
+ pcmp schema --format luau > pcmp.d.luau
+ ```
+
+ ```json title=".vscode/settings.json"
+ {
+ "luau-lsp.types.definitionFiles": ["pcmp.d.luau"]
+ }
+ ```
-!!! tip "New to ProCMP?"
- Start with [Getting Started](getting-started.md) for a step-by-step walkthrough.
+Regenerate after an upgrade. If you commit either file, [`stale-schema`](diagnostics.md#stale-schema) tells you when it has gone out of date.
diff --git a/docs/init.md b/docs/init.md
deleted file mode 100644
index a30d7d1..0000000
--- a/docs/init.md
+++ /dev/null
@@ -1,73 +0,0 @@
-# pcmp init
-
-The `pcmp init` command scaffolds a complete ProCMP pipeline for your project interactively.
-
-```sh
-pcmp init
-```
-
----
-
-## What it generates
-
-```
-pipeline/
- .pcmp.json
- .pcmp.local.json
- frames/
- release.luau
- debug.luau
- darklua/
- stable.json
- unminified.json
-.env.example
-.gitignore
-```
-
----
-
-## Steps
-
-### 1. Project name
-Used to name the output files (e.g. `generated/release/my-project.luau`).
-
-### 2. GitHub owner / repo
-Optional. If provided, deploy config is added to `.pcmp.json` and deploy prompts are enabled on Release/Beta configs.
-
-### 3. Editor preference
-Select your preferred editor. Options:
-
-| Choice | Command |
-|--------|---------|
-| VS Code | `code --wait {file}` |
-| Cursor | `cursor --wait {file}` |
-| Neovim | `nvim {file}` |
-| Zed | `zed --wait {file}` |
-| Skip | — |
-
-### 4. Build configs
-Choose which configs to include: Debug, Beta, Release. A directory-based `Tests` config can be added manually later (see [Config Reference](config-reference.md)).
-
-### 5. Entry point
-Path to your main Luau source file (e.g. `src/init.luau`). This is the file passed to darklua.
-
----
-
-## Re-running init
-
-You can run `pcmp init` in an existing project to:
-
-- Regenerate your personal `.pcmp.local.json` (e.g. after switching editors)
-- Regenerate frame/darklua templates
-
-!!! warning
- Running `pcmp init` again will overwrite `pipeline/.pcmp.json`. Back it up first if you've customised it.
-
----
-
-## After init: next steps
-
-1. Copy `.env.example` to `.env` and set your GitHub token
-2. Run `pcmp pipeline/.pcmp.json` to build
-3. Commit `pipeline/.pcmp.json` and `pipeline/frames/` and `pipeline/darklua/`
-4. Add `.pcmp.local.example.json` to show teammates what fields they can set
diff --git a/docs/local-config.md b/docs/local-config.md
deleted file mode 100644
index 325a348..0000000
--- a/docs/local-config.md
+++ /dev/null
@@ -1,93 +0,0 @@
-# Local Config
-
-ProCMP separates **shared pipeline config** from **personal preferences**
-
-## Fields
-
-| Field | Description |
-|-------|-------------|
-| `openComposedOutput` | Command to open the composed output after each build |
-| `releaseNotesEditor` | Command to edit release notes before deploy |
-| `envFile` | Path to dotenv file (default: `.env`) |
-
----
-
-## Example
-
-=== "VS Code user"
-
- ```json title="pipeline/.pcmp.local.json"
- {
- "openComposedOutput": {
- "command": "code",
- "args": ["{file}"]
- },
- "releaseNotesEditor": {
- "command": "code",
- "args": ["--wait", "{file}"]
- }
- }
- ```
-
-=== "Neovim user"
-
- ```json title="pipeline/.pcmp.local.json"
- {
- "openComposedOutput": {
- "command": "nvim",
- "args": ["{file}"]
- },
- "releaseNotesEditor": {
- "command": "nvim",
- "args": ["{file}"]
- }
- }
- ```
-
-=== "Cursor user"
-
- ```json title="pipeline/.pcmp.local.json"
- {
- "openComposedOutput": {
- "command": "cursor",
- "args": ["{file}"]
- },
- "releaseNotesEditor": {
- "command": "cursor",
- "args": ["--wait", "{file}"]
- }
- }
- ```
-
----
-
-## Setup
-
-1. Create `pipeline/.pcmp.local.json` (or wherever your `.pcmp.json` lives)
-2. Add your preferred fields (see table above)
-3. The file is gitignored automatically by `pcmp init`, or add it manually:
-
- ```gitignore title=".gitignore"
- pipeline/.pcmp.local.json
- ```
-
----
-
-## Sharing a template
-
-Commit a `.pcmp.local.example.json` alongside your `.pcmp.json` so teammates know what fields are available:
-
-```json title="pipeline/.pcmp.local.example.json"
-{
- "openComposedOutput": {
- "command": "code",
- "args": ["{file}"]
- },
- "releaseNotesEditor": {
- "command": "code",
- "args": ["--wait", "{file}"]
- }
-}
-```
-
-The `{file}` placeholder is replaced with the actual file path at runtime.
diff --git a/docs/manifest.md b/docs/manifest.md
new file mode 100644
index 0000000..0f3b82c
--- /dev/null
+++ b/docs/manifest.md
@@ -0,0 +1,220 @@
+---
+description: Every field you can write, and what it does.
+---
+
+# Manifest
+
+`pcmp` searches the working directory and then each directory above it. The extension picks the format, and all five produce the same build.
+
+```
+pcmp.json5 pcmp.json pcmp.jsonc pcmp.toml pcmp.luau
+```
+
+Paths resolve against the manifest's directory, not the directory you ran from.
+
+=== "pcmp.json5"
+
+ ```json5
+ {
+ vars: { name: "app" },
+ profiles: {
+ release: {
+ entry: "src/init.luau",
+ output: "dist/{name}.luau",
+ define: { DEBUG: false },
+ darklua: { generator: "dense", rules: ["compute_expression"] },
+ },
+ },
+ }
+ ```
+
+=== "pcmp.toml"
+
+ ```toml
+ [vars]
+ name = "app"
+
+ [profiles.release]
+ entry = "src/init.luau"
+ output = "dist/{name}.luau"
+ define = { DEBUG = false }
+
+ [profiles.release.darklua]
+ generator = "dense"
+ rules = ["compute_expression"]
+ ```
+
+=== "pcmp.luau"
+
+ ```lua
+ return {
+ vars = { name = "app" },
+ profiles = {
+ release = {
+ entry = "src/init.luau",
+ output = "dist/{name}.luau",
+ define = { DEBUG = false },
+ darklua = { generator = "dense", rules = { "compute_expression" } },
+ },
+ },
+ }
+ ```
+
+## Top level
+
+| Key | What it holds |
+| --- | --- |
+| `vars` | named values every profile starts from |
+| `templates` | never built, and exist to be extended |
+| `profiles` | built, once each, or once per axis combination |
+
+`extends` finds a name in either map, so a profile can extend a profile. A name may not appear in both.
+
+## Profile fields
+
+| Field | What it does |
+| --- | --- |
+| `extends` | a template or profile to inherit from |
+| `entry` | a file to bundle, or a directory to process as a tree |
+| `output` | where it goes |
+| `sources` | extra files and directories that count as build inputs |
+| `ignore` | globs excluded from that input set |
+| `vars` | named values |
+| `define` | constants substituted into your source |
+| `header` | lines written above each artifact |
+| `loaders` | an ordered list of `pattern` to `use` pairs |
+| `darklua` | [darklua's own configuration](darklua.md), verbatim |
+| `axes` | expands the profile into one task per combination |
+
+Down an `extends` chain the nearest declaration wins. `vars` and `define` accumulate, `darklua` merges key by key, and everything else replaces. Declare a list empty to clear an inherited one.
+
+```json5
+plain: { extends: "release", header: [] }
+```
+
+## Vars and defines
+
+```json5
+vars: { name: "app", retries: 3 },
+define: { DEBUG: false }
+```
+
+`name` gives you `{name}` in a path or header and `PCMP_NAME` in your source. `DEBUG` gives you only the constant. Both take a string, a number or a boolean, and the type reaches Luau intact. An integer above 2^53 is [`bad-define`](diagnostics.md#bad-define).
+
+```sh
+pcmp build --var version=v1.2.3 --define DEBUG=true
+```
+
+!!! warning "What a define can reach"
+
+ `DEBUG`, `_G.DEBUG` and `_G["DEBUG"]` are all substituted.
+
+ `getgenv().DEBUG` is not, because it is a function call. Nothing reports this, and the branch survives into the artifact.
+
+Misspell one and `pcmp check` reports [`unreachable-define`](diagnostics.md#unreachable-define).
+
+## Tokens
+
+```json5
+output: "dist/{profile}/{name}.luau"
+```
+
+`entry`, `output`, `sources` and `header` take `{token}`. Every var, every axis and `{profile}` expand. Write `{{` and `}}` for a literal brace, and anything else is [`bad-template`](diagnostics.md#bad-template).
+
+A token in a path may not expand to a `.` or `..` segment, so a profile named `..` is refused. A plain `/` is fine, and `{outdir}/app.luau` with `outdir` set to `build/dist` works.
+
+## Entry and output
+
+=== "File to file"
+
+ ```json5
+ entry: "src/init.luau", output: "dist/app.luau",
+ ```
+
+ Bundled into one file when `darklua.bundle` is set.
+
+=== "Directory to directory"
+
+ ```json5
+ entry: "src", output: "build",
+ ```
+
+ Every file processed, structure preserved, no bundling.
+
+`header` goes on `.luau` and `.lua`, or whatever `darklua.lua_extension` names.
+
+Two tasks writing one path is [`output-collision`](diagnostics.md#output-collision). A task writing inside an entry tree is [`output-in-inputs`](diagnostics.md#output-in-inputs). An output that climbs out of the project with `..` builds, and `pcmp check` reports [`output-outside-root`](diagnostics.md#output-outside-root).
+
+## Inputs
+
+```json5
+sources: ["../shared"],
+ignore: ["**/Packages/**"],
+```
+
+A build reads every file under the manifest's directory plus every `sources` root, minus anything `ignore` matches. Your outputs and the cache are excluded already. Extension is not a filter, because a loader can make a `.json` or a `.png` a real input.
+
+Requiring a file under no root fails the build with [`undeclared-input`](diagnostics.md#undeclared-input). Add the directory that holds it to `sources`.
+
+## Loaders
+
+```json5
+loaders: [
+ { pattern: "**/*.png", use: "buffer/base64" },
+ { pattern: "**/*.md", use: "string" },
+]
+```
+
+```lua
+local config = require("@self/assets/config.json")
+```
+
+| `use` | Behaviour |
+| --- | --- |
+| `copy` | passed through untouched |
+| `skip` | excluded from the output |
+| `luau` | parsed and processed as source |
+| `json`, `json_lines`, `toml`, `yaml` | returned as parsed data |
+| `string`, `buffer`, `bytes` | returned as content |
+
+The content forms also take `/base64`, `/zstd`, `/gzip` or `/zlib`. The first matching pattern wins, so put the specific ones first.
+
+## Axes
+
+```json5
+dist: {
+ extends: "base",
+ output: "dist/{target}/{flavour}.luau",
+ axes: {
+ flavour: ["min", "dev"],
+ target: {
+ roblox: { darklua: { bundle: { require_mode: "path" } } },
+ lune: { darklua: { bundle: { require_mode: "luau" } } },
+ },
+ },
+}
+```
+
+That is four tasks, named `dist[flavour=min,target=roblox]` and so on. An axis is a list of values, or a map from a value to an overlay that can set any profile field. Each axis is also a var.
+
+```sh
+pcmp build dist
+pcmp build 'dist[flavour=min,target=roblox]'
+pcmp build dist --axis target=roblox
+```
+
+## The pcmp API
+
+```lua
+pcmp.env("VERSION") -- errors when unset
+pcmp.envOr("VERSION", "v0.0.0") -- explicit fallback
+pcmp.now() -- RFC 3339 UTC
+pcmp.epoch() -- seconds, consistent with now() in one run
+pcmp.read("VERSION") -- a file, relative to the manifest
+pcmp.root -- the manifest's directory
+pcmp.darklua -- the linked darklua version
+```
+
+These work in `pcmp.luau` and nowhere else. `pcmp` records what they return, so read [Reproducing a build](cli.md#reproducing-a-build) first. There is no `pcmp.exec`, so pass a git SHA in with `--var`.
+
+Manifests run sandboxed. `os`, `io`, `require`, `debug` and `math.random` are all unavailable. `print` goes to stderr.
diff --git a/mkdocs.yml b/mkdocs.yml
deleted file mode 100644
index dd620df..0000000
--- a/mkdocs.yml
+++ /dev/null
@@ -1,73 +0,0 @@
-site_name: ProCMP Documentation
-
-repo_name: Proton-Utilities/ProCMP
-repo_url: https://github.com/Proton-Utilities/ProCMP
-edit_uri: edit/main/docs
-
-markdown_extensions:
- - pymdownx.highlight:
- anchor_linenums: true
- line_spans: __span
- pygments_lang_class: true
- - pymdownx.superfences
- - pymdownx.inlinehilite
- - pymdownx.snippets
- - pymdownx.details
- - pymdownx.tasklist
- - admonition
- - attr_list
- - md_in_html
-
-plugins:
- - search
- - awesome-nav
-
-theme:
- name: material
-
- icon:
- repo: fontawesome/brands/github
- edit: material/pencil
- view: material/eye
-
- palette:
- - scheme: default
- primary: deep purple
- accent: deep purple
- toggle:
- icon: material/toggle-switch-off-outline
- name: Switch to dark mode
- - scheme: slate
- primary: deep purple
- accent: deep purple
- toggle:
- icon: material/toggle-switch
- name: Switch to light mode
-
- font:
- text: Inter
- code: JetBrains Mono
-
- features:
- - navigation.instant
- - navigation.instant.progress
- - navigation.tracking
- - navigation.sections
- - navigation.expand
- - navigation.path
- - navigation.indexes
- - navigation.top
- - navigation.footer
- - toc.follow
- - search.suggest
- - content.action.edit
- - content.action.view
- - content.code.copy
- - content.code.select
- - content.code.annotate
-
-extra:
- generator: false
- social:
- - icon: fontawesome/brands/github
- link: https://github.com/Proton-Utilities/ProCMP
diff --git a/pipeline/build.bat b/pipeline/build.bat
deleted file mode 100644
index 2897c83..0000000
--- a/pipeline/build.bat
+++ /dev/null
@@ -1,54 +0,0 @@
-@echo off
-setlocal EnableDelayedExpansion
-
-set outputDir=generated
-set exeName=pcmp.exe
-set tempPath=%outputDir%\__temp_release
-set bundlePath=%outputDir%\__bundle.luau
-set darkluaConfig=pipeline\darklua.json
-
-set targets=windows-x86_64 linux-x86_64 linux-aarch64 macos-x86_64 macos-aarch64
-
-:: ── Version ──────────────────────────────────────────────────
-set version=3.1.0
-:: ─────────────────────────────────────────────────────────────
-
-rmdir /s /q %outputDir%
-mkdir %tempPath%
-
-:: ── Bundle src/ into a single file ───────────────────────────
-echo Bundling source with darklua...
-darklua process src/init.luau %bundlePath% -c %darkluaConfig%
-if %errorlevel% neq 0 (
- echo Darklua bundling failed.
- exit /b 1
-)
-echo Bundle complete: %bundlePath%
-:: ─────────────────────────────────────────────────────────────
-
-for %%T in (%targets%) do (
- echo Building for %%T...
-
- for /f "tokens=1,2 delims=-" %%A in ("%%T") do (
- set platform=%%A
- set arch=%%B
- )
-
- set zipName=pcmp-!version!-!platform!-!arch!.zip
- set zipPath=%outputDir%\!zipName!
-
- if exist "!zipPath!" del "!zipPath!"
-
- lune build %bundlePath% -o "%tempPath%\%exeName%" -t !platform!-!arch!
-
- powershell Compress-Archive -Path "%tempPath%\%exeName%" -DestinationPath "!zipPath!"
-
- echo Built: !zipName!
-)
-
-del %bundlePath%
-rmdir /s /q %tempPath%
-
-echo.
-echo All builds complete.
-pause
diff --git a/pipeline/build_debug.bat b/pipeline/build_debug.bat
deleted file mode 100644
index 3fd1e75..0000000
--- a/pipeline/build_debug.bat
+++ /dev/null
@@ -1,46 +0,0 @@
-@echo off
-setlocal EnableDelayedExpansion
-
-set outputDir=generated
-set exeName=pcmp.exe
-set tempPath=%outputDir%\__temp_release
-set bundlePath=%outputDir%\__bundle.luau
-set darkluaConfig=pipeline\darklua.json
-
-set targets=windows-x86_64
-
-rmdir /s /q %outputDir%
-mkdir %tempPath%
-
-echo Bundling source with darklua...
-darklua process src/init.luau %bundlePath% -c %darkluaConfig%
-if %errorlevel% neq 0 (
- echo Darklua bundling failed.
- exit /b 1
-)
-echo Bundle complete: %bundlePath%
-
-for %%T in (%targets%) do (
- echo Building for %%T...
-
- for /f "tokens=1,2 delims=-" %%A in ("%%T") do (
- set platform=%%A
- set arch=%%B
- )
-
- set zipName=pcmp-!version!-!platform!-!arch!.zip
- set zipPath=%outputDir%\pcmp
-
- if exist "!zipPath!" del "!zipPath!"
-
- lune build %bundlePath% -o "!zipPath!" -t !platform!-!arch!
-
- echo Built: !zipName!
-)
-
-del %bundlePath%
-rmdir /s /q %tempPath%
-
-echo.
-echo All builds complete.
-pause
diff --git a/pipeline/darklua.json b/pipeline/darklua.json
deleted file mode 100644
index e72630f..0000000
--- a/pipeline/darklua.json
+++ /dev/null
@@ -1,12 +0,0 @@
-{
- "generator": "retain_lines",
- "bundle": {
- "modules_identifier": "__PCMP",
- "require_mode": {
- "name": "luau",
- "use_luau_configuration": true
- },
- "excludes": ["@lune/**"]
- },
- "rules": []
-}
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index c9698e7..0000000
--- a/requirements.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-mkdocs-material
-mkdocs-awesome-nav
diff --git a/rust-toolchain.toml b/rust-toolchain.toml
new file mode 100644
index 0000000..c7a2241
--- /dev/null
+++ b/rust-toolchain.toml
@@ -0,0 +1,3 @@
+[toolchain]
+channel = "1.90"
+components = ["rustfmt", "clippy"]
diff --git a/rustfmt.toml b/rustfmt.toml
new file mode 100644
index 0000000..2fb1324
--- /dev/null
+++ b/rustfmt.toml
@@ -0,0 +1,4 @@
+edition = "2024"
+max_width = 100
+newline_style = "Unix"
+use_field_init_shorthand = true
diff --git a/src/build.luau b/src/build.luau
deleted file mode 100644
index 6c64289..0000000
--- a/src/build.luau
+++ /dev/null
@@ -1,226 +0,0 @@
---!nocheck
-
---// Imports
-local fs = require("@lune/fs")
-local process = require("@lune/process")
-local dateTime = require("@lune/datetime")
-local util = require("./util")
-
---// Types
-export type HookScript = {
- command: string?,
- script: string?,
- args: { string }?,
- env: { [string]: string }?,
-}
-
-export type TaskHooks = {
- preBuild: HookScript?,
- postBuild: HookScript?,
-}
-
-type BuildTask = {
- name: string,
- input: string,
- output: string,
- frame: string,
- darkluaConfig: string,
- prerelease: boolean?,
- hooks: TaskHooks?,
- [string]: any,
-}
-
---// State
-local currentBuildSource: string? = nil
-local currentBuildConfig: string? = nil
-local currentVersion: string? = nil
-local currentVariant: string? = nil
-local currentInput: string? = nil
-local currentOutput: string? = nil
-local currentPrerelease: boolean? = nil
-
---// Private Methods
-local function resolveTokens(s: string): string
- local resolved = s
- resolved = resolved:gsub("{version}", currentVersion or "")
- resolved = resolved:gsub("{config}", currentBuildConfig or "")
- resolved = resolved:gsub("{variant}", currentVariant or "")
- resolved = resolved:gsub("{input}", currentInput or "")
- resolved = resolved:gsub("{output}", currentOutput or "")
- resolved = resolved:gsub("{file}", currentOutput or "")
- return resolved
-end
-
-local function buildHookEnv(): { [string]: string }
- local env: { [string]: string } = {}
- for k, v in pairs(process.env) do
- env[k] = v
- end
-
- env.PCMP_VERSION = currentVersion or ""
- env.PCMP_CONFIG = currentBuildConfig or ""
- env.PCMP_VARIANT = currentVariant or ""
- env.PCMP_INPUT = currentInput or ""
- env.PCMP_OUTPUT = currentOutput or ""
- env.PCMP_PRERELEASE = currentPrerelease and "true" or "false"
-
- return env
-end
-
-local function runHook(hook: HookScript, label: string)
- local env = buildHookEnv()
- if hook.env then
- for k, v in pairs(hook.env) do
- env[k] = v
- end
- end
-
- local resolvedArgs: { string } = {}
- if hook.args then
- for _, a in ipairs(hook.args) do
- table.insert(resolvedArgs, resolveTokens(a))
- end
- end
-
- local result
- if hook.script then
- local lune_args = { "run", hook.script }
- if #resolvedArgs > 0 then
- table.insert(lune_args, "--")
- for _, a in ipairs(resolvedArgs) do
- table.insert(lune_args, a)
- end
- end
- result = process.exec("lune", lune_args, { env = env })
- elseif hook.command then
- local resolvedCmd = resolveTokens(hook.command)
- local parts: { string } = {}
- for part in resolvedCmd:gmatch("%S+") do
- table.insert(parts, part)
- end
- if #parts == 0 then return end
- local cmd = table.remove(parts, 1) :: string
- for _, a in ipairs(resolvedArgs) do
- table.insert(parts, a)
- end
- result = process.exec(cmd, parts, { env = env })
- else
- return
- end
-
- if not result.ok then
- local output = (result.stderr and result.stderr ~= "") and result.stderr or result.stdout or ""
- error(`[pcmp] Hook '{label}' failed (exit {result.code}):\n{output}`)
- end
-end
-
-local function runHookIfPresent(hook: HookScript?, label: string)
- if hook then
- runHook(hook, label)
- end
-end
-
---// Macros
-local function buildComposerMarkers(): { [string]: () -> string }
- return {
- -- Source
- ["__COMPOSER.Insert(__COMPOSER.build)"] = function()
- return currentBuildSource or ""
- end,
-
- -- Date / time
- ["__COMPOSER.Insert(__COMPOSER.genDate)"] = function()
- return string.format("%q", dateTime.now():toIsoDate())
- end,
- ["__COMPOSER.Insert(__COMPOSER.timestamp)"] = function()
- return tostring(os.time())
- end,
-
- -- Build identity
- ["__COMPOSER.Insert(__COMPOSER.cfg)"] = function()
- return string.format("%q", currentBuildConfig or "")
- end,
- ["__COMPOSER.Insert(__COMPOSER.vers)"] = function()
- return string.format("%q", currentVersion or "")
- end,
- ["__COMPOSER.Insert(__COMPOSER.variant)"] = function()
- return string.format("%q", currentVariant or "")
- end,
-
- -- File paths
- ["__COMPOSER.Insert(__COMPOSER.input)"] = function()
- return string.format("%q", currentInput or "")
- end,
- ["__COMPOSER.Insert(__COMPOSER.output)"] = function()
- return string.format("%q", currentOutput or "")
- end,
-
- -- Release flags
- ["__COMPOSER.Insert(__COMPOSER.prerelease)"] = function()
- return currentPrerelease and "true" or "false"
- end,
-
- -- Runtime / environment
- ["__COMPOSER.Insert(__COMPOSER.os)"] = function()
- return string.format("%q", process.os)
- end,
- ["__COMPOSER.Insert(__COMPOSER.arch)"] = function()
- return string.format("%q", process.arch)
- end,
- }
-end
-
---// Public Methods
-local function setContext(cfg: string?, vers: string?)
- currentBuildConfig = cfg
- currentVersion = vers
-end
-
-local function processFrame(rawFrame: string, builtSource: string): string
- currentBuildSource = builtSource
-
- local markers = buildComposerMarkers()
- local result = rawFrame
- for marker, handler in pairs(markers) do
- local cleanMarker = util.escapePattern(marker)
- result = result:gsub(cleanMarker, handler)
- end
-
- return result
-end
-
-local function runTask(buildTask: BuildTask)
- currentVariant = buildTask.name
- currentInput = buildTask.input
- currentOutput = buildTask.output
- currentPrerelease = buildTask.prerelease
-
- util.ensureDir(util.getDirName(buildTask.output))
-
- runHookIfPresent(buildTask.hooks and buildTask.hooks.preBuild, `preBuild:{buildTask.name}`)
-
- local result = process.exec("darklua", {
- "process",
- buildTask.input,
- buildTask.output,
- "-c",
- buildTask.darkluaConfig,
- })
-
- if not result.ok then
- error(result.stderr or "darklua returned a non-zero exit code")
- end
-
- local builtSource = fs.readFile(buildTask.output)
- local rawFrame = fs.readFile(buildTask.frame)
- local composed = processFrame(rawFrame, builtSource)
- fs.writeFile(buildTask.output, composed)
-
- runHookIfPresent(buildTask.hooks and buildTask.hooks.postBuild, `postBuild:{buildTask.name}`)
-end
-
-return {
- setContext = setContext,
- runTask = runTask,
- __runHook = runHook,
-}
diff --git a/src/build/commit.rs b/src/build/commit.rs
new file mode 100644
index 0000000..5aadd24
--- /dev/null
+++ b/src/build/commit.rs
@@ -0,0 +1,101 @@
+//! Getting artifacts onto disk, and taking away what is no longer one.
+//!
+//! Three rules, each closing one way an artifact can end up wrong.
+//!
+//! Write only what differs, so an unchanged artifact keeps its mtime and nothing
+//! downstream rebuilds for no reason. Write atomically, so an interrupted build leaves the
+//! previous artifact whole rather than half a file. And remove only what a *previous
+//! record* says this task wrote, never "everything under the output", so a directory
+//! output stops accumulating files whose sources were deleted, without `pcmp` ever
+//! deleting something it did not create.
+
+use std::collections::BTreeMap;
+
+use crate::report::Diagnostic;
+use crate::vfs::{self, AbsPath, Digest, RelPath, digest};
+
+/// `luau` and `lua`, or the single extension `darklua.lua_extension` names.
+///
+/// A header is a Lua comment, so it goes on Lua source and nowhere else. A `copy` loader
+/// can put a `.png` in the output tree, and a `.png` with a Lua comment on the front is a
+/// broken `.png`.
+pub fn headable(lua_extension: Option<&str>) -> Vec {
+ match lua_extension {
+ Some(extension) => vec![extension.to_owned()],
+ None => vec!["luau".to_owned(), "lua".to_owned()],
+ }
+}
+
+/// Prepends the header to every artifact it applies to, in memory.
+///
+/// In memory is the point. Composing after the write would mean re-reading the output
+/// directory and prepending to whatever is in it, which gives a file that survives from an
+/// earlier build one banner per build.
+pub fn compose(
+ artifacts: &mut BTreeMap>,
+ header: &[String],
+ extensions: &[String],
+) {
+ if header.is_empty() {
+ return;
+ }
+
+ let mut banner = header.join("\n");
+ banner.push('\n');
+
+ for (path, bytes) in artifacts.iter_mut() {
+ let applies = path
+ .extension()
+ .is_some_and(|extension| extensions.iter().any(|allowed| allowed == extension));
+
+ if applies {
+ let mut composed = banner.clone().into_bytes();
+ composed.append(bytes);
+ *bytes = composed;
+ }
+ }
+}
+
+pub fn fingerprint(artifacts: &BTreeMap>) -> Digest {
+ digest::of_files("artifacts", artifacts)
+}
+
+/// What is on disk now, for the digest that notices a hand-edited artifact.
+pub fn current(root: &AbsPath, outputs: &[RelPath]) -> BTreeMap> {
+ outputs
+ .iter()
+ .filter_map(|path| {
+ let absolute = root.join(path.as_str()).ok()?;
+ let bytes = vfs::read(&absolute).ok()?;
+ Some((path.clone(), bytes))
+ })
+ .collect()
+}
+
+/// Writes what changed and removes what this task no longer produces.
+pub fn write(
+ root: &AbsPath,
+ artifacts: &BTreeMap>,
+ previous: &[RelPath],
+) -> Result<(), Diagnostic> {
+ for (path, bytes) in artifacts {
+ let absolute = root.join(path.as_str())?;
+
+ // Reading to compare costs one read of a file already in the page cache. Writing
+ // when nothing changed costs every watcher downstream a rebuild.
+ if vfs::read(&absolute).is_ok_and(|existing| existing == *bytes) {
+ continue;
+ }
+
+ vfs::write(&absolute, bytes)?;
+ }
+
+ for stale in previous {
+ if artifacts.contains_key(stale) {
+ continue;
+ }
+ vfs::remove_file(&root.join(stale.as_str())?)?;
+ }
+
+ Ok(())
+}
diff --git a/src/build/inputs.rs b/src/build/inputs.rs
new file mode 100644
index 0000000..a4b51a8
--- /dev/null
+++ b/src/build/inputs.rs
@@ -0,0 +1,196 @@
+//! What counts as a build input, and the two digests that decide whether it changed.
+//!
+//! Two tiers, because one is unsound. A build can depend on a file's *absence*: if
+//! `require("./mod")` resolves to `mod.luau` and someone adds `mod/init.luau`, resolution
+//! changes although no recorded file did. darklua does not report the paths it tried, so
+//! **shape** covers every path under the roots without reading any of them, and **reads**
+//! covers the contents of the files darklua actually opened.
+//!
+//! Shape reads no file contents, only names. That is what keeps a no-op rebuild flat in
+//! the size of the repository rather than linear in it: a directory holding 460 MB costs
+//! one `readdir` per directory it contains, not 460 MB of hashing.
+
+use std::collections::{BTreeMap, BTreeSet};
+
+use wax::{Glob, Program};
+
+use crate::plan::{Plan, Task};
+use crate::report::{Code, Diagnostic};
+use crate::vfs::{self, AbsPath, Digest, Hasher, Kind, RelPath, digest, walk};
+
+/// Version-control metadata, never an input.
+const VCS: &str = ".git";
+
+/// The roots one task draws from, with what it excludes.
+///
+/// Per task, because a per-profile `ignore` applied to every profile is how the old
+/// design shipped stale artifacts. Shape is memoised on this, so a matrix whose tasks
+/// share roots and exclusions walks once rather than once per combination.
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
+pub struct Scope {
+ roots: Vec,
+ ignore: Vec,
+ excluded: Vec,
+}
+
+impl Scope {
+ /// `plan` is the whole plan rather than the selection: every task's output is
+ /// excluded from every scope, so building one task cannot move another's shape.
+ pub fn of(task: &Task, plan: &Plan, cache: Option<&RelPath>) -> Self {
+ let mut excluded: BTreeSet = plan
+ .tasks
+ .iter()
+ .map(|other| other.output.clone())
+ .collect();
+ excluded.extend(cache.cloned());
+
+ let mut roots = task.sources.clone();
+ roots.sort();
+ roots.dedup();
+
+ Self {
+ roots,
+ ignore: task.ignore.clone(),
+ excluded: excluded.into_iter().collect(),
+ }
+ }
+
+ /// The manifest's directory is always a root, and `sources` adds to it.
+ ///
+ /// Each root carries the prefix that names it relative to the manifest, so a file
+ /// under `../shared` gets a name that is still relative to the manifest and still the
+ /// same on every machine. Going through an absolute path and back would lose that,
+ /// because a root beside the manifest is not below it.
+ fn directories(&self, root: &AbsPath) -> Vec<(Option<&RelPath>, AbsPath)> {
+ let mut directories = vec![(None, root.clone())];
+
+ for source in &self.roots {
+ if let Ok(absolute) = root.join(source.as_str()) {
+ directories.push((Some(source), absolute));
+ }
+ }
+
+ directories
+ }
+
+ fn globs(&self) -> Result>, Diagnostic> {
+ self.ignore
+ .iter()
+ .map(|pattern| {
+ Glob::new(pattern).map(Glob::into_owned).map_err(|error| {
+ Diagnostic::new(Code::BadGlob, format!("`{pattern}` is not a valid glob"))
+ .help(error.to_string())
+ })
+ })
+ .collect()
+ }
+}
+
+/// Answers "did any file appear, vanish or move?" without opening one.
+///
+/// Files and symlinks only. A directory that holds nothing cannot change a build, and
+/// counting them would make every output directory a change the moment it is first
+/// created, so the first build after a clean checkout would never be cacheable. The
+/// negative-dependency case that shape exists for is a *file* appearing: adding
+/// `mod/init.luau` beside `mod.luau` moves the digest because the file does.
+///
+/// A symlink's target is part of its entry, so retargeting one, which changes a build
+/// while changing no file, still moves the digest.
+pub fn shape(scope: &Scope, root: &AbsPath) -> Result {
+ let globs = scope.globs()?;
+ let mut entries: BTreeMap = BTreeMap::new();
+
+ for (prefix, directory) in scope.directories(root) {
+ let found = walk::walk(&directory, &|path, _| keep(scope, &globs, path))?;
+ for (path, kind) in found {
+ if kind != Kind::Dir {
+ entries.insert(named(prefix, &path).to_string(), kind.tag());
+ }
+ }
+ }
+
+ let mut hasher = Hasher::new();
+ hasher.seq(
+ "shape",
+ entries.iter().map(|(path, kind)| format!("{path}\0{kind}")),
+ );
+ Ok(hasher.finish())
+}
+
+/// Answers "did a file we read change?".
+///
+/// A cold build has no recorded read set and falls back to every file in scope. That is
+/// the one build that reads everything, and the record it writes means the next one does
+/// not.
+pub fn reads(
+ scope: &Scope,
+ root: &AbsPath,
+ recorded: Option<&[RelPath]>,
+) -> Result<(Digest, BTreeMap>), Diagnostic> {
+ let paths = match recorded {
+ Some(recorded) => recorded.to_vec(),
+ None => everything(scope, root)?,
+ };
+
+ let mut contents = BTreeMap::new();
+ for path in paths {
+ let Ok(absolute) = root.join(path.as_str()) else {
+ continue;
+ };
+ // A recorded input that has since been deleted is a change, not a failure: the
+ // shape digest will have moved too, and the rebuild will find out why.
+ if let Ok(bytes) = vfs::read(&absolute) {
+ contents.insert(path, bytes);
+ }
+ }
+
+ Ok((fingerprint(&contents), contents))
+}
+
+/// The reads digest, over whatever set of files is handed to it.
+///
+/// A cold build has to over-approximate, so it hashes everything in scope, so the record it
+/// then writes must hold the digest of the set darklua *actually* read, or the next build
+/// would compare a digest over one set against a digest over another and rebuild forever.
+pub fn fingerprint(contents: &BTreeMap>) -> Digest {
+ digest::of_files("reads", contents)
+}
+
+/// Every file in scope, which is what a cold build has to stage.
+pub fn everything(scope: &Scope, root: &AbsPath) -> Result, Diagnostic> {
+ let globs = scope.globs()?;
+ let mut found = BTreeSet::new();
+
+ for (prefix, directory) in scope.directories(root) {
+ for (path, kind) in walk::walk(&directory, &|path, _| keep(scope, &globs, path))? {
+ if kind == Kind::File {
+ found.insert(named(prefix, &path));
+ }
+ }
+ }
+
+ Ok(found.into_iter().collect())
+}
+
+/// One name per file, relative to the manifest, however many roots overlap.
+fn named(prefix: Option<&RelPath>, path: &RelPath) -> RelPath {
+ match prefix {
+ Some(prefix) => prefix.join(path.as_str()),
+ None => path.clone(),
+ }
+}
+
+fn keep(scope: &Scope, globs: &[Glob<'static>], path: &RelPath) -> bool {
+ if path.has_segment(VCS) {
+ return false;
+ }
+ if scope
+ .excluded
+ .iter()
+ .any(|excluded| path.starts_with(excluded))
+ {
+ return false;
+ }
+
+ !globs.iter().any(|glob| glob.is_match(path.as_str()))
+}
diff --git a/src/build/mod.rs b/src/build/mod.rs
new file mode 100644
index 0000000..639bc92
--- /dev/null
+++ b/src/build/mod.rs
@@ -0,0 +1,385 @@
+//! Execution: a [`Plan`] in, artifacts out.
+//!
+//! A task is skipped only when all four digests match its record: configuration, shape,
+//! reads and artifacts. The fourth is the one that notices an artifact edited by hand,
+//! which an inputs-only stamp never could.
+
+pub mod commit;
+pub mod inputs;
+pub mod record;
+pub mod stage;
+pub mod watch;
+
+use std::collections::BTreeMap;
+use std::sync::Mutex;
+use std::time::Instant;
+
+use darklua_core::Options;
+
+use crate::plan::{Plan, Task, TaskId};
+use crate::report::{Code, Diagnostic};
+use crate::vfs::{self, AbsPath, Digest, RelPath};
+
+use inputs::Scope;
+use record::{Reason, Record};
+
+/// Where build state lives when `--cache-dir` is not given.
+pub const CACHE_DIR: &str = ".pcmp";
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Status {
+ Built,
+ Cached,
+ Failed,
+}
+
+#[derive(Debug, serde::Serialize)]
+pub struct TaskReport {
+ pub task: TaskId,
+ pub output: RelPath,
+ pub status: Status,
+ /// Absent when the task failed, which is the only time there is nothing to name.
+ pub artifacts: Option,
+ /// Why it rebuilt, which `--why` prints and a cached task does not have.
+ pub why: Option,
+ /// Printed only under `--timings`, in either output mode: a report has to be
+ /// byte-identical for the same build or it cannot be diffed.
+ #[serde(skip)]
+ pub millis: u128,
+ /// Why it failed, in the same shape every other diagnostic uses.
+ #[serde(skip_serializing_if = "Vec::is_empty")]
+ pub diagnostics: Vec,
+}
+
+#[derive(Debug, serde::Serialize)]
+pub struct Report {
+ pub tasks: Vec,
+}
+
+impl Report {
+ pub fn succeeded(&self) -> bool {
+ !self.tasks.iter().any(|task| task.status == Status::Failed)
+ }
+
+ /// Built, cached, failed.
+ pub fn counts(&self) -> (usize, usize, usize) {
+ self.tasks
+ .iter()
+ .fold((0, 0, 0), |(built, cached, failed), task| {
+ match task.status {
+ Status::Built => (built + 1, cached, failed),
+ Status::Cached => (built, cached + 1, failed),
+ Status::Failed => (built, cached, failed + 1),
+ }
+ })
+ }
+}
+
+#[derive(Debug)]
+pub struct Engine {
+ root: AbsPath,
+ cache: AbsPath,
+ /// `--frozen` and `--no-cache` both turn this off.
+ cached: bool,
+}
+
+/// Whether a run acts on what it decides, or only reports it.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum Work {
+ Do,
+ Skip,
+}
+
+impl Engine {
+ pub fn new(root: AbsPath, cache: AbsPath, cached: bool) -> Self {
+ Self {
+ root,
+ cache,
+ cached,
+ }
+ }
+
+ /// Reports what each task would do without doing any of it, for `plan --why`.
+ ///
+ /// Every digest is still computed, and only the work is skipped.
+ pub fn inspect(&self, plan: &Plan, selection: &Plan) -> Report {
+ self.execute(plan, selection, Work::Skip)
+ }
+
+ /// Failures land in the report rather than returning early, so one run names every
+ /// task that went wrong.
+ pub fn run(&self, plan: &Plan, selection: &Plan) -> Report {
+ self.execute(plan, selection, Work::Do)
+ }
+
+ fn execute(&self, plan: &Plan, selection: &Plan, work: Work) -> Report {
+ // `None` when the cache lives outside the manifest, in which case it is already
+ // outside every root and needs no excluding.
+ let cache = self.cache.relative_to(&self.root);
+
+ // Shape is a pure function of a scope. Combinations of one profile share a scope
+ // unless an axis overlay changes `sources` or `ignore`, so this is computed once
+ // per distinct scope rather than once per task.
+ let shapes: Mutex> = Mutex::new(BTreeMap::new());
+
+ let mut tasks: Vec = std::thread::scope(|threads| {
+ let handles: Vec<_> = selection
+ .tasks
+ .iter()
+ .map(|task| {
+ let shapes = &shapes;
+ let cache = cache.as_ref();
+ threads.spawn(move || self.one(task, plan, cache, shapes, work))
+ })
+ .collect();
+
+ handles
+ .into_iter()
+ .filter_map(|handle| handle.join().ok())
+ .collect()
+ });
+
+ tasks.sort_by(|a, b| a.task.cmp(&b.task));
+ Report { tasks }
+ }
+
+ fn one(
+ &self,
+ task: &Task,
+ plan: &Plan,
+ cache: Option<&RelPath>,
+ shapes: &Mutex>,
+ work: Work,
+ ) -> TaskReport {
+ let started = Instant::now();
+ let scope = Scope::of(task, plan, cache);
+
+ let outcome = self.attempt(task, &scope, shapes, work);
+ let (status, artifacts, why, diagnostics) = match outcome {
+ Ok(Skipped { artifacts }) => (Status::Cached, Some(artifacts), None, Vec::new()),
+ Ok(Rebuilt { artifacts, why }) => {
+ (Status::Built, Some(artifacts), Some(why), Vec::new())
+ }
+ Err(diagnostic) => (Status::Failed, None, None, vec![diagnostic]),
+ };
+
+ TaskReport {
+ task: task.id.clone(),
+ output: task.output.clone(),
+ status,
+ artifacts,
+ why,
+ millis: started.elapsed().as_millis(),
+ diagnostics,
+ }
+ }
+
+ fn attempt(
+ &self,
+ task: &Task,
+ scope: &Scope,
+ shapes: &Mutex>,
+ work: Work,
+ ) -> Result {
+ let record = Record::load(&self.cache, task.id.as_str());
+ let shape = self.shape(scope, shapes)?;
+ let plan_digest = task.digest();
+
+ let recorded_reads = record.as_ref().map(|record| record.read_set.as_slice());
+ let (reads, _) = inputs::reads(scope, &self.root, recorded_reads)?;
+
+ let previous: &[RelPath] = record.as_ref().map_or(&[], |record| &record.outputs);
+ let on_disk = commit::fingerprint(&commit::current(&self.root, previous));
+
+ let stale = Record::stale(record.as_ref(), plan_digest, shape, reads, on_disk);
+
+ if self.cached && stale.is_none() {
+ return Ok(Skipped { artifacts: on_disk });
+ }
+
+ let why = stale.unwrap_or(Reason::NoRecord);
+ if work == Work::Skip {
+ return Ok(Rebuilt {
+ artifacts: on_disk,
+ why,
+ });
+ }
+
+ let artifacts = self.build(task, scope, plan_digest, shape, previous)?;
+ Ok(Rebuilt { artifacts, why })
+ }
+
+ fn shape(
+ &self,
+ scope: &Scope,
+ shapes: &Mutex>,
+ ) -> Result {
+ if let Ok(cached) = shapes.lock()
+ && let Some(digest) = cached.get(scope)
+ {
+ return Ok(*digest);
+ }
+
+ let digest = inputs::shape(scope, &self.root)?;
+
+ if let Ok(mut cached) = shapes.lock() {
+ cached.insert(scope.clone(), digest);
+ }
+
+ Ok(digest)
+ }
+
+ fn build(
+ &self,
+ task: &Task,
+ scope: &Scope,
+ plan: Digest,
+ shape: Digest,
+ previous: &[RelPath],
+ ) -> Result {
+ let entry = self.root.join(task.entry.as_str())?;
+ let output = self.root.join(task.output.as_str())?;
+
+ if !vfs::exists(&entry) {
+ return Err(Diagnostic::new(
+ Code::MissingEntryFile,
+ format!("`{}` does not exist", task.entry),
+ )
+ .help("the path resolves against the manifest's directory"));
+ }
+
+ // A rebuild stages everything in scope rather than the recorded read set: the set
+ // may have grown, and a file that is not staged cannot be opened.
+ let (_, contents) = inputs::reads(scope, &self.root, None)?;
+ let staged: Vec = contents.keys().cloned().collect();
+ let resources = stage::inputs(&self.root, &contents)?;
+
+ let worked = darklua_core::process(
+ &resources,
+ Options::new(entry.as_std())
+ .with_output(output.as_std())
+ .with_configuration(task.config.build(task.id.as_str())?),
+ )
+ .map_err(|error| failed(task, &error.to_string()))?;
+
+ // `process` reports per-file failures inside the returned tree rather than as an
+ // `Err`, so a build that produced nothing would otherwise look successful.
+ let errors = worked.collect_errors();
+ if !errors.is_empty() {
+ let detail = errors
+ .iter()
+ .map(ToString::to_string)
+ .collect::>()
+ .join("\n");
+ return Err(failed(task, &detail));
+ }
+
+ let mut artifacts = stage::outputs(&resources, &self.root, &output);
+ if artifacts.is_empty() {
+ return Err(Diagnostic::new(
+ Code::NoOutput,
+ format!("`{}` reported no failure but wrote nothing", task.id),
+ )
+ .help(
+ "a file filter that matches nothing is the usual cause: `apply_to_files` and \
+ `skip_files` match each file's path relative to the entry",
+ ));
+ }
+
+ commit::compose(
+ &mut artifacts,
+ &task.header,
+ &commit::headable(lua_extension(task)),
+ );
+
+ let digest = commit::fingerprint(&artifacts);
+ commit::write(&self.root, &artifacts, previous)?;
+
+ let read_set = read_set(&worked, &self.root, &task.entry, &staged);
+ let outputs: Vec = artifacts.keys().cloned().collect();
+
+ // Over the set darklua read, not the set that was staged. Those differ on a cold
+ // build, and recording the wrong one is a cache that never hits.
+ let read: BTreeMap> = contents
+ .into_iter()
+ .filter(|(path, _)| read_set.contains(path))
+ .collect();
+
+ Record::new(
+ plan,
+ shape,
+ inputs::fingerprint(&read),
+ digest,
+ read_set,
+ outputs,
+ )
+ .save(&self.cache, task.id.as_str())?;
+
+ Ok(digest)
+ }
+}
+
+enum Outcome {
+ Skipped { artifacts: Digest },
+ Rebuilt { artifacts: Digest, why: Reason },
+}
+
+use Outcome::{Rebuilt, Skipped};
+
+/// What darklua opened: the modules it followed, plus the entry tree it walked.
+fn read_set(
+ worked: &darklua_core::WorkerTree,
+ root: &AbsPath,
+ entry: &RelPath,
+ staged: &[RelPath],
+) -> Vec {
+ let mut found: Vec = worked
+ .iter_external_dependencies()
+ .filter_map(|path| path.to_str())
+ .filter_map(|path| AbsPath::new(path).ok())
+ .filter_map(|path| path.relative_to(root))
+ .collect();
+
+ // A directory entry is processed file by file rather than followed, so those are not
+ // external dependencies, but they are certainly reads.
+ found.extend(
+ staged
+ .iter()
+ .filter(|path| path.starts_with(entry))
+ .cloned(),
+ );
+
+ found.sort();
+ found.dedup();
+ found
+}
+
+/// darklua's own `lua_extension`, so the header applies to the extension darklua writes
+/// rather than to a list this crate keeps of its own.
+fn lua_extension(task: &Task) -> Option<&str> {
+ task.config
+ .rest
+ .get("lua_extension")
+ .and_then(serde_json::Value::as_str)
+}
+
+/// Split on darklua's own wording, which is the only signal it gives. A reword upstream
+/// would silently reclassify every missing dependency as a generic failure, so this
+/// string is checked against darklua when the `=` pin in `Cargo.toml` moves.
+fn failed(task: &Task, detail: &str) -> Diagnostic {
+ // An unstaged file is not a transformation failure. It is a dependency nobody
+ // declared, and saying so is the whole point of staging.
+ let code = if detail.contains("unable to find") {
+ Code::UndeclaredInput
+ } else {
+ Code::ProcessFailed
+ };
+
+ let diagnostic = Diagnostic::new(code, format!("`{}` failed", task.id)).help(detail.to_owned());
+
+ match code {
+ Code::UndeclaredInput => diagnostic.help("add the directory that holds it to `sources`"),
+ _ => diagnostic,
+ }
+}
diff --git a/src/build/record.rs b/src/build/record.rs
new file mode 100644
index 0000000..c0afc03
--- /dev/null
+++ b/src/build/record.rs
@@ -0,0 +1,198 @@
+//! What a build wrote down, in two places for two reasons.
+//!
+//! `.pcmp/` is a local cache: disposable, gitignored, one record per task. `pcmp.lock` is
+//! provenance: committed, and what `--frozen` reproduces. Keeping them apart is what lets
+//! the lock stay still while the cache churns. A manifest that calls `pcmp.now()` would
+//! otherwise rewrite the lock on every invocation.
+
+use std::collections::BTreeMap;
+
+use serde::{Deserialize, Serialize};
+
+use crate::manifest::ledger::Ledger;
+use crate::report::Diagnostic;
+use crate::vfs::{self, AbsPath, Digest, RelPath, digest};
+
+/// Bumped when the shape of a record changes.
+///
+/// A record that does not match is ignored rather than migrated. Ignoring one costs a
+/// rebuild. Migrating one wrongly costs a wrong artifact that nothing will notice.
+const VERSION: u32 = 1;
+
+/// One task's last successful build.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Record {
+ pub version: u32,
+ pub plan: Digest,
+ pub shape: Digest,
+ pub reads: Digest,
+ pub artifacts: Digest,
+ /// The linked darklua. A patch release can change emitted bytes.
+ pub darklua: String,
+ /// What darklua actually opened, so the next build hashes only these.
+ pub read_set: Vec,
+ /// What this task wrote, so a later build can remove what it no longer writes,
+ /// and only that. `pcmp` never deletes a file it did not create.
+ pub outputs: Vec,
+}
+
+/// Which digest moved, for `plan --why`.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum Reason {
+ NoRecord,
+ Version,
+ Darklua,
+ Plan,
+ Shape,
+ Reads,
+ Artifacts,
+}
+
+impl Reason {
+ pub const fn describe(self) -> &'static str {
+ match self {
+ Self::NoRecord => "no previous build",
+ Self::Version => "the record predates this version of pcmp",
+ Self::Darklua => "a different darklua",
+ Self::Plan => "the task's configuration changed",
+ Self::Shape => "a file appeared, vanished or moved",
+ Self::Reads => "a source file changed",
+ Self::Artifacts => "the artifacts on disk are not the ones pcmp wrote",
+ }
+ }
+}
+
+impl Record {
+ /// [`None`] when nothing needs doing. Everything must match: the artifacts digest is
+ /// what notices a hand-edited output, which an inputs-only stamp never could.
+ pub fn stale(
+ current: Option<&Self>,
+ plan: Digest,
+ shape: Digest,
+ reads: Digest,
+ artifacts: Digest,
+ ) -> Option {
+ let Some(record) = current else {
+ return Some(Reason::NoRecord);
+ };
+
+ if record.version != VERSION {
+ return Some(Reason::Version);
+ }
+ if record.darklua != crate::DARKLUA {
+ return Some(Reason::Darklua);
+ }
+ if record.plan != plan {
+ return Some(Reason::Plan);
+ }
+ if record.shape != shape {
+ return Some(Reason::Shape);
+ }
+ if record.reads != reads {
+ return Some(Reason::Reads);
+ }
+ if record.artifacts != artifacts {
+ return Some(Reason::Artifacts);
+ }
+
+ None
+ }
+
+ pub fn new(
+ plan: Digest,
+ shape: Digest,
+ reads: Digest,
+ artifacts: Digest,
+ read_set: Vec,
+ outputs: Vec,
+ ) -> Self {
+ Self {
+ version: VERSION,
+ plan,
+ shape,
+ reads,
+ artifacts,
+ darklua: crate::DARKLUA.to_owned(),
+ read_set,
+ outputs,
+ }
+ }
+
+ /// Named by digest: a task identifier holds `[`, `]`, `=` and `,`.
+ fn path(cache: &AbsPath, task: &str) -> Result {
+ cache.join(format!("{}.json", digest::of(task)))
+ }
+
+ /// A missing or unreadable record reads as "never built", which is always safe.
+ pub fn load(cache: &AbsPath, task: &str) -> Option {
+ let path = Self::path(cache, task).ok()?;
+ let bytes = vfs::read(&path).ok()?;
+ serde_json::from_slice(&bytes).ok()
+ }
+
+ pub fn save(&self, cache: &AbsPath, task: &str) -> Result<(), Diagnostic> {
+ let path = Self::path(cache, task)?;
+ let body = serde_json::to_vec_pretty(self).map_err(|error| {
+ Diagnostic::new(
+ crate::report::Code::WriteFailed,
+ format!("could not encode the record for `{task}`"),
+ )
+ .caused_by(error)
+ })?;
+
+ vfs::write(&path, &body)
+ }
+}
+
+/// The committed side: what a release was built from, and what it produced.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Lock {
+ pub version: u32,
+ /// Every value the manifest took from outside itself, including the clock.
+ pub ledger: Ledger,
+ pub tasks: BTreeMap,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct Locked {
+ pub plan: Digest,
+ pub artifacts: Digest,
+}
+
+impl Lock {
+ pub const NAME: &'static str = "pcmp.lock";
+
+ pub fn new(ledger: Ledger, tasks: BTreeMap) -> Self {
+ Self {
+ version: VERSION,
+ ledger,
+ tasks,
+ }
+ }
+
+ pub fn path(root: &AbsPath) -> Result {
+ root.join(Self::NAME)
+ }
+
+ pub fn load(root: &AbsPath) -> Option {
+ let path = Self::path(root).ok()?;
+ let bytes = vfs::read(&path).ok()?;
+ serde_json::from_slice(&bytes).ok()
+ }
+
+ /// Written only on `--lock`, and formatted so a diff shows what changed about a build.
+ pub fn save(&self, root: &AbsPath) -> Result<(), Diagnostic> {
+ let path = Self::path(root)?;
+ let mut body = serde_json::to_vec_pretty(self).map_err(|error| {
+ Diagnostic::new(
+ crate::report::Code::WriteFailed,
+ "could not encode pcmp.lock",
+ )
+ .caused_by(error)
+ })?;
+ body.push(b'\n');
+
+ vfs::write(&path, &body)
+ }
+}
diff --git a/src/build/stage.rs b/src/build/stage.rs
new file mode 100644
index 0000000..5c8b7f0
--- /dev/null
+++ b/src/build/stage.rs
@@ -0,0 +1,73 @@
+//! Handing darklua exactly what the manifest declared, and nothing else.
+//!
+//! The virtual filesystem mirrors the real absolute layout, because darklua resolves a
+//! require by doing parent and prefix arithmetic on paths. Mirroring is what makes the
+//! in-memory build produce the same bytes as a filesystem one, what makes
+//! `sources: ["../shared"]` work without a translation table, and what keeps darklua's own
+//! error messages truthful.
+//!
+//! Absolute paths therefore exist here and nowhere else in the crate.
+//!
+//! Staging is what makes hermeticity a property rather than a promise: a file that is not
+//! staged cannot be opened, so an undeclared dependency is a named error instead of a
+//! silent influence on the output.
+
+use std::collections::BTreeMap;
+
+use darklua_core::Resources;
+
+use crate::report::{Code, Diagnostic};
+use crate::vfs::{AbsPath, RelPath};
+
+/// The staged input set for one task.
+///
+/// Each task gets its own, never a clone of another's: `Resources` is `Clone` and its
+/// memory variant holds an `Arc>`, so a clone would share one store and let one
+/// task's output become another's input.
+pub fn inputs(
+ root: &AbsPath,
+ contents: &BTreeMap>,
+) -> Result {
+ let resources = Resources::from_memory();
+
+ for (path, bytes) in contents {
+ let absolute = root.join(path.as_str())?;
+ resources
+ .write_bytes(absolute.as_std(), bytes)
+ // `ResourceError` implements neither `Display` nor `Error`, so there is
+ // nothing to attach beyond the path, and an in-memory write has one way to
+ // fail, which the path already names.
+ .map_err(|_| Diagnostic::new(Code::WriteFailed, format!("could not stage `{path}`")))?;
+ }
+
+ Ok(resources)
+}
+
+/// Everything the task produced, named relative to the manifest.
+///
+/// Reading the artifacts back out of memory is what lets headers be composed, digests be
+/// taken and stale files be spotted before anything touches the disk, so a failed task
+/// leaves the previous artifact exactly as it was.
+pub fn outputs(
+ resources: &Resources,
+ root: &AbsPath,
+ output: &AbsPath,
+) -> BTreeMap> {
+ let mut produced = BTreeMap::new();
+
+ for path in resources.walk(output.as_std()) {
+ let Some(text) = path.to_str().and_then(|text| AbsPath::new(text).ok()) else {
+ continue;
+ };
+ let Some(relative) = text.relative_to(root) else {
+ continue;
+ };
+ let Ok(bytes) = resources.get_bytes(&path) else {
+ continue;
+ };
+
+ produced.insert(relative, bytes);
+ }
+
+ produced
+}
diff --git a/src/build/watch.rs b/src/build/watch.rs
new file mode 100644
index 0000000..af58646
--- /dev/null
+++ b/src/build/watch.rs
@@ -0,0 +1,144 @@
+//! Rebuilding on change.
+
+use std::sync::mpsc;
+use std::time::Duration;
+
+use notify::{EventKind, RecursiveMode};
+use notify_debouncer_full::new_debouncer;
+
+use super::Engine;
+use crate::cli::render;
+use crate::plan::Plan;
+use crate::report::{Code, Diagnostic, Exit};
+use crate::vfs::AbsPath;
+
+/// A save is a create, a write and a rename, or a truncate and a write. A formatter on
+/// save doubles that. One debounce window turns the lot into one rebuild.
+const SETTLE: Duration = Duration::from_millis(200);
+
+/// Watches the manifest's directory and every extra root the selection declares.
+///
+/// The plan is resolved once, at startup. Re-reading it each cycle would mean a half-saved
+/// manifest producing a run of parse errors, so a manifest edit is reported and otherwise
+/// ignored until the process is restarted.
+pub fn run(
+ root: &AbsPath,
+ cache: &AbsPath,
+ manifest: &AbsPath,
+ plan: &Plan,
+ selection: &Plan,
+ emit: bool,
+ timings: bool,
+) -> Result {
+ let (sender, receiver) = mpsc::channel();
+ let mut debouncer = new_debouncer(SETTLE, None, sender).map_err(|error| {
+ Diagnostic::new(Code::Unreadable, "could not watch the filesystem").help(error.to_string())
+ })?;
+
+ let mut roots = vec![root.clone()];
+ roots.extend(
+ selection
+ .tasks
+ .iter()
+ .flat_map(|task| &task.sources)
+ .filter_map(|source| root.join(source.as_str()).ok()),
+ );
+ roots.sort();
+ roots.dedup();
+
+ for directory in &roots {
+ debouncer
+ .watch(directory.as_std(), RecursiveMode::Recursive)
+ .map_err(|error| {
+ Diagnostic::new(Code::Unreadable, format!("could not watch `{directory}`"))
+ .help(error.to_string())
+ })?;
+ }
+
+ // What a build writes itself. Without this the first cycle's own records and artifacts
+ // arrive as events and set off a cascade of cycles that find nothing to do.
+ let mut ours = vec![cache.clone()];
+ ours.extend(
+ plan.tasks
+ .iter()
+ .filter_map(|task| root.join(task.output.as_str()).ok()),
+ );
+
+ let engine = Engine::new(root.clone(), cache.clone(), true);
+ let cycle = || render::build(&engine.run(plan, selection), emit, timings, false);
+
+ // Nothing else says the process is alive or which directories reach it, and `--json`
+ // is a stream of build reports that a banner would make unparseable.
+ if !emit {
+ let listed: Vec = roots.iter().map(AbsPath::to_string).collect();
+ render::watching(&listed, plan);
+ }
+
+ // Watching is established first, so an edit made during the first build is queued
+ // rather than lost.
+ cycle();
+
+ for batch in receiver {
+ // One blank line, so consecutive rounds do not read as a single report.
+ let separated = || {
+ if !emit {
+ render::line("");
+ }
+ cycle();
+ };
+
+ // A failed batch means lost events, and a rebuild is cheaper than a wrong answer.
+ let Ok(events) = batch else {
+ separated();
+ continue;
+ };
+
+ let touched: Vec<&std::path::Path> = events
+ .iter()
+ .filter(|event| changed(event.kind))
+ .flat_map(|event| &event.paths)
+ .map(std::path::PathBuf::as_path)
+ .collect();
+
+ // Rebuilding with the plan from startup would silently ignore the edit, so the
+ // one thing to do about a changed manifest is say so.
+ if touched.iter().any(|path| *path == manifest.as_std()) {
+ render::problem(format!(
+ "note: `{manifest}` changed. This run is using the plan it had at startup, \
+ so restart `pcmp watch` to pick the edit up"
+ ));
+ }
+
+ if touched.iter().any(|path| ours_not(path, &ours)) {
+ separated();
+ }
+ }
+
+ Ok(Exit::Success)
+}
+
+/// Whether a changed path is something the project owns rather than something a build
+/// just produced.
+fn ours_not(path: &std::path::Path, ours: &[AbsPath]) -> bool {
+ let Some(path) = path.to_str().and_then(|text| AbsPath::new(text).ok()) else {
+ return false;
+ };
+ if path.as_str().contains("/.git/")
+ || path
+ .file_name()
+ .is_some_and(|name| name.starts_with(crate::vfs::TEMPORARY))
+ {
+ return false;
+ }
+
+ !ours.iter().any(|written| path.is_under(written))
+}
+
+/// Reads are excluded: fingerprinting opens every input on each build.
+fn changed(kind: EventKind) -> bool {
+ match kind {
+ EventKind::Create(_) | EventKind::Remove(_) => true,
+ EventKind::Modify(modify) => !matches!(modify, notify::event::ModifyKind::Metadata(_)),
+ _ => false,
+ }
+}
diff --git a/src/cli/mod.rs b/src/cli/mod.rs
new file mode 100644
index 0000000..c0a664a
--- /dev/null
+++ b/src/cli/mod.rs
@@ -0,0 +1,523 @@
+//! Argument parsing and dispatch.
+//!
+//! `clap` already prints the flag reference and `pcmp explain` already prints the
+//! diagnostic reference, so neither is restated in the documentation and neither can go
+//! stale.
+
+pub mod render;
+pub mod select;
+
+use std::collections::BTreeMap;
+use std::rc::Rc;
+
+use clap::{Parser, Subcommand, ValueEnum};
+
+use crate::build::{self, Engine};
+use crate::manifest::ledger::Reader;
+use crate::manifest::{Manifest, Scalar, format, scaffold, schema};
+use crate::plan::{self, Overrides, Plan};
+use crate::report::{self, Code, Diagnostic, Exit, Severity};
+use crate::vfs::AbsPath;
+
+#[derive(Debug, Parser)]
+#[command(
+ name = "pcmp",
+ version,
+ long_version = concat!(env!("CARGO_PKG_VERSION"), "\ndarklua ", "0.19.0"),
+ about = "Multi-target build composition for Luau projects",
+ // There is no default subcommand. One would only fire with no arguments, so `pcmp`
+ // would build and `pcmp release` would report an unrecognised subcommand, which is
+ // a worse answer than asking for the word `build`.
+ arg_required_else_help = true
+)]
+pub struct Cli {
+ /// Manifest to use. Discovered from the working directory upwards otherwise.
+ #[arg(long, short, global = true, value_name = "PATH")]
+ manifest: Option,
+
+ /// Where build state is kept. Defaults to `.pcmp/` beside the manifest.
+ #[arg(long, global = true, value_name = "PATH")]
+ cache_dir: Option,
+
+ /// A value for `pcmp.env`, ahead of the process environment. Repeatable.
+ #[arg(long = "env", short = 'e', global = true, value_name = "KEY=VALUE")]
+ env: Vec,
+
+ /// Set a token. Repeatable. Beats the manifest.
+ #[arg(long = "var", global = true, value_name = "KEY=VALUE")]
+ var: Vec,
+
+ /// Set a constant. Repeatable. Beats the manifest.
+ #[arg(long = "define", short = 'D', global = true, value_name = "KEY=VALUE")]
+ define: Vec,
+
+ /// Pin `pcmp.now()`. `SOURCE_DATE_EPOCH` is honoured when this is absent.
+ #[arg(long, global = true, value_name = "RFC3339")]
+ now: Option,
+
+ /// Machine-readable output, byte-stable for a given build.
+ #[arg(long, global = true)]
+ json: bool,
+
+ /// Add a duration to each row. Left out by default so a report can be diffed.
+ #[arg(long, global = true)]
+ timings: bool,
+
+ #[command(subcommand)]
+ command: Command,
+}
+
+#[derive(Debug, Subcommand)]
+enum Command {
+ /// Build every task, or a selection.
+ Build {
+ /// Profile names or exact task identifiers. All when omitted.
+ tasks: Vec,
+ /// Filter an expansion by coordinate. Repeatable.
+ #[arg(long = "axis", value_name = "KEY=VALUE")]
+ axis: Vec,
+ /// Ignore cached state.
+ #[arg(long)]
+ no_cache: bool,
+ /// Write pcmp.lock, recording what this build read and produced.
+ #[arg(long, conflicts_with = "frozen")]
+ lock: bool,
+ /// Reproduce what pcmp.lock records, and fail if anything differs.
+ #[arg(long)]
+ frozen: bool,
+ },
+
+ /// Resolve and print without building. Naming a task prints its full configuration.
+ Plan {
+ /// An exact task identifier. Every task when omitted.
+ task: Option,
+ /// Say whether each task is stale or fresh, and why.
+ #[arg(long)]
+ why: bool,
+ },
+
+ /// Lint the manifest and the resolved plan.
+ Check {
+ /// Fail on warnings too.
+ #[arg(long)]
+ strict: bool,
+ },
+
+ /// Rebuild whenever an input or the manifest changes.
+ Watch {
+ /// Profile names or exact task identifiers. All when omitted.
+ tasks: Vec,
+ /// Filter an expansion by coordinate. Repeatable.
+ #[arg(long = "axis", value_name = "KEY=VALUE")]
+ axis: Vec,
+ },
+
+ /// Write a starter manifest.
+ Init {
+ /// Defaults to the directory name.
+ #[arg(long)]
+ name: Option,
+ /// Detected from common locations when omitted.
+ #[arg(long)]
+ entry: Option,
+ /// `luau` computes values and adds `pcmp.env`, `pcmp.now` and `pcmp.read`.
+ #[arg(long, value_enum, default_value_t = scaffold::Format::Json5)]
+ format: scaffold::Format,
+ },
+
+ /// Emit the manifest schema.
+ Schema {
+ #[arg(long, value_enum, default_value_t = Shape::Json)]
+ format: Shape,
+ },
+
+ /// Explain a diagnostic code, as printed in `error[missing-output]`.
+ Explain {
+ /// Omit to list every code.
+ code: Option,
+ /// `markdown` emits the whole catalogue as a documentation page.
+ #[arg(long, value_enum, default_value_t = Prose::Text)]
+ format: Prose,
+ },
+}
+
+#[derive(Debug, Clone, Copy, ValueEnum)]
+enum Shape {
+ Json,
+ Luau,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
+enum Prose {
+ Text,
+ Markdown,
+}
+
+/// Whatever ended the run.
+///
+/// A `Vec`, because resolution reports every bad profile at once. The `From`
+/// impl is what lets `?` carry a single finding and a whole resolution failure along the
+/// same path, so neither has to be printed before the run is over.
+#[derive(Debug)]
+pub struct Failure(Vec);
+
+impl From for Failure {
+ fn from(diagnostic: Diagnostic) -> Self {
+ Self(vec![diagnostic])
+ }
+}
+
+/// Prints what ended the run and says what to exit with. The one place either happens.
+pub fn fail(cli: &Cli, failure: &Failure) -> Exit {
+ render::failures(&failure.0, cli.json);
+
+ // Sorted worst first by `report::sort`, so the first is the one that decides.
+ failure
+ .0
+ .first()
+ .map_or(Exit::Config, |first| first.code.exit())
+}
+
+/// A manifest found, loaded and resolved. Every command except `schema`, `init` and
+/// `explain` opens one, and each says so by asking for it.
+struct Project {
+ root: AbsPath,
+ /// The manifest itself, which is inside the roots but is not a source.
+ manifest_path: AbsPath,
+ cache: AbsPath,
+ manifest: Manifest,
+ plan: Plan,
+ diagnostics: Vec,
+ reader: Rc,
+}
+
+impl Project {
+ fn open(cli: &Cli, cwd: &AbsPath, frozen: bool) -> Result {
+ let overrides = overrides(cli)?;
+
+ let mut reader = Reader::new(select::pairs(&cli.env)?, cli.now.clone())?;
+ let path = match cli.manifest.as_deref() {
+ Some(given) => cwd.join(given)?,
+ None => format::discover(cwd)?,
+ };
+ let root = path
+ .parent()
+ .ok_or_else(|| Diagnostic::new(Code::NoManifest, format!("`{path}` has no parent")))?;
+
+ if frozen {
+ let lock = build::record::Lock::load(&root).ok_or_else(|| {
+ Diagnostic::new(Code::Frozen, "no pcmp.lock to reproduce")
+ .help("run `pcmp build --lock` first")
+ })?;
+ reader = reader.frozen(lock.ledger);
+ }
+
+ let reader = Rc::new(reader);
+ for (name, value) in overrides.vars.iter().chain(&overrides.defines) {
+ reader.note_override(name, &value.text());
+ }
+
+ let loaded = format::load(&path, &reader)?;
+ let cache = match cli.cache_dir.as_deref() {
+ Some(given) => cwd.join(given)?,
+ None => loaded.root.join(build::CACHE_DIR)?,
+ };
+
+ // Every finding travels out rather than being printed here, so one place decides
+ // how a failure is rendered and `--json` means the same thing on every path.
+ let outcome = plan::resolve(&loaded.manifest, &overrides);
+ let Some(plan) = outcome.value else {
+ return Err(Failure(outcome.diagnostics));
+ };
+
+ Ok(Self {
+ root: loaded.root,
+ manifest_path: loaded.path,
+ cache,
+ manifest: loaded.manifest,
+ plan,
+ diagnostics: outcome.diagnostics,
+ reader,
+ })
+ }
+}
+
+fn overrides(cli: &Cli) -> Result {
+ let scalars = |arguments: &[String]| -> Result, Diagnostic> {
+ Ok(select::pairs(arguments)?
+ .into_iter()
+ .map(|(key, value)| (key, Scalar::parse(&value)))
+ .collect())
+ };
+
+ Ok(Overrides {
+ vars: scalars(&cli.var)?,
+ defines: scalars(&cli.define)?,
+ })
+}
+
+pub fn run(cli: &Cli) -> Result {
+ let cwd = AbsPath::cwd()?;
+
+ match &cli.command {
+ Command::Schema { format } => {
+ render::line(match format {
+ Shape::Json => schema::json(),
+ Shape::Luau => schema::luau(),
+ });
+ Ok(Exit::Success)
+ }
+
+ Command::Explain { code, format } => explain(code.as_deref(), *format),
+
+ Command::Init {
+ name,
+ entry,
+ format,
+ } => {
+ let name = name
+ .clone()
+ .unwrap_or_else(|| cwd.file_name().unwrap_or("project").to_owned());
+ let written = scaffold::write(&cwd, &name, entry.as_deref(), *format)?;
+ render::created(&[written]);
+ Ok(Exit::Success)
+ }
+
+ Command::Plan { task, why } => plan(cli, &cwd, task.as_deref(), *why),
+
+ Command::Check { strict } => {
+ let project = Project::open(cli, &cwd, false)?;
+
+ let staged = sources(&project);
+ let mut diagnostics = plan::lint::run(
+ &project.manifest,
+ &project.plan,
+ &project.reader.ledger(),
+ &project.root,
+ staged.as_deref(),
+ );
+ diagnostics.extend(project.diagnostics);
+ report::sort(&mut diagnostics);
+ render::diagnostics(&diagnostics, cli.json);
+
+ Ok(match report::worst(&diagnostics) {
+ Some(Severity::Error) => Exit::Lint,
+ Some(Severity::Warning) if *strict => Exit::Lint,
+ _ => Exit::Success,
+ })
+ }
+
+ Command::Build {
+ tasks,
+ axis,
+ no_cache,
+ lock,
+ frozen,
+ } => build(cli, &cwd, tasks, axis, *no_cache, *lock, *frozen),
+
+ Command::Watch { tasks, axis } => {
+ let project = Project::open(cli, &cwd, false)?;
+ let selection = select::select(&project.plan, tasks, &select::pairs(axis)?)?;
+ Ok(build::watch::run(
+ &project.root,
+ &project.cache,
+ &project.manifest_path,
+ &project.plan,
+ &selection,
+ cli.json,
+ cli.timings,
+ )?)
+ }
+ }
+}
+
+fn plan(cli: &Cli, cwd: &AbsPath, task: Option<&str>, why: bool) -> Result {
+ let project = Project::open(cli, cwd, false)?;
+
+ match task {
+ Some(name) => {
+ let chosen = project.plan.get(name).ok_or_else(|| {
+ Diagnostic::new(Code::NoSuchTask, format!("no task `{name}`"))
+ .help(format!("known tasks: {}", project.plan.known()))
+ })?;
+ render::task(chosen, cli.json);
+ }
+ // Every digest is computed, and nothing is built, so this says what a build would
+ // do without doing it.
+ None if why => {
+ let engine = Engine::new(project.root.clone(), project.cache, true);
+ render::heading(&project.plan, cli.json);
+ render::build(
+ &engine.inspect(&project.plan, &project.plan),
+ cli.json,
+ cli.timings,
+ true,
+ );
+ }
+ None => render::plan(&project.plan, cli.json),
+ }
+
+ Ok(Exit::Success)
+}
+
+fn build(
+ cli: &Cli,
+ cwd: &AbsPath,
+ tasks: &[String],
+ axis: &[String],
+ no_cache: bool,
+ lock: bool,
+ frozen: bool,
+) -> Result {
+ let project = Project::open(cli, cwd, frozen)?;
+ let selection = select::select(&project.plan, tasks, &select::pairs(axis)?)?;
+
+ let engine = Engine::new(
+ project.root.clone(),
+ project.cache.clone(),
+ !no_cache && !frozen,
+ );
+
+ render::heading(&project.plan, cli.json);
+ let report = engine.run(&project.plan, &selection);
+ render::build(&report, cli.json, cli.timings, false);
+
+ if !report.succeeded() {
+ return Ok(Exit::Build);
+ }
+ if lock {
+ write_lock(&project, &report)?;
+ }
+ if frozen {
+ return frozen_verdict(&project, &report);
+ }
+
+ Ok(Exit::Success)
+}
+
+/// Every Luau source byte in the project, for the define check.
+///
+/// This is what `check` costs beyond resolving: one read of the sources, and no darklua.
+/// The union across tasks rather than one set per task, because a define read from another
+/// profile's tree is a false negative, and a false positive would be worse.
+///
+/// Two things are left out. Anything that is not Lua source, because a define is only ever
+/// substituted into Lua source. And the manifest itself, which lives inside the roots and
+/// names every define it declares, so including it would mean a misspelled define always
+/// found itself.
+fn sources(project: &Project) -> Option {
+ let cache = project.cache.relative_to(&project.root);
+ let mut seen = std::collections::BTreeSet::new();
+ let mut text = String::new();
+
+ for task in &project.plan.tasks {
+ let scope = build::inputs::Scope::of(task, &project.plan, cache.as_ref());
+ let Ok(paths) = build::inputs::everything(&scope, &project.root) else {
+ continue;
+ };
+
+ for path in paths {
+ if !matches!(path.extension(), Some("luau" | "lua")) || !seen.insert(path.clone()) {
+ continue;
+ }
+ if let Ok(absolute) = project.root.join(path.as_str())
+ && absolute != project.manifest_path
+ && let Ok(bytes) = crate::vfs::read(&absolute)
+ && let Ok(source) = String::from_utf8(bytes)
+ {
+ text.push_str(&source);
+ text.push('\n');
+ }
+ }
+ }
+
+ (!text.is_empty()).then_some(text)
+}
+
+fn explain(code: Option<&str>, format: Prose) -> Result {
+ if format == Prose::Markdown {
+ render::line(report::reference());
+ return Ok(Exit::Success);
+ }
+
+ let Some(code) = code else {
+ for known in report::ALL {
+ render::line(known.slug());
+ }
+ return Ok(Exit::Success);
+ };
+
+ let known = Code::parse(code).ok_or_else(|| {
+ Diagnostic::new(Code::BadArgument, format!("no diagnostic code `{code}`"))
+ .help("run `pcmp explain` with no argument for the list")
+ })?;
+
+ render::line(format!("{}\n", known.slug()));
+ render::line(known.description());
+ Ok(Exit::Success)
+}
+
+fn write_lock(project: &Project, report: &build::Report) -> Result<(), Failure> {
+ let tasks = report
+ .tasks
+ .iter()
+ .filter_map(|task| {
+ let plan = project.plan.get(task.task.as_str())?;
+ Some((
+ task.task.to_string(),
+ build::record::Locked {
+ plan: plan.digest(),
+ artifacts: task.artifacts?,
+ },
+ ))
+ })
+ .collect();
+
+ build::record::Lock::new(project.reader.ledger(), tasks).save(&project.root)?;
+ Ok(())
+}
+
+/// A frozen build has already produced its artifacts, so this is only the comparison.
+fn frozen_verdict(project: &Project, report: &build::Report) -> Result {
+ let Some(lock) = build::record::Lock::load(&project.root) else {
+ return Err(Diagnostic::new(Code::Frozen, "no pcmp.lock to reproduce").into());
+ };
+
+ let differing: Vec<&str> = report
+ .tasks
+ .iter()
+ .filter(|task| {
+ lock.tasks
+ .get(task.task.as_str())
+ .is_none_or(|locked| Some(locked.artifacts) != task.artifacts)
+ })
+ .map(|task| task.task.as_str())
+ .collect();
+
+ // A second summary line under the build's own, counted the same way, so the verdict
+ // reads as part of the report rather than as an announcement.
+ render::line(format!(
+ "{} reproduced, {} differing",
+ report.tasks.len() - differing.len(),
+ differing.len()
+ ));
+
+ if differing.is_empty() {
+ return Ok(Exit::Success);
+ }
+
+ render::line("");
+ render::diagnostics(
+ &[Diagnostic::new(
+ Code::Frozen,
+ format!(
+ "{} did not reproduce",
+ render::count(differing.len(), "task")
+ ),
+ )
+ .help(differing.join(", "))],
+ false,
+ );
+
+ Ok(Exit::Build)
+}
diff --git a/src/cli/render.rs b/src/cli/render.rs
new file mode 100644
index 0000000..181cc9f
--- /dev/null
+++ b/src/cli/render.rs
@@ -0,0 +1,351 @@
+//! Everything written to a stream.
+//!
+//! The only module that prints, which the crate's `print_stdout` and `print_stderr` lints
+//! make a compile error to forget. `println!` is avoided too: it panics on a closed pipe,
+//! which `pcmp plan | head` produces routinely.
+//!
+//! Three shapes, and every screen is built from them. A [`labels`] group for anything
+//! keyed by a word, a [`table`] for anything with one row per task, and a summary line
+//! counting what the screen just showed. A command decides what goes in them and never
+//! how they are spaced.
+
+#![allow(
+ clippy::print_stdout,
+ clippy::print_stderr,
+ reason = "the one module that writes to a stream"
+)]
+
+use std::fmt::Write as _;
+use std::io::Write as _;
+
+use crate::build::{Report, Status};
+use crate::plan::{Plan, Task};
+use crate::report::{self, Diagnostic, Severity};
+use crate::vfs::RelPath;
+
+pub fn line(text: impl std::fmt::Display) {
+ let _ = writeln!(std::io::stdout(), "{text}");
+}
+
+pub fn problem(text: impl std::fmt::Display) {
+ let _ = writeln!(std::io::stderr(), "{text}");
+}
+
+fn json(value: &T) {
+ match serde_json::to_string_pretty(value) {
+ Ok(rendered) => line(rendered),
+ // On stderr, so stdout stays parseable.
+ Err(error) => problem(format!("error: could not encode output: {error}")),
+ }
+}
+
+/// `1 task`, `3 tasks`.
+///
+/// Every count in the CLI is a phrase a person could say aloud, so none of them is
+/// written `task(s)`. A summary counting adjectives writes them inline instead, because
+/// `3 builts` is not a phrase.
+pub fn count(number: usize, noun: &str) -> String {
+ if number == 1 {
+ format!("{number} {noun}")
+ } else {
+ format!("{number} {noun}s")
+ }
+}
+
+/// Measured in characters, not bytes.
+fn pad(text: &str, to: usize) -> String {
+ let width = text.chars().count();
+ if width >= to {
+ text.to_owned()
+ } else {
+ format!("{text}{}", " ".repeat(to - width))
+ }
+}
+
+fn widest<'a>(items: impl Iterator- ) -> usize {
+ items.map(|text| text.chars().count()).max().unwrap_or(0)
+}
+
+/// A `label value` block.
+///
+/// Labels are padded to the widest in this group and to nothing outside it, so a group
+/// reads as a unit and two groups on one screen never have to agree on a width.
+fn labels<'a>(rows: impl IntoIterator
- ) {
+ let rows: Vec<_> = rows.into_iter().collect();
+ let width = widest(rows.iter().map(|(label, _)| *label));
+
+ for (label, value) in &rows {
+ line(format!("{} {value}", pad(label, width)));
+ }
+}
+
+fn widths(rows: &[Vec
]) -> Vec {
+ let columns = rows.iter().map(Vec::len).max().unwrap_or(0);
+ (0..columns)
+ .map(|column| {
+ widest(
+ rows.iter()
+ .filter_map(|row| row.get(column))
+ .map(String::as_str),
+ )
+ })
+ .collect()
+}
+
+/// One table row: indented by two, columns separated by two.
+///
+/// The trailing trim matters. A task with nothing in its last column would otherwise end
+/// in the padding of a column it did not fill, which every diff tool flags and nothing
+/// needs.
+fn row(cells: &[String], widths: &[usize]) -> String {
+ let padded: Vec = cells
+ .iter()
+ .enumerate()
+ .map(|(column, cell)| pad(cell, widths.get(column).copied().unwrap_or(0)))
+ .collect();
+
+ format!(" {}", padded.join(" ")).trim_end().to_owned()
+}
+
+fn table(rows: &[Vec]) {
+ let widths = widths(rows);
+ for cells in rows {
+ line(row(cells, &widths));
+ }
+}
+
+/// The identity of the resolved plan, which is what `--frozen` compares and what names a
+/// set of artifacts. Printed once per invocation, ahead of any work.
+pub fn heading(plan: &Plan, emit: bool) {
+ if !emit {
+ labels([("plan", plan.digest().short().to_string())]);
+ line("");
+ }
+}
+
+pub fn plan(plan: &Plan, emit: bool) {
+ if emit {
+ return json(plan);
+ }
+
+ heading(plan, false);
+ table(
+ &plan
+ .tasks
+ .iter()
+ .map(|task| {
+ vec![
+ task.id.to_string(),
+ task.output.to_string(),
+ task.config.rules.as_ref().map_or_else(
+ || "darklua defaults".to_owned(),
+ |rules| count(rules.len(), "rule"),
+ ),
+ ]
+ })
+ .collect::>(),
+ );
+
+ line(format!("\n{}", count(plan.len(), "task")));
+}
+
+/// One task in full, which is what `pcmp plan ` prints.
+pub fn task(task: &Task, emit: bool) {
+ if emit {
+ return json(&serde_json::json!({
+ "task": task,
+ "digest": task.digest().to_string(),
+ "darklua": task.config.json(),
+ }));
+ }
+
+ labels([
+ ("task", task.id.to_string()),
+ ("entry", task.entry.to_string()),
+ ("output", task.output.to_string()),
+ ("digest", task.digest().short().to_string()),
+ ]);
+
+ line("");
+ section(
+ "vars",
+ task.vars.iter().map(|(k, v)| (k.to_string(), v.text())),
+ );
+ line("");
+ section(
+ "defines",
+ task.defines
+ .iter()
+ .map(|(k, v)| (k.to_string(), v.tagged())),
+ );
+
+ line("");
+ line("darklua");
+ for text in serde_json::to_string_pretty(&task.config.json())
+ .unwrap_or_default()
+ .lines()
+ {
+ line(format!(" {text}"));
+ }
+}
+
+/// A heading and its rows, indented like every other table.
+fn section(label: &str, pairs: impl Iterator- ) {
+ line(label);
+ table(
+ &pairs
+ .map(|(key, value)| vec![key, value])
+ .collect::
>(),
+ );
+}
+
+/// `plan --why` reports what a build would do and does none of it, so it must not say a
+/// task was built.
+///
+/// Padded here rather than by the table, because this is the one column whose width must
+/// not depend on the run. Measuring it would make a report of three cached tasks a
+/// character wider than a report of three built ones, and two runs of the same project
+/// would not line up.
+const fn status(status: Status, why: bool) -> &'static str {
+ match (status, why) {
+ (Status::Built, false) => "built ",
+ (Status::Built, true) => "stale ",
+ (Status::Cached, false) => "cached",
+ (Status::Cached, true) => "fresh ",
+ (Status::Failed, _) => "FAILED",
+ }
+}
+
+pub fn build(report: &Report, emit: bool, timings: bool, why: bool) {
+ if emit {
+ return json(report);
+ }
+
+ let rows: Vec> = report
+ .tasks
+ .iter()
+ .map(|task| {
+ let note = match (why, timings) {
+ (true, _) => task.why.map(|reason| reason.describe().to_owned()),
+ (false, true) => Some(format!("{} ms", task.millis)),
+ (false, false) => None,
+ };
+
+ vec![
+ status(task.status, why).to_owned(),
+ task.task.to_string(),
+ task.output.to_string(),
+ note.unwrap_or_default(),
+ ]
+ })
+ .collect();
+
+ // A diagnostic belongs under its own row, so the widths are measured once here and
+ // the rows are written one at a time rather than by `table`.
+ let widths = widths(&rows);
+ let indent = " ".repeat(widths.first().copied().unwrap_or(0) + 4);
+
+ for (cells, task) in rows.iter().zip(&report.tasks) {
+ line(row(cells, &widths));
+
+ for diagnostic in &task.diagnostics {
+ for text in rendered(diagnostic).lines() {
+ line(format!("{indent}{text}"));
+ }
+ }
+ }
+
+ let (built, cached, failed) = report.counts();
+ line(if why {
+ format!("\n{built} stale, {cached} fresh, {failed} failed")
+ } else {
+ format!("\n{built} built, {cached} cached, {failed} failed")
+ });
+}
+
+/// Findings that are the command's answer, as `check`'s are.
+pub fn diagnostics(diagnostics: &[Diagnostic], emit: bool) {
+ report_to(diagnostics, emit, line);
+}
+
+/// Findings that mean the command failed.
+///
+/// On stderr, so that `pcmp build > artifacts.json` still says why it did not work. With
+/// `--json` both go to stdout, because that is where machine output lives and the exit
+/// code already says which happened.
+pub fn failures(diagnostics: &[Diagnostic], emit: bool) {
+ if emit {
+ json(&diagnostics);
+ } else {
+ report_to(diagnostics, false, problem);
+ }
+}
+
+fn report_to(diagnostics: &[Diagnostic], emit: bool, out: fn(String)) {
+ if emit {
+ return json(&diagnostics);
+ }
+
+ for diagnostic in diagnostics {
+ out(rendered(diagnostic));
+ out(String::new());
+ }
+
+ // Always, so a clean run and a dirty one end the same way and a script reading the
+ // last line has one case rather than two.
+ let (errors, warnings) = report::tally(diagnostics);
+ out(format!(
+ "{}, {}",
+ count(errors, "error"),
+ count(warnings, "warning")
+ ));
+}
+
+/// One diagnostic, in the shape `error[missing-output]`, code first, so the reader knows
+/// what to pass to `pcmp explain`. The continuation lines are a `labels` group in all but
+/// name, which is why each carries a keyword and a colon.
+pub fn rendered(diagnostic: &Diagnostic) -> String {
+ let marker = match diagnostic.severity() {
+ Severity::Error => "error",
+ Severity::Warning => "warning",
+ };
+
+ let mut out = format!(
+ "{marker}[{}]: {}",
+ diagnostic.code.slug(),
+ diagnostic.message
+ );
+
+ if let Some(at) = &diagnostic.at {
+ let _ = write!(out, "\n at: {at}");
+ }
+ if let Some(source) = &diagnostic.source {
+ let _ = write!(out, "\n from: {source}");
+ }
+ for help in diagnostic.help.iter().flat_map(|help| help.lines()) {
+ let _ = write!(out, "\n help: {help}");
+ }
+
+ out
+}
+
+pub fn created(paths: &[RelPath]) {
+ labels(
+ paths
+ .iter()
+ .map(|path| ("created", path.to_string()))
+ .chain(std::iter::once(("next", "pcmp plan".to_owned()))),
+ );
+}
+
+/// `pcmp watch` opens with the roots it will react to and the plan it resolved, then
+/// prints one build report per cycle.
+pub fn watching(roots: &[String], plan: &Plan) {
+ labels(
+ roots
+ .iter()
+ .map(|root| ("watching", root.clone()))
+ .chain(std::iter::once(("plan", plan.digest().short().to_string()))),
+ );
+ line("");
+}
diff --git a/src/cli/select.rs b/src/cli/select.rs
new file mode 100644
index 0000000..9111959
--- /dev/null
+++ b/src/cli/select.rs
@@ -0,0 +1,71 @@
+//! Choosing which tasks to act on.
+//!
+//! A selector is a profile name or an exact task identifier, and `--axis KEY=VALUE`
+//! filters an expansion by coordinate. There is no wildcard, because a task identifier
+//! contains `[`, `]`, `=` and `,`, which every glob dialect reads as syntax.
+
+use std::collections::BTreeMap;
+
+use crate::plan::Plan;
+use crate::report::{Code, Diagnostic};
+
+/// Everything when nothing is named. An explicit selection that matches nothing is an
+/// error rather than a quiet success.
+pub fn select(
+ plan: &Plan,
+ selectors: &[String],
+ axes: &BTreeMap,
+) -> Result {
+ let tasks: Vec<_> = plan
+ .tasks
+ .iter()
+ .filter(|task| {
+ selectors.is_empty()
+ || selectors.iter().any(|selector| {
+ selector == task.id.as_str() || selector == task.profile.as_str()
+ })
+ })
+ .filter(|task| {
+ axes.iter().all(|(axis, value)| {
+ task.axes
+ .iter()
+ .any(|(name, chosen)| name.as_str() == axis && chosen == value)
+ })
+ })
+ .cloned()
+ .collect();
+
+ if tasks.is_empty() {
+ let named = if selectors.is_empty() {
+ axes.iter()
+ .map(|(axis, value)| format!("{axis}={value}"))
+ .collect::>()
+ .join(", ")
+ } else {
+ selectors.join(", ")
+ };
+
+ return Err(
+ Diagnostic::new(Code::NoSuchTask, format!("nothing matched `{named}`"))
+ .help(format!("known tasks: {}", plan.known())),
+ );
+ }
+
+ Ok(Plan { tasks })
+}
+
+/// `KEY=VALUE`, repeatable, last occurrence winning.
+pub fn pairs(arguments: &[String]) -> Result, Diagnostic> {
+ arguments
+ .iter()
+ .map(|argument| {
+ argument
+ .split_once('=')
+ .map(|(key, value)| (key.to_owned(), value.to_owned()))
+ .ok_or_else(|| {
+ Diagnostic::new(Code::BadArgument, format!("`{argument}` is not KEY=VALUE"))
+ .help("write it as `name=value`, with no space around the `=`")
+ })
+ })
+ .collect()
+}
diff --git a/src/config.luau b/src/config.luau
deleted file mode 100644
index e14e0fb..0000000
--- a/src/config.luau
+++ /dev/null
@@ -1,251 +0,0 @@
---!nocheck
-
---// Imports
-
-local fs = require("@lune/fs")
-local serde = require("@lune/serde")
-local util = require("./util")
-
---// Types
-
-export type OpenFileConfig = {
- command: string,
- args: { string },
-}
-
-export type DeployGithubConfig = {
- owner: string,
- repo: string,
- tokenEnvVar: string?,
-}
-
-export type DeploymentConfig = {
- github: DeployGithubConfig?,
-}
-
-export type HookScript = {
- command: string?,
- script: string?,
- args: { string }?,
- env: { [string]: string }?,
-}
-
-export type TaskHooks = {
- preBuild: HookScript?,
- postBuild: HookScript?,
-}
-
-export type PipelineHooks = {
- preDeploy: HookScript?,
- postDeploy: HookScript?,
- preAllBuilds: HookScript?,
- postAllBuilds: HookScript?,
-}
-
-export type BuildVariant = {
- name: string?,
- input: string?,
- output: string?,
- frame: string?,
- darkluaConfig: string?,
- hooks: TaskHooks?,
-}
-
-export type BuildConfig = {
- name: string,
- input: string?,
- inputDir: string?,
- output: string?,
- outputDir: string?,
- frame: string,
- darkluaConfig: string,
- promptVersion: boolean?,
- versionFormat: string?,
- promptDeploy: boolean?,
- prerelease: boolean?,
- openComposedOutput: OpenFileConfig?,
- variants: { BuildVariant }?,
- hooks: TaskHooks?,
- pipelineHooks: PipelineHooks?,
-}
-
-export type PcmpConfig = {
- envFile: string?,
- openComposedOutput: OpenFileConfig?,
- releaseNotesEditor: OpenFileConfig?,
- deployment: DeploymentConfig?,
- buildConfigs: { BuildConfig },
-}
-
-export type PcmpLocalConfig = {
- envFile: string?,
- openComposedOutput: OpenFileConfig?,
- releaseNotesEditor: OpenFileConfig?,
-}
-
-export type BuildTask = {
- name: string,
- input: string,
- output: string,
- frame: string,
- darkluaConfig: string,
- prerelease: boolean?,
- openComposedOutput: OpenFileConfig?,
- hooks: TaskHooks?,
-}
-
---// Constants
-local LOCAL_CONFIG_SUFFIX = ".local.json"
-
---// Private Methods
-local function resolveOpenComposedOutput(local_: OpenFileConfig?, global_: OpenFileConfig?): OpenFileConfig?
- return if local_ ~= nil then local_ else global_
-end
-
-local function deriveLocalPath(configPath: string): string
- local stem = configPath:gsub("%.json$", "")
- return stem .. LOCAL_CONFIG_SUFFIX
-end
-
-local function mergeHooks(base: TaskHooks?, override: TaskHooks?): TaskHooks?
- if not base and not override then
- return nil
- end
- local merged: TaskHooks = {}
- if base then
- merged.preBuild = base.preBuild
- merged.postBuild = base.postBuild
- end
- if override then
- if override.preBuild ~= nil then
- merged.preBuild = override.preBuild
- end
- if override.postBuild ~= nil then
- merged.postBuild = override.postBuild
- end
- end
-
- if merged.preBuild == nil and merged.postBuild == nil then
- return nil
- end
-
- return merged
-end
-
---// Public Methods
-local function load(configPath: string): PcmpConfig
- if not fs.isFile(configPath) then
- error(`[pcmp] Config file not found: {configPath}`)
- end
-
- local raw = serde.decode("json", fs.readFile(configPath))
- return raw :: PcmpConfig
-end
-
-local function applyLocalOverrides(cfg: PcmpConfig, configPath: string): PcmpConfig
- local localPath = deriveLocalPath(configPath)
- if not fs.isFile(localPath) then
- return cfg
- end
-
- local ok, raw = pcall(serde.decode, "json", fs.readFile(localPath))
- if not ok then
- error(`[pcmp] Failed to parse local config at {localPath}: {raw}`)
- end
-
- local local_: PcmpLocalConfig = raw :: any
-
- local merged: PcmpConfig = table.clone(cfg :: any) :: any
- if local_.openComposedOutput ~= nil then
- merged.openComposedOutput = local_.openComposedOutput
- end
- if local_.releaseNotesEditor ~= nil then
- merged.releaseNotesEditor = local_.releaseNotesEditor
- end
- if local_.envFile ~= nil then
- merged.envFile = local_.envFile
- end
-
- return merged
-end
-
-local function getToken(cfg: PcmpConfig, envVars: { [string]: string }): string?
- local github = cfg.deployment and cfg.deployment.github
- if not github then
- return nil
- end
-
- local varName = (github.tokenEnvVar and github.tokenEnvVar ~= "") and github.tokenEnvVar or "GITHUB_TOKEN"
- return envVars[varName]
-end
-
-local function expandBuildConfig(bc: BuildConfig, globalOpen: OpenFileConfig?): { BuildTask }
- local tasks: { BuildTask } = {}
- local resolvedOpen = resolveOpenComposedOutput(bc.openComposedOutput, globalOpen)
-
- if bc.inputDir then
- if not fs.isDir(bc.inputDir) then
- error(`[pcmp] inputDir not found: {bc.inputDir}`)
- end
-
- local outDir = bc.outputDir or bc.output or ("generated/" .. bc.name:lower())
- util.ensureDir(outDir)
-
- for _, fileName in ipairs(fs.readDir(bc.inputDir)) do
- if fileName:match("%.luau$") or fileName:match("%.lua$") then
- table.insert(tasks, {
- name = fileName,
- input = bc.inputDir .. "/" .. fileName,
- output = outDir .. "/" .. fileName,
- frame = bc.frame,
- darkluaConfig = bc.darkluaConfig,
- prerelease = bc.prerelease,
- openComposedOutput = resolvedOpen,
- hooks = bc.hooks,
- })
- end
- end
- else
- if not bc.input then
- error(`[pcmp] Build config '{bc.name}' must specify 'input' or 'inputDir'`)
- end
- if not bc.output then
- error(`[pcmp] Build config '{bc.name}' must specify 'output'`)
- end
-
- table.insert(tasks, {
- name = bc.name,
- input = bc.input :: string,
- output = bc.output :: string,
- frame = bc.frame,
- darkluaConfig = bc.darkluaConfig,
- prerelease = bc.prerelease,
- openComposedOutput = resolvedOpen,
- hooks = bc.hooks,
- })
-
- if bc.variants then
- for _, variant in ipairs(bc.variants) do
- table.insert(tasks, {
- name = variant.name or bc.name,
- input = variant.input or bc.input :: string,
- output = variant.output or bc.output :: string,
- frame = variant.frame or bc.frame,
- darkluaConfig = variant.darkluaConfig or bc.darkluaConfig,
- prerelease = bc.prerelease,
- openComposedOutput = resolvedOpen,
- hooks = mergeHooks(bc.hooks, variant.hooks),
- })
- end
- end
- end
-
- return tasks
-end
-
-return {
- load = load,
- applyLocalOverrides = applyLocalOverrides,
- getToken = getToken,
- expandBuildConfig = expandBuildConfig,
-}
diff --git a/src/deploy.luau b/src/deploy.luau
deleted file mode 100644
index 2125451..0000000
--- a/src/deploy.luau
+++ /dev/null
@@ -1,242 +0,0 @@
---!nocheck
-
---// Imports
-
-local fs = require("@lune/fs")
-local net = require("@lune/net")
-local serde = require("@lune/serde")
-local util = require("./util")
-local ui = require("./ui")
-local io = require("@lune/stdio")
-
---// Constants
-
-local MAX_REDIRECTS = 5
-local _UPLOAD_HOST = "uploads.github.com"
-local API_HOST = "api.github.com"
-
---// Private Methods
-
-local function getHost(url: string): string
- return url:match("^https?://([^/]+)") or ""
-end
-
-local function followRedirects(
- url: string,
- method: string,
- headers: { [string]: string },
- body: string?
-): net.FetchResponse
- local currentUrl = url
- local currentMethod = method
- local currentHeaders = table.clone(headers)
- local redirectCount = 0
-
- while true do
- local resp = net.request({
- url = currentUrl,
- method = currentMethod,
- headers = currentHeaders,
- body = body,
- })
-
- local status = resp.statusCode
- if status >= 300 and status < 400 then
- if redirectCount >= MAX_REDIRECTS then
- error(`[pcmp] Too many redirects (>{MAX_REDIRECTS}) from {currentUrl}`)
- end
- redirectCount += 1
-
- local location: string? = nil
- for k, v in pairs(resp.headers) do
- if k:lower() == "location" then
- location = v
- break
- end
- end
-
- if not location or location == "" then
- print(`[pcmp] Redirect status {status} from {currentUrl} but no location header found!`)
- print(`[pcmp] Response body: {resp.body}`)
- print(`[pcmp] Response headers:`)
- for k, v in pairs(resp.headers) do
- print(` {k}: {v}`)
- end
- return resp
- end
-
- if not location:match("^https?://") then
- local base = currentUrl:match("^(https?://[^/]+)")
- if base then
- location = base .. (location:match("^/") and "" or "/") .. location
- end
- end
-
- print(`[pcmp] Redirecting ({status}) to: {location}`)
-
- local newHost = getHost(location)
- local origHost = getHost(currentUrl)
-
- local isGitHub = newHost:match("%.github%.com$")
- or newHost:match("%.githubusercontent%.com$")
- or newHost:match("%.githublabs%.com$")
-
- if newHost ~= origHost and not isGitHub then
- currentHeaders = table.clone(currentHeaders)
- currentHeaders["Authorization"] = nil :: any
- end
-
- currentUrl = location
- if status ~= 307 and status ~= 308 then
- currentMethod = "GET"
- body = nil
- currentHeaders["Content-Type"] = nil :: any
- currentHeaders["Content-Length"] = nil :: any
- end
- else
- return resp
- end
- end
-end
-
-local function createRelease(
- owner: string,
- repo: string,
- token: string,
- tagName: string,
- releaseName: string,
- body: string,
- prerelease: boolean
-): (boolean, number?, string?, string?)
- local resp = followRedirects(
- `https://{API_HOST}/repos/{owner}/{repo}/releases`,
- "POST",
- {
- ["Authorization"] = `Bearer {token}`,
- ["Content-Type"] = "application/json",
- ["Accept"] = "application/vnd.github+json",
- ["X-GitHub-Api-Version"] = "2022-11-28",
- },
- serde.encode("json", {
- tag_name = tagName,
- name = releaseName,
- body = body,
- draft = false,
- prerelease = prerelease,
- })
- )
-
- if not resp.ok then
- return false, nil, `HTTP {resp.statusCode}: {resp.body}`
- end
-
- local data = serde.decode("json", resp.body)
- local releaseId = data.id
- if not releaseId then
- return false, nil, "GitHub response missing release ID"
- end
-
- return true, releaseId, data.upload_url, nil
-end
-
-local function uploadAsset(
- owner: string,
- repo: string,
- releaseId: number,
- token: string,
- filePath: string,
- assetName: string,
- uploadUrl: string
-): (boolean, string?)
- local cleanUrl = uploadUrl:gsub("{%?name,label}", "")
- local uploadUrlResolved = `{cleanUrl}?name={assetName}`
-
- local uploadResp = followRedirects(uploadUrlResolved, "POST", {
- ["Authorization"] = `Bearer {token}`,
- ["Content-Type"] = "application/octet-stream",
- ["Accept"] = "application/vnd.github+json",
- ["X-GitHub-Api-Version"] = "2022-11-28",
- }, fs.readFile(filePath))
-
- if uploadResp.ok or uploadResp.statusCode == 201 then
- return true, nil
- end
-
- return false, `HTTP {uploadResp.statusCode}: {uploadResp.body}`
-end
-
---// Public Methods
-
-export type DeployOptions = {
- owner: string,
- repo: string,
- token: string,
- version: string,
- configName: string,
- prerelease: boolean,
- artifacts: { string },
- releaseBodyPath: string,
- releaseNotesEditor: { command: string, args: { string } }?,
-}
-
-local function run(opts: DeployOptions): boolean
- -- Notes
- fs.writeFile(opts.releaseBodyPath, `# {opts.repo} {opts.version}\n\n`)
-
- if opts.releaseNotesEditor then
- ui.printInfo("Opening release notes editor — save and close when done.")
- ui.openFile(opts.releaseBodyPath, opts.releaseNotesEditor, true)
- io.prompt("text", "Press Enter when you are finished editing to continue...")
- end
-
- local releaseBody = fs.isFile(opts.releaseBodyPath) and fs.readFile(opts.releaseBodyPath) or ""
-
- -- Release
- local ok, releaseId, uploadUrl, err = createRelease(
- opts.owner,
- opts.repo,
- opts.token,
- opts.version,
- `{opts.configName} {opts.version}`,
- releaseBody,
- opts.prerelease
- )
-
- if opts.releaseBodyPath and fs.isFile(opts.releaseBodyPath) then
- fs.removeFile(opts.releaseBodyPath)
- end
-
- if not ok or not releaseId then
- ui.printError(`Release creation failed: {err or "unknown error"}`)
- return false
- end
-
- ui.printSuccess(`Release created (id={releaseId})`)
-
- -- Artifacts
- local allOk = true
- for _, filePath in ipairs(opts.artifacts) do
- local assetName = util.getBaseName(filePath)
- local uploadOk, uploadErr = uploadAsset(
- opts.owner,
- opts.repo,
- releaseId :: number,
- opts.token,
- filePath,
- assetName,
- uploadUrl :: string
- )
- if uploadOk then
- ui.printSuccess(`Uploaded: {assetName}`)
- else
- ui.printError(`Upload failed: {assetName} — {uploadErr or "unknown"}`)
- allOk = false
- end
- end
-
- return allOk
-end
-
-return {
- run = run,
-}
diff --git a/src/init.luau b/src/init.luau
deleted file mode 100644
index 8ad6806..0000000
--- a/src/init.luau
+++ /dev/null
@@ -1,360 +0,0 @@
---!nocheck
-
---// Imports
-local io = require("@lune/stdio")
-local process = require("@lune/process")
-
-local util = require("@self/util")
-local ui = require("@self/ui")
-local config = require("@self/config")
-local build = require("@self/build")
-local deploy = require("@self/deploy")
-local scaffold = require("@self/scaffold")
-
---// Constants
-local VERSION = "3.1.0"
-
-local RELEASE_BODY_PATH = `{process.env.TEMP or process.env.TMP or "/tmp"}/PCMP_RELEASE_BODY.md`
-
-local HELP_TEXT = "ProCMP v"
- .. VERSION
- .. [[ Build composition system with multi-target support, profiles, injectable macros, and lifecycle hooks.
-
-Usage:
- pcmp Run the pipeline with the given config file
- pcmp build [options] Run a configless build via CLI
- pcmp init Initialise a new ProCMP project interactively
- pcmp --help Show this help message
- pcmp --version Print version and exit
-
-Arguments:
- Path to a .pcmp.json file
-
-Subcommands:
- build Run a configless build with flags
- init Scaffold pipeline files and personal local config
-
-Options:
- -h, --help Show this help message
- -V, --version Print version string
- -i, --input Input file for 'build' subcommand
- -o, --output Output file for 'build' subcommand
- -f, --frame Frame file for 'build' subcommand
- -c, --config Darklua config for 'build' subcommand
- -n, --name Build task name for 'build' subcommand (optional)
-
-Local config:
- Personal settings (editor, output opener) live in .pcmp.local.json next to
- the shared config — this file is gitignored so each team member sets their own.
-
-Macros (usable in frame files):
- __COMPOSER.Insert(__COMPOSER.build) Composed source
- __COMPOSER.Insert(__COMPOSER.cfg) Build config name
- __COMPOSER.Insert(__COMPOSER.vers) Build version
- __COMPOSER.Insert(__COMPOSER.variant) Current variant / task name
- __COMPOSER.Insert(__COMPOSER.input) Input file path
- __COMPOSER.Insert(__COMPOSER.output) Output file path
- __COMPOSER.Insert(__COMPOSER.prerelease) true/false literal
- __COMPOSER.Insert(__COMPOSER.genDate) ISO date string
- __COMPOSER.Insert(__COMPOSER.timestamp) Unix epoch (number literal)
- __COMPOSER.Insert(__COMPOSER.os) Operating system ("windows"/"linux"/"macos")
- __COMPOSER.Insert(__COMPOSER.arch) CPU architecture
-
-Lifecycle hooks (in .pcmp.json buildConfigs[].pipelineHooks / hooks / variants[].hooks):
- preAllBuilds / postAllBuilds — once before/after all tasks in a config
- preBuild / postBuild — before/after each individual build task
- preDeploy / postDeploy — before/after the GitHub release step
-
-Hook fields:
- command shell command; tokens: {version} {config} {variant} {input} {output} {file}
- script path to a .luau script run via `lune run