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 +

ProCMP

-Build composition system with multi-target support, profiles, and injectable macros. +

Build one Luau source tree into several artifacts at once, from a single manifest.

+

+ CI + MIT + Release +

+ +--- + +```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