diff --git a/.agents/skills/cutting-releases/SKILL.md b/.agents/skills/cutting-releases/SKILL.md index 62eb0d3b1..c1a7bc072 100644 --- a/.agents/skills/cutting-releases/SKILL.md +++ b/.agents/skills/cutting-releases/SKILL.md @@ -42,25 +42,25 @@ Both commands should report that the target does not exist. Stop if either exist Create a release branch from current `main`. Update both version sources: -- `compiler-bin/Cargo.toml`: package `version` -- `Cargo.lock`: the `purescript-iris` package `version` +- `compiler-bin/iris-cli/Cargo.toml`: package `version` +- `Cargo.lock`: the `iris-cli` package `version` Do not change the internal compiler crates, which remain independently versioned. Verify the focused package and the user-visible version: ```bash -cargo check -p purescript-iris --tests --locked -cargo run -p purescript-iris --locked \ +cargo check -p iris-cli --tests --locked +cargo run -p iris-cli --locked \ --bin iris -- --version git diff --check -git diff -- compiler-bin/Cargo.toml Cargo.lock +git diff -- compiler-bin/iris-cli/Cargo.toml Cargo.lock ``` The CLI must print `iris $version`. Commit only the manifest and lockfile: ```bash -git add compiler-bin/Cargo.toml Cargo.lock +git add compiler-bin/iris-cli/Cargo.toml Cargo.lock git commit -m "Prepare $version release" ``` @@ -94,7 +94,7 @@ Do not tag the release-branch commit. Read the merge commit OID from the merged - It has two parents. - It is the current `origin/main`. -- `compiler-bin/Cargo.toml` and `Cargo.lock` contain the requested version at that commit. +- `compiler-bin/iris-cli/Cargo.toml` and `Cargo.lock` contain the requested version at that commit. - The remote release tag is still absent. After tag-push approval, create the lightweight tag on that exact merge commit and push only the tag: diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index 8984254f0..6148dd5d1 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -43,7 +43,7 @@ jobs: COMMIT_SHA: ${{ github.event.workflow_run.head_sha }} run: | package_version=$(cargo metadata --format-version 1 --no-deps | - jq -r '.packages[] | select(.name == "purescript-iris") | .version') + jq -r '.packages[] | select(.name == "iris-cli") | .version') if [[ ! "$package_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "Package version must be a stable semantic version, found $package_version." >&2 exit 1 @@ -138,7 +138,7 @@ jobs: IRIS_BUILD_REVISION: ${{ needs.prepare.outputs.revision }} with: bin: iris - package: purescript-iris + package: iris-cli archive: iris-$target include: README.md,LICENSE,ACKNOWLEDGEMENTS.md,THIRDPARTY.toml leading-dir: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 158f5eb25..94d9cc9af 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -56,7 +56,7 @@ jobs: uses: taiki-e/upload-rust-binary-action@v1 with: bin: iris - package: purescript-iris + package: iris-cli archive: iris-$target include: README.md,LICENSE,ACKNOWLEDGEMENTS.md,THIRDPARTY.toml leading-dir: true diff --git a/Cargo.lock b/Cargo.lock index 205df5b02..aaf7d35ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -141,6 +141,15 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + [[package]] name = "arrayvec" version = "0.5.2" @@ -333,6 +342,12 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + [[package]] name = "bytecount" version = "0.6.9" @@ -517,6 +532,20 @@ dependencies = [ "memchr", ] +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "compact_str" version = "0.10.0" @@ -572,6 +601,15 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -672,6 +710,33 @@ version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.1", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crunchy" version = "0.2.4" @@ -687,6 +752,40 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.5", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.5", +] + [[package]] name = "data-encoding" version = "2.11.1" @@ -736,6 +835,34 @@ dependencies = [ "thiserror", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + [[package]] name = "diagnostics" version = "0.1.0" @@ -781,6 +908,15 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "documentation" version = "0.1.0" @@ -1224,6 +1360,8 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ + "allocator-api2", + "equivalent", "foldhash 0.2.0", ] @@ -1433,6 +1571,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -1510,6 +1654,15 @@ dependencies = [ "web-time", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "inotify" version = "0.11.5" @@ -1544,6 +1697,19 @@ dependencies = [ "tempfile", ] +[[package]] +name = "instability" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf84e73fa6f27f299dec58e13223cf70db80da872eb921d4f6138342a0eabc8" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "interner" version = "0.1.0" @@ -1561,6 +1727,118 @@ version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" +[[package]] +name = "iris-build" +version = "0.1.0" +dependencies = [ + "building", + "diagnostics", + "dunce", + "files", + "globset", + "ignore", + "iris-progress", + "itertools 0.15.0", + "javascript", + "line-index", + "path-absolutize", + "petgraph", + "prim-constants", + "rayon", + "serde", + "serde_yml", + "smol_str", + "spago", + "tempfile", + "thiserror", + "url", + "walkdir", +] + +[[package]] +name = "iris-cli" +version = "0.1.0" +dependencies = [ + "configuration", + "iris-build", + "iris-lsp", + "iris-package", + "iris-watch", + "itertools 0.15.0", + "serde_json", + "thiserror", + "tracing", + "tracing-subscriber", + "usage-rs", +] + +[[package]] +name = "iris-lsp" +version = "0.1.0" +dependencies = [ + "analyzer", + "async-lsp", + "building", + "configuration", + "dunce", + "files", + "globset", + "iris-build", + "itertools 0.15.0", + "lsp-types", + "parking_lot", + "path-absolutize", + "rustc-hash", + "serde", + "serde_json", + "smol_str", + "spago", + "tempfile", + "thiserror", + "tokio", + "tokio-util", + "tower", + "tracing", + "url", + "walkdir", +] + +[[package]] +name = "iris-package" +version = "0.1.0" +dependencies = [ + "ignore", + "itertools 0.15.0", + "serde", + "serde_yml", + "spago", + "thiserror", +] + +[[package]] +name = "iris-progress" +version = "0.1.0" +dependencies = [ + "console", + "itertools 0.15.0", + "ratatui", + "smol_str", + "terminal-colorsaurus", +] + +[[package]] +name = "iris-watch" +version = "0.1.0" +dependencies = [ + "iris-build", + "iris-progress", + "itertools 0.15.0", + "jiff", + "notify", + "thiserror", + "tracing", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1576,6 +1854,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.15.0" @@ -1786,6 +2073,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror", +] + [[package]] name = "kqueue" version = "1.2.1" @@ -1847,6 +2145,12 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libtest-mimic" version = "0.8.2" @@ -1881,6 +2185,15 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "line-clipping" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e" +dependencies = [ + "bitflags 2.13.1", +] + [[package]] name = "line-index" version = "0.1.2" @@ -1903,6 +2216,12 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -1940,6 +2259,15 @@ dependencies = [ "test_each_file", ] +[[package]] +name = "lru" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff9840bcc50b71349309900da0ce7279aa336ae71d73250b07998932c7d97c25" +dependencies = [ + "hashbrown 0.17.1", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -2099,6 +2427,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-integer" version = "0.1.47" @@ -2138,6 +2472,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -2387,7 +2730,7 @@ version = "0.149.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8305f2b3c5e2030658fba8457260d313dbf5fe442c40c184f54a3a50eb42365c" dependencies = [ - "compact_str", + "compact_str 0.10.0", "oxc_allocator", "oxc_ast_macros", "oxc_estree", @@ -2400,7 +2743,7 @@ version = "0.149.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16486b3832d8f1355283030ae048c5ae530d8a57e2cdfd9514e0d6cdbd4c5506" dependencies = [ - "compact_str", + "compact_str 0.10.0", "hashbrown 0.17.1", "oxc_allocator", "oxc_estree", @@ -2436,6 +2779,39 @@ dependencies = [ "winapi", ] +[[package]] +name = "palette" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64" +dependencies = [ + "approx", + "libm", + "palette_derive", + "palette_math", +] + +[[package]] +name = "palette_derive" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "palette_math" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12" +dependencies = [ + "libm", +] + [[package]] name = "papaya" version = "0.2.5" @@ -2639,6 +3015,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "pretty" version = "0.12.5" @@ -2675,59 +3057,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "purescript-iris" -version = "0.1.0" -dependencies = [ - "analyzer", - "async-lsp", - "building", - "checking", - "configuration", - "console", - "diagnostics", - "documentation", - "dunce", - "files", - "globset", - "ignore", - "indexing", - "indicatif", - "insta", - "itertools 0.15.0", - "javascript", - "jiff", - "line-index", - "lowering", - "lsp-types", - "notify", - "parking_lot", - "path-absolutize", - "prim-constants", - "radix_trie", - "rayon", - "resolving", - "rustc-hash", - "serde", - "serde_json", - "serde_yml", - "smol_str", - "spago", - "stabilizing", - "tempfile", - "terminal-colorsaurus", - "thiserror", - "tokio", - "tokio-util", - "tower", - "tracing", - "tracing-subscriber", - "ts-rs", - "url", - "usage-rs", - "walkdir", -] - [[package]] name = "quinn" version = "0.11.11" @@ -2842,6 +3171,72 @@ dependencies = [ "rand_core", ] +[[package]] +name = "ratatui" +version = "0.30.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-widgets", + "serde", +] + +[[package]] +name = "ratatui-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" +dependencies = [ + "bitflags 2.13.1", + "compact_str 0.9.1", + "hashbrown 0.17.1", + "itertools 0.14.0", + "kasuari", + "lru", + "palette", + "serde", + "strum", + "thiserror", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" +dependencies = [ + "bitflags 2.13.1", + "hashbrown 0.17.1", + "indoc", + "instability", + "itertools 0.14.0", + "line-clipping", + "ratatui-core", + "serde", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "rayon" version = "1.12.0" @@ -3339,6 +3734,17 @@ dependencies = [ "signal-hook-registry", ] +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -3743,9 +4149,9 @@ version = "0.1.0" dependencies = [ "async-lsp", "insta", + "iris-cli", "itertools 0.15.0", "lsp-types", - "purescript-iris", "regex", "serde_json", "tempfile", @@ -3851,6 +4257,27 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + [[package]] name = "tinystr" version = "0.8.4" @@ -3895,7 +4322,6 @@ dependencies = [ "bytes", "libc", "mio", - "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", @@ -4110,6 +4536,17 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools 0.14.0", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "unicode-width" version = "0.2.2" diff --git a/Cargo.toml b/Cargo.toml index bd7475658..4c695e26d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] members = [ - "compiler-bin", + "compiler-bin/*", "compiler-backend/*", "compiler-core/*", "compiler-frontend/*", diff --git a/README.md b/README.md index c0d0e57c8..75c139b5a 100644 --- a/README.md +++ b/README.md @@ -200,4 +200,4 @@ continue to use GitHub's latest release. Iris keeps its package version separate from source provenance. Packagers can set `IRIS_BUILD_REVISION` to a Git revision when invoking Cargo to include that revision in the reported CLI and language-server versions. The value is read at compile time; builds that omit it report the -version from `compiler-bin/Cargo.toml` unchanged. +version from `compiler-bin/iris-cli/Cargo.toml` unchanged. diff --git a/THIRDPARTY.toml b/THIRDPARTY.toml index 1be4592e5..cfa4b6639 100644 --- a/THIRDPARTY.toml +++ b/THIRDPARTY.toml @@ -1,4 +1,4 @@ -root_name = "purescript-iris" +root_name = "iris-cli" [[third_party_libraries]] package_name = "aho-corasick" @@ -110,6 +110,219 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ +[[third_party_libraries]] +package_name = "approx" +package_version = "0.5.1" +repository = "https://github.com/brendanzab/approx" +license = "Apache-2.0" + +[[third_party_libraries.licenses]] +license = "Apache-2.0" +text = """ +\r + Apache License\r + Version 2.0, January 2004\r + http://www.apache.org/licenses/\r +\r + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\r +\r + 1. Definitions.\r +\r + "License" shall mean the terms and conditions for use, reproduction,\r + and distribution as defined by Sections 1 through 9 of this document.\r +\r + "Licensor" shall mean the copyright owner or entity authorized by\r + the copyright owner that is granting the License.\r +\r + "Legal Entity" shall mean the union of the acting entity and all\r + other entities that control, are controlled by, or are under common\r + control with that entity. For the purposes of this definition,\r + "control" means (i) the power, direct or indirect, to cause the\r + direction or management of such entity, whether by contract or\r + otherwise, or (ii) ownership of fifty percent (50%) or more of the\r + outstanding shares, or (iii) beneficial ownership of such entity.\r +\r + "You" (or "Your") shall mean an individual or Legal Entity\r + exercising permissions granted by this License.\r +\r + "Source" form shall mean the preferred form for making modifications,\r + including but not limited to software source code, documentation\r + source, and configuration files.\r +\r + "Object" form shall mean any form resulting from mechanical\r + transformation or translation of a Source form, including but\r + not limited to compiled object code, generated documentation,\r + and conversions to other media types.\r +\r + "Work" shall mean the work of authorship, whether in Source or\r + Object form, made available under the License, as indicated by a\r + copyright notice that is included in or attached to the work\r + (an example is provided in the Appendix below).\r +\r + "Derivative Works" shall mean any work, whether in Source or Object\r + form, that is based on (or derived from) the Work and for which the\r + editorial revisions, annotations, elaborations, or other modifications\r + represent, as a whole, an original work of authorship. For the purposes\r + of this License, Derivative Works shall not include works that remain\r + separable from, or merely link (or bind by name) to the interfaces of,\r + the Work and Derivative Works thereof.\r +\r + "Contribution" shall mean any work of authorship, including\r + the original version of the Work and any modifications or additions\r + to that Work or Derivative Works thereof, that is intentionally\r + submitted to Licensor for inclusion in the Work by the copyright owner\r + or by an individual or Legal Entity authorized to submit on behalf of\r + the copyright owner. For the purposes of this definition, "submitted"\r + means any form of electronic, verbal, or written communication sent\r + to the Licensor or its representatives, including but not limited to\r + communication on electronic mailing lists, source code control systems,\r + and issue tracking systems that are managed by, or on behalf of, the\r + Licensor for the purpose of discussing and improving the Work, but\r + excluding communication that is conspicuously marked or otherwise\r + designated in writing by the copyright owner as "Not a Contribution."\r +\r + "Contributor" shall mean Licensor and any individual or Legal Entity\r + on behalf of whom a Contribution has been received by Licensor and\r + subsequently incorporated within the Work.\r +\r + 2. Grant of Copyright License. Subject to the terms and conditions of\r + this License, each Contributor hereby grants to You a perpetual,\r + worldwide, non-exclusive, no-charge, royalty-free, irrevocable\r + copyright license to reproduce, prepare Derivative Works of,\r + publicly display, publicly perform, sublicense, and distribute the\r + Work and such Derivative Works in Source or Object form.\r +\r + 3. Grant of Patent License. Subject to the terms and conditions of\r + this License, each Contributor hereby grants to You a perpetual,\r + worldwide, non-exclusive, no-charge, royalty-free, irrevocable\r + (except as stated in this section) patent license to make, have made,\r + use, offer to sell, sell, import, and otherwise transfer the Work,\r + where such license applies only to those patent claims licensable\r + by such Contributor that are necessarily infringed by their\r + Contribution(s) alone or by combination of their Contribution(s)\r + with the Work to which such Contribution(s) was submitted. If You\r + institute patent litigation against any entity (including a\r + cross-claim or counterclaim in a lawsuit) alleging that the Work\r + or a Contribution incorporated within the Work constitutes direct\r + or contributory patent infringement, then any patent licenses\r + granted to You under this License for that Work shall terminate\r + as of the date such litigation is filed.\r +\r + 4. Redistribution. You may reproduce and distribute copies of the\r + Work or Derivative Works thereof in any medium, with or without\r + modifications, and in Source or Object form, provided that You\r + meet the following conditions:\r +\r + (a) You must give any other recipients of the Work or\r + Derivative Works a copy of this License; and\r +\r + (b) You must cause any modified files to carry prominent notices\r + stating that You changed the files; and\r +\r + (c) You must retain, in the Source form of any Derivative Works\r + that You distribute, all copyright, patent, trademark, and\r + attribution notices from the Source form of the Work,\r + excluding those notices that do not pertain to any part of\r + the Derivative Works; and\r +\r + (d) If the Work includes a "NOTICE" text file as part of its\r + distribution, then any Derivative Works that You distribute must\r + include a readable copy of the attribution notices contained\r + within such NOTICE file, excluding those notices that do not\r + pertain to any part of the Derivative Works, in at least one\r + of the following places: within a NOTICE text file distributed\r + as part of the Derivative Works; within the Source form or\r + documentation, if provided along with the Derivative Works; or,\r + within a display generated by the Derivative Works, if and\r + wherever such third-party notices normally appear. The contents\r + of the NOTICE file are for informational purposes only and\r + do not modify the License. You may add Your own attribution\r + notices within Derivative Works that You distribute, alongside\r + or as an addendum to the NOTICE text from the Work, provided\r + that such additional attribution notices cannot be construed\r + as modifying the License.\r +\r + You may add Your own copyright statement to Your modifications and\r + may provide additional or different license terms and conditions\r + for use, reproduction, or distribution of Your modifications, or\r + for any such Derivative Works as a whole, provided Your use,\r + reproduction, and distribution of the Work otherwise complies with\r + the conditions stated in this License.\r +\r + 5. Submission of Contributions. Unless You explicitly state otherwise,\r + any Contribution intentionally submitted for inclusion in the Work\r + by You to the Licensor shall be under the terms and conditions of\r + this License, without any additional terms or conditions.\r + Notwithstanding the above, nothing herein shall supersede or modify\r + the terms of any separate license agreement you may have executed\r + with Licensor regarding such Contributions.\r +\r + 6. Trademarks. This License does not grant permission to use the trade\r + names, trademarks, service marks, or product names of the Licensor,\r + except as required for reasonable and customary use in describing the\r + origin of the Work and reproducing the content of the NOTICE file.\r +\r + 7. Disclaimer of Warranty. Unless required by applicable law or\r + agreed to in writing, Licensor provides the Work (and each\r + Contributor provides its Contributions) on an "AS IS" BASIS,\r + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\r + implied, including, without limitation, any warranties or conditions\r + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\r + PARTICULAR PURPOSE. You are solely responsible for determining the\r + appropriateness of using or redistributing the Work and assume any\r + risks associated with Your exercise of permissions under this License.\r +\r + 8. Limitation of Liability. In no event and under no legal theory,\r + whether in tort (including negligence), contract, or otherwise,\r + unless required by applicable law (such as deliberate and grossly\r + negligent acts) or agreed to in writing, shall any Contributor be\r + liable to You for damages, including any direct, indirect, special,\r + incidental, or consequential damages of any character arising as a\r + result of this License or out of the use or inability to use the\r + Work (including but not limited to damages for loss of goodwill,\r + work stoppage, computer failure or malfunction, or any and all\r + other commercial damages or losses), even if such Contributor\r + has been advised of the possibility of such damages.\r +\r + 9. Accepting Warranty or Additional Liability. While redistributing\r + the Work or Derivative Works thereof, You may choose to offer,\r + and charge a fee for, acceptance of support, warranty, indemnity,\r + or other liability obligations and/or rights consistent with this\r + License. However, in accepting such obligations, You may act only\r + on Your own behalf and on Your sole responsibility, not on behalf\r + of any other Contributor, and only if You agree to indemnify,\r + defend, and hold each Contributor harmless for any liability\r + incurred by, or claims asserted against, such Contributor by reason\r + of your accepting any such warranty or additional liability.\r +\r + END OF TERMS AND CONDITIONS\r +\r + APPENDIX: How to apply the Apache License to your work.\r +\r + To apply the Apache License to your work, attach the following\r + boilerplate notice, with the fields enclosed by brackets "[]"\r + replaced with your own identifying information. (Don't include\r + the brackets!) The text should be enclosed in the appropriate\r + comment syntax for the file format. We also recommend that a\r + file or class name and description of purpose be included on the\r + same "printed page" as the copyright notice for easier\r + identification within third-party archives.\r +\r + Copyright [yyyy] [name of copyright owner]\r +\r + Licensed under the Apache License, Version 2.0 (the "License");\r + you may not use this file except in compliance with the License.\r + You may obtain a copy of the License at\r +\r + http://www.apache.org/licenses/LICENSE-2.0\r +\r + Unless required by applicable law or agreed to in writing, software\r + distributed under the License is distributed on an "AS IS" BASIS,\r + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\r + See the License for the specific language governing permissions and\r + limitations under the License.\r +""" + [[third_party_libraries]] package_name = "arrayvec" package_version = "0.5.2" @@ -386,6 +599,42 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ +[[third_party_libraries]] +package_name = "by_address" +package_version = "1.2.1" +repository = "https://github.com/mbrubeck/by_address" +license = "MIT" + +[[third_party_libraries.licenses]] +license = "MIT" +text = """ +Copyright (c) 2017 Matt Brubeck + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + [[third_party_libraries]] package_name = "bytecount" package_version = "0.6.9" @@ -530,6 +779,38 @@ license = "No license specified" license = "No license specified" text = "NOT FOUND" +[[third_party_libraries]] +package_name = "compact_str" +package_version = "0.9.1" +repository = "https://github.com/ParkMyCar/compact_str" +license = "MIT" + +[[third_party_libraries.licenses]] +license = "MIT" +text = """ +MIT License + +Copyright (c) 2021 Parker Timmerman + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + [[third_party_libraries]] package_name = "compact_str" package_version = "0.10.0" @@ -562,6 +843,16 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ +[[third_party_libraries]] +package_name = "configuration" +package_version = "0.1.0" +repository = "" +license = "No license specified" + +[[third_party_libraries.licenses]] +license = "No license specified" +text = "NOT FOUND" + [[third_party_libraries]] package_name = "console" package_version = "0.16.4" @@ -595,6 +886,37 @@ SOFTWARE. """ +[[third_party_libraries]] +package_name = "convert_case" +package_version = "0.10.0" +repository = "https://github.com/rutrum/convert-case" +license = "MIT" + +[[third_party_libraries.licenses]] +license = "MIT" +text = """ +MIT License + +Copyright (c) 2025 rutrum + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE.""" + [[third_party_libraries]] package_name = "cow-utils" package_version = "0.1.3" @@ -741,6 +1063,166 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ +[[third_party_libraries]] +package_name = "crossterm" +package_version = "0.29.0" +repository = "https://github.com/crossterm-rs/crossterm" +license = "MIT" + +[[third_party_libraries.licenses]] +license = "MIT" +text = """ +MIT License\r +\r +Copyright (c) 2019 Timon\r +\r +Permission is hereby granted, free of charge, to any person obtaining a copy\r +of this software and associated documentation files (the "Software"), to deal\r +in the Software without restriction, including without limitation the rights\r +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\r +copies of the Software, and to permit persons to whom the Software is\r +furnished to do so, subject to the following conditions:\r +\r +The above copyright notice and this permission notice shall be included in all\r +copies or substantial portions of the Software.\r +\r +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\r +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\r +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\r +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\r +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\r +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\r +SOFTWARE.\r +""" + +[[third_party_libraries]] +package_name = "crossterm_winapi" +package_version = "0.9.1" +repository = "https://github.com/crossterm-rs/crossterm-winapi" +license = "MIT" + +[[third_party_libraries.licenses]] +license = "MIT" +text = """ +MIT License\r +\r +Copyright (c) 2019 Timon\r +\r +Permission is hereby granted, free of charge, to any person obtaining a copy\r +of this software and associated documentation files (the "Software"), to deal\r +in the Software without restriction, including without limitation the rights\r +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\r +copies of the Software, and to permit persons to whom the Software is\r +furnished to do so, subject to the following conditions:\r +\r +The above copyright notice and this permission notice shall be included in all\r +copies or substantial portions of the Software.\r +\r +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\r +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\r +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\r +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\r +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\r +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\r +SOFTWARE.\r +""" + +[[third_party_libraries]] +package_name = "darling" +package_version = "0.24.1" +repository = "https://github.com/TedDriggs/darling" +license = "MIT" + +[[third_party_libraries.licenses]] +license = "MIT" +text = """ +MIT License + +Copyright (c) 2017 Ted Driggs + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +[[third_party_libraries]] +package_name = "darling_core" +package_version = "0.24.1" +repository = "https://github.com/TedDriggs/darling" +license = "MIT" + +[[third_party_libraries.licenses]] +license = "MIT" +text = """ +MIT License + +Copyright (c) 2017 Ted Driggs + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +[[third_party_libraries]] +package_name = "darling_macro" +package_version = "0.24.1" +repository = "https://github.com/TedDriggs/darling" +license = "MIT" + +[[third_party_libraries.licenses]] +license = "MIT" +text = """ +MIT License + +Copyright (c) 2017 Ted Driggs + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + [[third_party_libraries]] package_name = "defmt" package_version = "1.1.1" @@ -821,6 +1303,100 @@ license = "MIT" license = "MIT" text = "NOT FOUND" +[[third_party_libraries]] +package_name = "deranged" +package_version = "0.5.8" +repository = "https://github.com/jhpratt/deranged" +license = "MIT" + +[[third_party_libraries.licenses]] +license = "MIT" +text = """ +Copyright (c) 2024 Jacob Pratt et al. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +[[third_party_libraries]] +package_name = "derive_more" +package_version = "2.1.1" +repository = "https://github.com/JelteF/derive_more" +license = "MIT" + +[[third_party_libraries.licenses]] +license = "MIT" +text = """ +The MIT License (MIT) + +Copyright (c) 2016 Jelte Fennema + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +[[third_party_libraries]] +package_name = "derive_more-impl" +package_version = "2.1.1" +repository = "https://github.com/JelteF/derive_more" +license = "MIT" + +[[third_party_libraries.licenses]] +license = "MIT" +text = """ +The MIT License (MIT) + +Copyright (c) 2016 Jelte Fennema + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + [[third_party_libraries]] package_name = "diagnostics" package_version = "0.1.0" @@ -866,14 +1442,34 @@ DEALINGS IN THE SOFTWARE. """ [[third_party_libraries]] -package_name = "documentation" -package_version = "0.1.0" -repository = "" -license = "No license specified" +package_name = "document-features" +package_version = "0.2.12" +repository = "https://github.com/slint-ui/document-features" +license = "MIT" [[third_party_libraries.licenses]] -license = "No license specified" -text = "NOT FOUND" +license = "MIT" +text = """ +Copyright (c) 2020 Olivier Goffart + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" [[third_party_libraries]] package_name = "documenting" @@ -2210,6 +2806,42 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ +[[third_party_libraries]] +package_name = "heck" +package_version = "0.5.0" +repository = "https://github.com/withoutboats/heck" +license = "MIT" + +[[third_party_libraries.licenses]] +license = "MIT" +text = """ +Copyright (c) 2015 The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + [[third_party_libraries]] package_name = "icu_collections" package_version = "2.3.0" @@ -2609,6 +3241,36 @@ Portions of ICU4X may have been adapted from ICU4C and/or ICU4J. ICU 1.8.1 to ICU 57.1 © 1995-2016 International Business Machines Corporation and others. """ +[[third_party_libraries]] +package_name = "ident_case" +package_version = "1.0.1" +repository = "https://github.com/TedDriggs/ident_case" +license = "MIT" + +[[third_party_libraries.licenses]] +license = "MIT" +text = """ +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + [[third_party_libraries]] package_name = "idna" package_version = "1.1.0" @@ -2760,36 +3422,37 @@ DEALINGS IN THE SOFTWARE. """ [[third_party_libraries]] -package_name = "indicatif" -package_version = "0.18.6" -repository = "https://github.com/console-rs/indicatif" +package_name = "indoc" +package_version = "2.0.7" +repository = "https://github.com/dtolnay/indoc" license = "MIT" [[third_party_libraries.licenses]] license = "MIT" text = """ -The MIT License (MIT) - -Copyright (c) 2017 Armin Ronacher - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. """ [[third_party_libraries]] @@ -2839,6 +3502,39 @@ OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.""" +[[third_party_libraries]] +package_name = "instability" +package_version = "0.3.13" +repository = "https://github.com/ratatui/instability" +license = "MIT" + +[[third_party_libraries.licenses]] +license = "MIT" +text = """ +# MIT License + +Copyright (c) 2020 Stephen M. Coakley +Copyright (c) The Ratatui Developers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + [[third_party_libraries]] package_name = "interner" package_version = "0.1.0" @@ -2849,6 +3545,252 @@ license = "No license specified" license = "No license specified" text = "NOT FOUND" +[[third_party_libraries]] +package_name = "iris-build" +package_version = "0.1.0" +repository = "https://github.com/purefunctor/purescript-iris" +license = "BSD-3-Clause" + +[[third_party_libraries.licenses]] +license = "BSD-3-Clause" +text = """ +BSD 3-Clause License + +Copyright (c) 2023, purefunctor +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Additional license information can be found in the ACKNOWLEDGEMENTS.md file. +""" + +[[third_party_libraries]] +package_name = "iris-lsp" +package_version = "0.1.0" +repository = "https://github.com/purefunctor/purescript-iris" +license = "BSD-3-Clause" + +[[third_party_libraries.licenses]] +license = "BSD-3-Clause" +text = """ +BSD 3-Clause License + +Copyright (c) 2023, purefunctor +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Additional license information can be found in the ACKNOWLEDGEMENTS.md file. +""" + +[[third_party_libraries]] +package_name = "iris-package" +package_version = "0.1.0" +repository = "https://github.com/purefunctor/purescript-iris" +license = "BSD-3-Clause" + +[[third_party_libraries.licenses]] +license = "BSD-3-Clause" +text = """ +BSD 3-Clause License + +Copyright (c) 2023, purefunctor +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Additional license information can be found in the ACKNOWLEDGEMENTS.md file. +""" + +[[third_party_libraries]] +package_name = "iris-progress" +package_version = "0.1.0" +repository = "https://github.com/purefunctor/purescript-iris" +license = "BSD-3-Clause" + +[[third_party_libraries.licenses]] +license = "BSD-3-Clause" +text = """ +BSD 3-Clause License + +Copyright (c) 2023, purefunctor +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Additional license information can be found in the ACKNOWLEDGEMENTS.md file. +""" + +[[third_party_libraries]] +package_name = "iris-watch" +package_version = "0.1.0" +repository = "https://github.com/purefunctor/purescript-iris" +license = "BSD-3-Clause" + +[[third_party_libraries.licenses]] +license = "BSD-3-Clause" +text = """ +BSD 3-Clause License + +Copyright (c) 2023, purefunctor +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Additional license information can be found in the ACKNOWLEDGEMENTS.md file. +""" + +[[third_party_libraries]] +package_name = "itertools" +package_version = "0.14.0" +repository = "https://github.com/rust-itertools/itertools" +license = "MIT" + +[[third_party_libraries.licenses]] +license = "MIT" +text = """ +Copyright (c) 2015 + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + [[third_party_libraries]] package_name = "itertools" package_version = "0.15.0" @@ -3125,6 +4067,39 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ +[[third_party_libraries]] +package_name = "kasuari" +package_version = "0.4.12" +repository = "https://github.com/ratatui/kasuari" +license = "MIT" + +[[third_party_libraries.licenses]] +license = "MIT" +text = """ +The MIT License (MIT) + +Copyright (c) 2016 Dylan Ede +Copyright (c) 2024 Josh McKinney + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + [[third_party_libraries]] package_name = "kqueue" package_version = "1.2.1" @@ -3580,6 +4555,38 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ +[[third_party_libraries]] +package_name = "line-clipping" +package_version = "0.3.8" +repository = "https://github.com/ratatui/line-clipping" +license = "MIT" + +[[third_party_libraries.licenses]] +license = "MIT" +text = """ +MIT License + +Copyright (c) 2024 Josh McKinney + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + [[third_party_libraries]] package_name = "line-index" package_version = "0.1.2" @@ -3681,6 +4688,42 @@ Portions of ICU4X may have been adapted from ICU4C and/or ICU4J. ICU 1.8.1 to ICU 57.1 © 1995-2016 International Business Machines Corporation and others. """ +[[third_party_libraries]] +package_name = "litrs" +package_version = "1.0.0" +repository = "https://github.com/LukasKalbertodt/litrs" +license = "MIT" + +[[third_party_libraries.licenses]] +license = "MIT" +text = """ +Copyright (c) 2020 Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + [[third_party_libraries]] package_name = "lock_api" package_version = "0.4.14" @@ -3763,6 +4806,37 @@ license = "No license specified" license = "No license specified" text = "NOT FOUND" +[[third_party_libraries]] +package_name = "lru" +package_version = "0.18.4" +repository = "https://github.com/jeromefroe/lru-rs.git" +license = "MIT" + +[[third_party_libraries.licenses]] +license = "MIT" +text = """ +MIT License + +Copyright (c) 2016 Jerome Froelich + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE.""" + [[third_party_libraries]] package_name = "lsp-types" package_version = "0.95.1" @@ -4063,6 +5137,36 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ +[[third_party_libraries]] +package_name = "num-conv" +package_version = "0.2.2" +repository = "https://github.com/jhpratt/num-conv" +license = "MIT" + +[[third_party_libraries.licenses]] +license = "MIT" +text = """ +Copyright (c) Jacob Pratt + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + [[third_party_libraries]] package_name = "num-integer" package_version = "0.1.47" @@ -4135,6 +5239,36 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ +[[third_party_libraries]] +package_name = "num_threads" +package_version = "0.1.7" +repository = "https://github.com/jhpratt/num_threads" +license = "MIT" + +[[third_party_libraries.licenses]] +license = "MIT" +text = """ +Copyright (c) 2021 Jacob Pratt + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + [[third_party_libraries]] package_name = "once_cell" package_version = "1.21.4" @@ -4319,15 +5453,114 @@ license = "MIT" license = "MIT" text = "NOT FOUND" -[[third_party_libraries]] -package_name = "oxc_syntax" -package_version = "0.149.0" -repository = "https://github.com/oxc-project/oxc" -license = "MIT" +[[third_party_libraries]] +package_name = "oxc_syntax" +package_version = "0.149.0" +repository = "https://github.com/oxc-project/oxc" +license = "MIT" + +[[third_party_libraries.licenses]] +license = "MIT" +text = "NOT FOUND" + +[[third_party_libraries]] +package_name = "palette" +package_version = "0.7.7" +repository = "https://github.com/Ogeon/palette" +license = "MIT" + +[[third_party_libraries.licenses]] +license = "MIT" +text = """ +The MIT License (MIT) + +Copyright (c) 2015 Erik Hedvall + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +""" + +[[third_party_libraries]] +package_name = "palette_derive" +package_version = "0.7.7" +repository = "https://github.com/Ogeon/palette" +license = "MIT" + +[[third_party_libraries.licenses]] +license = "MIT" +text = """ +The MIT License (MIT) + +Copyright (c) 2015 Erik Hedvall + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +""" + +[[third_party_libraries]] +package_name = "palette_math" +package_version = "0.7.7" +repository = "https://github.com/Ogeon/palette" +license = "MIT" + +[[third_party_libraries.licenses]] +license = "MIT" +text = """ +The MIT License (MIT) + +Copyright (c) 2015 Erik Hedvall + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. -[[third_party_libraries.licenses]] -license = "MIT" -text = "NOT FOUND" +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +""" [[third_party_libraries]] package_name = "papaya" @@ -4894,6 +6127,36 @@ Portions of ICU4X may have been adapted from ICU4C and/or ICU4J. ICU 1.8.1 to ICU 57.1 © 1995-2016 International Business Machines Corporation and others. """ +[[third_party_libraries]] +package_name = "powerfmt" +package_version = "0.2.0" +repository = "https://github.com/jhpratt/powerfmt" +license = "MIT" + +[[third_party_libraries.licenses]] +license = "MIT" +text = """ +Copyright (c) 2023 Jacob Pratt et al. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + [[third_party_libraries]] package_name = "pretty" package_version = "0.12.5" @@ -5081,6 +6344,138 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ +[[third_party_libraries]] +package_name = "ratatui" +package_version = "0.30.2" +repository = "https://github.com/ratatui/ratatui" +license = "MIT" + +[[third_party_libraries.licenses]] +license = "MIT" +text = """ +The MIT License (MIT) + +Copyright (c) 2016-2022 Florian Dehau +Copyright (c) 2023-2025 The Ratatui Developers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +[[third_party_libraries]] +package_name = "ratatui-core" +package_version = "0.1.2" +repository = "https://github.com/ratatui/ratatui" +license = "MIT" + +[[third_party_libraries.licenses]] +license = "MIT" +text = """ +The MIT License (MIT) + +Copyright (c) 2016-2022 Florian Dehau +Copyright (c) 2023-2025 The Ratatui Developers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +[[third_party_libraries]] +package_name = "ratatui-crossterm" +package_version = "0.1.2" +repository = "https://github.com/ratatui/ratatui" +license = "MIT" + +[[third_party_libraries.licenses]] +license = "MIT" +text = """ +The MIT License (MIT) + +Copyright (c) 2016-2022 Florian Dehau +Copyright (c) 2023-2025 The Ratatui Developers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +[[third_party_libraries]] +package_name = "ratatui-widgets" +package_version = "0.3.2" +repository = "https://github.com/ratatui/ratatui" +license = "MIT" + +[[third_party_libraries.licenses]] +license = "MIT" +text = """ +The MIT License (MIT) + +Copyright (c) 2016-2022 Florian Dehau +Copyright (c) 2023-2025 The Ratatui Developers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + [[third_party_libraries]] package_name = "rayon" package_version = "1.12.0" @@ -6138,36 +7533,108 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ -[[third_party_libraries]] -package_name = "sharded-slab" -package_version = "0.1.7" -repository = "https://github.com/hawkw/sharded-slab" -license = "MIT" - -[[third_party_libraries.licenses]] -license = "MIT" -text = """ -Copyright (c) 2019 Eliza Weisman - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. -""" - +[[third_party_libraries]] +package_name = "sharded-slab" +package_version = "0.1.7" +repository = "https://github.com/hawkw/sharded-slab" +license = "MIT" + +[[third_party_libraries.licenses]] +license = "MIT" +text = """ +Copyright (c) 2019 Eliza Weisman + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +""" + +[[third_party_libraries]] +package_name = "signal-hook" +package_version = "0.3.18" +repository = "https://github.com/vorner/signal-hook" +license = "MIT" + +[[third_party_libraries.licenses]] +license = "MIT" +text = """ +Copyright (c) 2017 tokio-jsonrpc developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +[[third_party_libraries]] +package_name = "signal-hook-mio" +package_version = "0.2.5" +repository = "https://github.com/vorner/signal-hook" +license = "MIT" + +[[third_party_libraries.licenses]] +license = "MIT" +text = """ +Copyright (c) 2017 tokio-jsonrpc developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + [[third_party_libraries]] package_name = "signal-hook-registry" package_version = "1.4.8" @@ -6549,6 +8016,70 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ +[[third_party_libraries]] +package_name = "strum" +package_version = "0.28.0" +repository = "https://github.com/Peternator7/strum" +license = "MIT" + +[[third_party_libraries.licenses]] +license = "MIT" +text = """ +MIT License + +Copyright (c) 2019 Peter Glotfelty + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +[[third_party_libraries]] +package_name = "strum_macros" +package_version = "0.28.0" +repository = "https://github.com/Peternator7/strum" +license = "MIT" + +[[third_party_libraries.licenses]] +license = "MIT" +text = """ +MIT License + +Copyright (c) 2019 Peter Glotfelty + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + [[third_party_libraries]] package_name = "sugar" package_version = "0.1.0" @@ -6889,38 +8420,6 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ -[[third_party_libraries]] -package_name = "termcolor" -package_version = "1.4.1" -repository = "https://github.com/BurntSushi/termcolor" -license = "MIT" - -[[third_party_libraries.licenses]] -license = "MIT" -text = """ -The MIT License (MIT) - -Copyright (c) 2015 Andrew Gallant - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. -""" - [[third_party_libraries]] package_name = "terminal-colorsaurus" package_version = "1.0.3" @@ -7155,6 +8654,66 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ +[[third_party_libraries]] +package_name = "time" +package_version = "0.3.55" +repository = "https://github.com/time-rs/time" +license = "MIT" + +[[third_party_libraries.licenses]] +license = "MIT" +text = """ +Copyright (c) Jacob Pratt et al. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +[[third_party_libraries]] +package_name = "time-core" +package_version = "0.1.9" +repository = "https://github.com/time-rs/time" +license = "MIT" + +[[third_party_libraries.licenses]] +license = "MIT" +text = """ +Copyright (c) Jacob Pratt et al. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + [[third_party_libraries]] package_name = "tinystr" package_version = "0.8.4" @@ -7597,26 +9156,6 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ -[[third_party_libraries]] -package_name = "ts-rs" -package_version = "12.0.1" -repository = "https://github.com/Aleph-Alpha/ts-rs" -license = "MIT" - -[[third_party_libraries.licenses]] -license = "MIT" -text = "NOT FOUND" - -[[third_party_libraries]] -package_name = "ts-rs-macros" -package_version = "12.0.1" -repository = "https://github.com/Aleph-Alpha/ts-rs" -license = "MIT" - -[[third_party_libraries.licenses]] -license = "MIT" -text = "NOT FOUND" - [[third_party_libraries]] package_name = "typed-arena" package_version = "2.0.2" @@ -8416,15 +9955,15 @@ DEALINGS IN THE SOFTWARE. """ [[third_party_libraries]] -package_name = "unicode-width" -package_version = "0.2.2" -repository = "https://github.com/unicode-rs/unicode-width" +package_name = "unicode-truncate" +package_version = "2.0.1" +repository = "https://github.com/Aetf/unicode-truncate" license = "MIT" [[third_party_libraries.licenses]] license = "MIT" text = """ -Copyright (c) 2015 The Rust Project Developers +Copyright (c) 2019 Aetf Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated @@ -8452,47 +9991,51 @@ DEALINGS IN THE SOFTWARE. """ [[third_party_libraries]] -package_name = "unicode_categories" -package_version = "0.1.1" -repository = "https://github.com/swgillespie/unicode-categories" +package_name = "unicode-width" +package_version = "0.2.2" +repository = "https://github.com/unicode-rs/unicode-width" license = "MIT" [[third_party_libraries.licenses]] license = "MIT" text = """ -Copyright (c) 2015 The unicode-categories Developers +Copyright (c) 2015 The Rust Project Developers -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. """ [[third_party_libraries]] -package_name = "unit-prefix" -package_version = "0.5.2" -repository = "https://codeberg.org/commons-rs/unit-prefix" +package_name = "unicode_categories" +package_version = "0.1.1" +repository = "https://github.com/swgillespie/unicode-categories" license = "MIT" [[third_party_libraries.licenses]] license = "MIT" text = """ -MIT License - -Copyright (c) 2024 Benjamin Sago, Fabio Valentini +Copyright (c) 2015 The unicode-categories Developers Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -8852,17 +10395,15 @@ DEALINGS IN THE SOFTWARE. """ [[third_party_libraries]] -package_name = "web-time" -package_version = "1.1.0" -repository = "https://github.com/daxpedda/web-time" +package_name = "winapi" +package_version = "0.3.9" +repository = "https://github.com/retep998/winapi-rs" license = "MIT" [[third_party_libraries.licenses]] license = "MIT" text = """ -MIT License - -Copyright (c) 2023 dAxpeDDa +Copyright (c) 2015-2018 The winapi-rs Developers Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -8883,6 +10424,16 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ +[[third_party_libraries]] +package_name = "winapi-i686-pc-windows-gnu" +package_version = "0.4.0" +repository = "https://github.com/retep998/winapi-rs" +license = "MIT" + +[[third_party_libraries.licenses]] +license = "MIT" +text = "NOT FOUND" + [[third_party_libraries]] package_name = "winapi-util" package_version = "0.1.11" @@ -8915,6 +10466,16 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ +[[third_party_libraries]] +package_name = "winapi-x86_64-pc-windows-gnu" +package_version = "0.4.0" +repository = "https://github.com/retep998/winapi-rs" +license = "MIT" + +[[third_party_libraries.licenses]] +license = "MIT" +text = "NOT FOUND" + [[third_party_libraries]] package_name = "windows-link" package_version = "0.2.1" diff --git a/compiler-bin/Cargo.toml b/compiler-bin/Cargo.toml deleted file mode 100644 index 2930b46a1..000000000 --- a/compiler-bin/Cargo.toml +++ /dev/null @@ -1,73 +0,0 @@ -[package] -name = "purescript-iris" -version = "0.1.0" -edition = "2024" -authors = ["Justin Garcia "] -license = "BSD-3-Clause" -description = "Language implementation for the PureScript programming language." -readme = "../README.md" -homepage = "https://github.com/purefunctor/purescript-iris" -repository = "https://github.com/purefunctor/purescript-iris" -keywords = ["purescript", "language server"] -categories = ["compilers"] - -[features] -default = [] -no-tracing = ["tracing/max_level_off"] - -[[bin]] -name = "iris" -path = "src/bin/iris.rs" - -[dependencies] -analyzer = { version = "0.1.0", path = "../compiler-lsp/analyzer" } -async-lsp = { version = "0.2.4", features = ["tokio"] } -building = { version = "0.1.0", path = "../compiler-core/building" } -checking = { version = "0.1.0", path = "../compiler-frontend/checking" } -configuration = { version = "0.1.0", path = "../compiler-lsp/configuration" } -console = "0.16.4" -documentation = { version = "0.1.0", path = "../compiler-lsp/documentation" } -diagnostics = { version = "0.1.0", path = "../compiler-frontend/diagnostics" } -dunce = "1.0.5" -files = { version = "0.1.0", path = "../compiler-core/files" } -globset = "0.4.20" -ignore = "0.4.33" -indicatif = "0.18.6" -indexing = { version = "0.1.0", path = "../compiler-frontend/indexing" } -itertools = "0.15.0" -javascript = { version = "0.1.0", path = "../compiler-backend/javascript" } -jiff = "0.2.35" -line-index = "0.1.2" -lowering = { version = "0.1.0", path = "../compiler-frontend/lowering" } -lsp-types.workspace = true -notify = "8.2.0" -parking_lot = "0.12.5" -path-absolutize = "4.0.1" -prim-constants = { version = "0.1.0", path = "../compiler-core/prim-constants" } -radix_trie = "0.3.0" -rayon = "1.12.0" -resolving = { version = "0.1.0", path = "../compiler-frontend/resolving" } -rustc-hash = "2.1.3" -serde = { version = "1.0.229", features = ["derive"] } -serde_json = "1.0.151" -serde_yml = "0.0.12" -smol_str = "0.3.6" -spago = { version = "0.1.0", path = "../compiler-lsp/spago" } -stabilizing = { version = "0.1.0", path = "../compiler-frontend/stabilizing" } -tempfile = "3.27.0" -terminal-colorsaurus = "1.0.3" -thiserror = "2.0.20" -tokio = { version = "1.53.1", features = ["full"] } -tower = "0.5.3" -tracing = "0.1.44" -tracing-subscriber = "0.3.23" -ts-rs = { version = "12.0.1", default-features = false, features = ["serde-compat"] } -url = "2.5.8" -usage = { package = "usage-rs", version = "6.8.0" } -walkdir = "2.5.0" - -[dev-dependencies] -insta = "1.48.0" - -[target.'cfg(not(unix))'.dependencies] -tokio-util = { version = "0.7.19", features = ["compat"] } diff --git a/compiler-bin/iris-build/Cargo.toml b/compiler-bin/iris-build/Cargo.toml new file mode 100644 index 000000000..655340b75 --- /dev/null +++ b/compiler-bin/iris-build/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "iris-build" +version = "0.1.0" +edition = "2024" +authors = ["Justin Garcia "] +license = "BSD-3-Clause" +description = "PureScript package build planning and execution for Iris." +repository = "https://github.com/purefunctor/purescript-iris" + +[dependencies] +building = { version = "0.1.0", path = "../../compiler-core/building" } +iris-progress = { version = "0.1.0", path = "../iris-progress" } +diagnostics = { version = "0.1.0", path = "../../compiler-frontend/diagnostics" } +dunce = "1.0.5" +files = { version = "0.1.0", path = "../../compiler-core/files" } +globset = "0.4.20" +ignore = "0.4.33" +itertools = "0.15.0" +javascript = { version = "0.1.0", path = "../../compiler-backend/javascript" } +line-index = "0.1.2" +path-absolutize = "4.0.1" +petgraph = "0.8.3" +prim-constants = { version = "0.1.0", path = "../../compiler-core/prim-constants" } +rayon = "1.12.0" +serde = { version = "1.0.229", features = ["derive"] } +serde_yml = "0.0.12" +smol_str = "0.3.6" +spago = { version = "0.1.0", path = "../../compiler-lsp/spago" } +tempfile = "3.27.0" +thiserror = "2.0.20" +url = "2.5.8" +walkdir = "2.5.0" diff --git a/compiler-bin/bundled/project/runner.mjs b/compiler-bin/iris-build/bundled/runner.mjs similarity index 100% rename from compiler-bin/bundled/project/runner.mjs rename to compiler-bin/iris-build/bundled/runner.mjs diff --git a/compiler-bin/iris-build/src/compilation.rs b/compiler-bin/iris-build/src/compilation.rs new file mode 100644 index 000000000..2eeb089ee --- /dev/null +++ b/compiler-bin/iris-build/src/compilation.rs @@ -0,0 +1,171 @@ +//! Compiler query state used by build execution and long-lived consumers. + +use std::collections::BTreeSet; +use std::sync::Arc; +use std::{fs, io}; + +use building::{ + DiskObservation, FileLifecycle, ForeignEvent, LifecycleChange, LifecycleEvent, QueryEngine, + QueryError, SourceEvent, SourceUnitKey, +}; +use files::{FileId, ForeignSourceKind}; +use prim_constants::MODULE_MAP; +use tempfile::TempDir; +use url::Url; + +pub struct MaterializedPrim { + directory: TempDir, +} + +impl MaterializedPrim { + pub fn new() -> io::Result { + let directory = TempDir::new()?; + for (name, content) in MODULE_MAP { + fs::write(directory.path().join(format!("{name}.purs")), content)?; + } + Ok(MaterializedPrim { directory }) + } +} + +pub struct CompilationState { + engine: QueryEngine, + files: FileLifecycle, + sources: BTreeSet, + prim: MaterializedPrim, +} + +pub struct CompilationParts { + pub engine: QueryEngine, + pub files: FileLifecycle, + pub prim: MaterializedPrim, +} + +impl CompilationState { + pub fn new( + prim: MaterializedPrim, + prim_metadata: Metadata, + ) -> CompilationState { + let engine = QueryEngine::default(); + let mut files = FileLifecycle::default(); + + for (name, content) in MODULE_MAP { + let path = prim.directory.path().join(format!("{name}.purs")); + let source = Url::from_file_path(&path) + .expect("invariant violated: failed to create Prim module file URL"); + let foreign_path = path.with_extension("js"); + let foreign = Url::from_file_path(&foreign_path) + .expect("invariant violated: failed to create Prim foreign file URL"); + let event = LifecycleEvent::Source { + unit: SourceUnitKey::new(source.as_str(), foreign.as_str()), + event: SourceEvent::DiskObserved { + disk: DiskObservation::Found(Arc::from(*content)), + metadata: Metadata::clone(&prim_metadata), + }, + }; + + let change = files.apply(&engine, event); + let id = change + .changed_sources() + .next() + .expect("invariant violated: Prim source lifecycle did not insert a source"); + engine.set_module_file(name, id); + } + + CompilationState { engine, files, sources: BTreeSet::new(), prim } + } + + pub fn observe_source( + &mut self, + unit: SourceUnitKey, + disk: DiskObservation, + metadata: Metadata, + ) -> LifecycleChange { + let event = + LifecycleEvent::Source { unit, event: SourceEvent::DiskObserved { disk, metadata } }; + let change = self.files.apply(&self.engine, event); + self.sources.extend(change.changed_sources()); + for removed in change.removed_sources() { + self.sources.remove(&removed.file_id); + } + change + } + + pub fn observe_foreign( + &mut self, + unit: SourceUnitKey, + kind: ForeignSourceKind, + disk: DiskObservation, + ) -> LifecycleChange { + let event = + LifecycleEvent::Foreign { unit, kind, event: ForeignEvent::DiskObserved { disk } }; + self.files.apply(&self.engine, event) + } + + pub fn source_content(&self, locator: &str) -> Result>, QueryError> { + let Some(file_id) = self.files.source_id(locator) else { + return Ok(None); + }; + self.engine.content(file_id).map(Some) + } + + pub fn foreign_content(&self, locator: &str) -> Option> { + let file_id = self.files.foreign_id(locator)?; + self.engine.foreign_content(file_id) + } + + pub fn snapshot(&self) -> QueryEngine { + self.engine.snapshot() + } + + pub(crate) fn query_engine(&self) -> &QueryEngine { + &self.engine + } + + pub fn source_ids(&self) -> impl Iterator + '_ { + self.sources.iter().copied() + } + + pub fn into_parts(self) -> CompilationParts { + CompilationParts { engine: self.engine, files: self.files, prim: self.prim } + } + + pub fn source_path(&self, file_id: FileId) -> Option> { + self.files.source_path(file_id) + } + + pub fn source_foreign_content(&self, source_id: FileId) -> Option> { + let foreign_id = self.engine.foreign_file(source_id)?; + let content = self + .engine + .foreign_content(foreign_id) + .expect("invariant violated: associated foreign file has no engine content"); + Some(content) + } + + pub fn module_name(&self, locator: &str) -> Result, QueryError> { + let Some(file_id) = self.files.source_id(locator) else { + return Ok(None); + }; + let engine = self.engine.snapshot(); + let content = engine.content(file_id)?; + let (parsed, _) = engine.parsed(file_id)?; + Ok(parsed.module_name(&content).map(|name| name.to_string())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn prim_modules_are_materialized() { + let prim = MaterializedPrim::new().unwrap(); + let compilation = CompilationState::<(), ()>::new(prim, ()); + let prim = compilation.snapshot().module_file("Prim").unwrap(); + let source = compilation.source_path(prim).unwrap(); + let path = Url::parse(&source).unwrap().to_file_path().unwrap(); + + assert!(path.is_file()); + assert_eq!(fs::read_to_string(path).unwrap(), MODULE_MAP[0].1); + } +} diff --git a/compiler-bin/iris-build/src/compile.rs b/compiler-bin/iris-build/src/compile.rs new file mode 100644 index 000000000..813ee0374 --- /dev/null +++ b/compiler-bin/iris-build/src/compile.rs @@ -0,0 +1,486 @@ +use std::collections::{BTreeSet, HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Instant; +use std::{fs, io}; + +use building::{DiskObservation, QueryError, SourceUnitKey}; +use diagnostics::Severity; +use files::{FileId, ForeignSourceKind}; +use itertools::Itertools; +use rayon::prelude::*; +use thiserror::Error; +use url::Url; + +use super::compilation::{CompilationState, MaterializedPrim}; +use super::events::{BuildEvent, BuildEventSink, BuildOutcome}; +use super::plan::{BuildPlan, BuildPlanError, PackageInput, SelectedSource}; +use super::{executor, walk}; + +pub(crate) struct BuildConfig<'a> { + pub root: PathBuf, + pub output: PathBuf, + pub source_globs: Vec, + pub packages: Vec, + pub color: bool, + pub diagnostics: bool, + pub resilient: bool, + pub events: &'a dyn BuildEventSink, +} + +#[derive(Debug, Error)] +pub enum CompileError { + #[error("compilation failed")] + Diagnostics { reported: bool }, + #[error("failed to convert path to a file URL: {0}")] + InvalidPath(PathBuf), + #[error("no input files found")] + NoInputs, + #[error(transparent)] + BuildPlan(#[from] BuildPlanError), + #[error(transparent)] + Io(#[from] io::Error), + #[error(transparent)] + Query(#[from] QueryError), + #[error(transparent)] + Walk(#[from] walk::Error), +} + +pub(crate) struct RebuildResult { + pub outcome: BuildOutcome, + pub outputs: BTreeSet, +} + +struct ModuleWrite { + outputs: Vec, + result: Result<(), CompileError>, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PackageExecution { + Serial, + Parallel, +} + +pub struct InitialBuildConfig<'a, Metadata, SourceMetadata> { + pub root: &'a Path, + pub source_globs: &'a [PathBuf], + pub excluded: &'a [PathBuf], + pub packages: Vec, + pub prim_metadata: Metadata, + pub source_metadata: SourceMetadata, + pub execution: PackageExecution, + pub events: &'a dyn BuildEventSink, +} + +pub struct InitialBuild { + compilation: CompilationState, + source_paths: BTreeSet, + report: InitialBuildReport, +} + +pub(crate) struct InitialBuildReport { + sources: Vec, + diagnostics: Vec, + has_errors: bool, + no_inputs: bool, + failure: Option, + duration: std::time::Duration, +} + +pub(crate) struct InitialBuildParts { + pub compilation: CompilationState, + pub source_paths: BTreeSet, + pub report: InitialBuildReport, +} + +impl InitialBuild { + pub fn compilation(&self) -> &CompilationState { + &self.compilation + } + + pub fn sources(&self) -> &[FileId] { + &self.report.sources + } + + pub fn has_errors(&self) -> bool { + self.report.has_errors + } + + pub fn duration(&self) -> std::time::Duration { + self.report.duration + } + + pub fn into_compilation(self) -> CompilationState { + self.compilation + } + + pub(crate) fn into_parts(self) -> InitialBuildParts { + InitialBuildParts { + compilation: self.compilation, + source_paths: self.source_paths, + report: self.report, + } + } +} + +pub fn build_initial( + config: InitialBuildConfig<'_, Metadata, SourceMetadata>, +) -> Result, CompileError> +where + Version: Clone + Ord, + Metadata: Clone, + SourceMetadata: for<'path> Fn(&'path Path) -> Metadata, +{ + let InitialBuildConfig { + root, + source_globs, + excluded, + packages, + prim_metadata, + source_metadata, + execution, + events, + } = config; + let started = Instant::now(); + let selected_paths = walk::walk_filtered(root, source_globs, excluded)?.files; + let selected_sources = selected_paths.into_iter().map(|path| { + let identity = dunce::canonicalize(&path)?; + Ok::<_, io::Error>(SelectedSource { path, identity }) + }); + let selected_sources = selected_sources.process_results(|sources| sources.collect_vec())?; + let package_inputs = packages.into_iter().map(|package| { + let source_identities = package.source_identities.into_iter().map(dunce::canonicalize); + let source_identities = + source_identities.process_results(|sources| sources.collect_vec())?; + Ok::<_, io::Error>(PackageInput { source_identities, ..package }) + }); + let package_inputs = package_inputs.process_results(|packages| packages.collect_vec())?; + let plan = BuildPlan::new(selected_sources, package_inputs)?; + events.send(BuildEvent::PlanReady { package_count: plan.package_count() }); + + let prim = MaterializedPrim::new()?; + let mut compilation = CompilationState::new(prim, prim_metadata); + let planned_source_paths = + plan.packages().flat_map(|package| package.source_paths.iter()).cloned(); + let source_paths = planned_source_paths.collect::>(); + let mut sources = HashMap::new(); + for path in &source_paths { + let metadata = source_metadata(path); + sources.insert(PathBuf::clone(path), load_source(&mut compilation, path, metadata)?); + } + + let engine = compilation.query_engine(); + let execute = |package: &super::plan::PlannedPackage| { + let package_sources = package.source_paths.iter().map(|path| sources[path]); + let package_sources = package_sources.collect_vec(); + query_package(&engine, &package_sources) + }; + let execution = match execution { + PackageExecution::Serial => executor::execute_serial(&plan, events, &execute), + PackageExecution::Parallel => executor::execute_parallel(&plan, events, &execute), + }; + + let duration = started.elapsed(); + events.send(BuildEvent::Finalizing { duration }); + let sources = sources.into_values().collect_vec(); + let no_inputs = sources.is_empty(); + let (diagnostics, has_errors, failure) = match execution { + Ok(()) => match collect_diagnostics(&compilation, &sources) { + Ok((diagnostics, has_errors)) => (diagnostics, has_errors, None), + Err(error) => (vec![], true, Some(error)), + }, + Err(error) => (vec![], true, Some(error)), + }; + let report = + InitialBuildReport { sources, diagnostics, has_errors, no_inputs, failure, duration }; + Ok(InitialBuild { compilation, source_paths, report }) +} + +pub(crate) fn build(config: BuildConfig<'_>) -> Result<(), CompileError> { + let started = Instant::now(); + let BuildConfig { root, output, source_globs, packages, color, diagnostics, resilient, events } = + config; + let excluded = [PathBuf::clone(&output)]; + let initial = build_initial(InitialBuildConfig { + root: &root, + source_globs: &source_globs, + excluded: &excluded, + packages, + prim_metadata: (), + source_metadata: |_: &Path| (), + execution: PackageExecution::Parallel, + events, + })?; + let InitialBuildParts { compilation, source_paths: _, mut report } = initial.into_parts(); + if report.no_inputs { + return Err(CompileError::NoInputs); + } + if let Some(error) = report.failure.take() { + return Err(error); + } + let has_errors = report.has_errors; + if !has_errors || resilient { + let modules = collect_modules(&compilation, &report.sources)?; + write_modules(&compilation, &modules, &output, &mut BTreeSet::new())?; + } + let outcome = if has_errors { BuildOutcome::Diagnostics } else { BuildOutcome::Succeeded }; + events.send(BuildEvent::Finished { duration: started.elapsed(), outcome }); + if diagnostics { + report_diagnostics(&compilation, std::mem::take(&mut report.diagnostics), &root, color); + } + if has_errors { + return Err(CompileError::Diagnostics { reported: diagnostics }); + } + Ok(()) +} + +pub(crate) fn rebuild( + compilation: &CompilationState, + root: &Path, + output: &Path, + color: bool, + diagnostics: bool, + owned_outputs: &mut BTreeSet, +) -> Result { + let sources = compilation.source_ids().collect_vec(); + let engine = compilation.query_engine(); + query_package(&engine, &sources)?; + let (diagnostic_collections, has_errors) = collect_diagnostics(compilation, &sources)?; + if diagnostics { + report_diagnostics(compilation, diagnostic_collections, root, color); + } + let outputs = if has_errors { + BTreeSet::new() + } else { + let modules = collect_modules(compilation, &sources)?; + write_modules(compilation, &modules, output, owned_outputs)? + }; + let outcome = if has_errors { BuildOutcome::Diagnostics } else { BuildOutcome::Succeeded }; + Ok(RebuildResult { outcome, outputs }) +} + +pub(crate) fn finish_initial( + compilation: &CompilationState, + report: &mut InitialBuildReport, + root: &Path, + output: &Path, + color: bool, + diagnostics: bool, + owned_outputs: &mut BTreeSet, +) -> Result { + if report.no_inputs { + return Ok(RebuildResult { outcome: BuildOutcome::NoInputs, outputs: BTreeSet::new() }); + } + if let Some(error) = report.failure.take() { + return Err(error); + } + if diagnostics { + let diagnostics = std::mem::take(&mut report.diagnostics); + report_diagnostics(compilation, diagnostics, root, color); + } + let outputs = if report.has_errors { + BTreeSet::new() + } else { + let modules = collect_modules(compilation, &report.sources)?; + write_modules(compilation, &modules, output, owned_outputs)? + }; + let outcome = + if report.has_errors { BuildOutcome::Diagnostics } else { BuildOutcome::Succeeded }; + Ok(RebuildResult { outcome, outputs }) +} + +fn load_source( + compilation: &mut CompilationState, + path: &Path, + metadata: Metadata, +) -> Result +where + Version: Clone + Ord, + Metadata: Clone, +{ + let source_url = + Url::from_file_path(path).map_err(|_| CompileError::InvalidPath(path.to_path_buf()))?; + let foreign_path = path.with_extension("js"); + let foreign_url = Url::from_file_path(&foreign_path) + .map_err(|_| CompileError::InvalidPath(PathBuf::clone(&foreign_path)))?; + let unit = SourceUnitKey::new(source_url.as_str(), foreign_url.as_str()); + let content = fs::read_to_string(path)?; + let change = compilation.observe_source( + SourceUnitKey::clone(&unit), + DiskObservation::Found(content.into()), + metadata, + ); + let file_id = change + .changed_sources() + .next() + .expect("invariant violated: newly loaded source did not change its lifecycle"); + for kind in ForeignSourceKind::ALL { + let foreign_path = path.with_extension(kind.extension()); + let disk = match fs::read_to_string(&foreign_path) { + Ok(content) => DiskObservation::Found(content.into()), + Err(error) if error.kind() == io::ErrorKind::NotFound => DiskObservation::NotFound, + Err(error) => return Err(error.into()), + }; + compilation.observe_foreign(SourceUnitKey::clone(&unit), kind, disk); + } + Ok(file_id) +} + +fn query_package(engine: &building::QueryEngine, sources: &[FileId]) -> Result<(), CompileError> { + sources.par_iter().try_for_each(|&file_id| { + let engine = engine.snapshot(); + engine.stabilized(file_id)?; + engine.indexed(file_id)?; + engine.resolved(file_id)?; + engine.lowered(file_id)?; + engine.checked(file_id)?; + engine.foreign_validation(file_id)?; + let _ = engine.javascript(file_id)?; + Ok::<_, CompileError>(()) + }) +} + +fn collect_diagnostics( + compilation: &CompilationState, + sources: &[FileId], +) -> Result<(Vec, bool), CompileError> +where + Version: Clone + Ord, + Metadata: Clone, +{ + let engine = compilation.snapshot(); + let diagnostics = diagnostics::collect_diagnostics(&engine, sources)?; + let has_errors = diagnostics + .iter() + .flat_map(diagnostics::DiagnosticCollection::diagnostics) + .any(|diagnostic| diagnostic.severity == Severity::Error); + Ok((diagnostics, has_errors)) +} + +fn report_diagnostics( + compilation: &CompilationState, + diagnostics: Vec, + root: &Path, + color: bool, +) { + for collected in diagnostics { + if collected.diagnostics().is_empty() { + continue; + } + let source_path = compilation + .source_path(collected.file_id) + .expect("invariant violated: input source has no lifecycle path"); + let display_path = display_source_path(&source_path, root); + let line_index = line_index::LineIndex::new(&collected.content); + eprint!( + "{}", + diagnostics::format_rich_with_path( + collected.diagnostics(), + &collected.content, + &line_index, + &display_path, + color, + ) + ); + } +} + +fn display_source_path(source_path: &str, root: &Path) -> String { + if let Some(file_path) = Url::parse(source_path).ok().and_then(|url| url.to_file_path().ok()) { + file_path.strip_prefix(root).unwrap_or(&file_path).to_string_lossy().replace('\\', "/") + } else { + source_path.to_owned() + } +} + +fn collect_modules( + compilation: &CompilationState, + sources: &[FileId], +) -> Result>, CompileError> { + let mut pending = sources.to_vec(); + let mut visited = HashSet::new(); + let mut modules = vec![]; + while !pending.is_empty() { + let frontier = pending.drain(..).filter(|file_id| visited.insert(*file_id)); + let frontier = frontier.collect_vec(); + let generated = frontier.par_iter().map(|&file_id| { + let module = compilation.snapshot().javascript(file_id)?.ok(); + Ok::<_, CompileError>(module) + }); + let generated = generated.collect::, _>>()?; + for module in generated.into_iter().flatten() { + pending.extend(module.dependencies().iter().copied()); + modules.push(module); + } + } + Ok(modules) +} + +fn write_modules( + compilation: &CompilationState, + modules: &[Arc], + output: &Path, + owned_outputs: &mut BTreeSet, +) -> Result, CompileError> { + let mut outputs = BTreeSet::new(); + if modules.iter().any(|module| module.requires_runtime()) { + fs::create_dir_all(output)?; + let runtime = output.join(javascript::runtime_filename()); + write_if_changed(&runtime, javascript::runtime_source().as_bytes())?; + owned_outputs.insert(PathBuf::clone(&runtime)); + outputs.insert(runtime); + } + let module_writes = modules.par_iter().map(|module| write_module(compilation, module, output)); + let module_writes = module_writes.collect::>(); + let mut failure = None; + for write in module_writes { + owned_outputs.extend(write.outputs.iter().cloned()); + outputs.extend(write.outputs); + if let Err(error) = write.result + && failure.is_none() + { + failure = Some(error); + } + } + if let Some(error) = failure { + return Err(error); + } + Ok(outputs) +} + +fn write_module( + compilation: &CompilationState, + module: &javascript::Module, + output: &Path, +) -> ModuleWrite { + let output_path = output.join(module.filename()); + let result = fs::create_dir_all( + output_path.parent().expect("invariant violated: module filename has no parent"), + ) + .and_then(|_| write_if_changed(&output_path, module.source().as_bytes())); + if let Err(error) = result { + return ModuleWrite { outputs: vec![], result: Err(error.into()) }; + } + let mut outputs = vec![output_path]; + if let Some(kind) = module.foreign_kind() { + let output_path = output.join(javascript::foreign_module_filename(module.name(), kind)); + let foreign = compilation + .source_foreign_content(module.file_id()) + .expect("invariant violated: generated module requires missing foreign content"); + if let Err(error) = write_if_changed(&output_path, foreign.as_bytes()) { + return ModuleWrite { outputs, result: Err(error.into()) }; + } + outputs.push(output_path); + } + ModuleWrite { outputs, result: Ok(()) } +} + +fn write_if_changed(path: &Path, content: &[u8]) -> io::Result<()> { + match fs::read(path) { + Ok(previous) if previous == content => Ok(()), + Ok(_) => fs::write(path, content), + Err(error) if error.kind() == io::ErrorKind::NotFound => fs::write(path, content), + Err(error) => Err(error), + } +} diff --git a/compiler-bin/iris-build/src/events.rs b/compiler-bin/iris-build/src/events.rs new file mode 100644 index 000000000..c619624a4 --- /dev/null +++ b/compiler-bin/iris-build/src/events.rs @@ -0,0 +1,89 @@ +//! Typed notifications emitted by build execution. + +use std::time::Duration; + +use iris_progress::{ProgressEvent, ProgressOutcome, ProgressReporter}; +use smol_str::SmolStr; + +use std::sync::Mutex; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BuildOutcome { + Succeeded, + Diagnostics, + NoInputs, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum BuildEvent { + Preparing, + PlanReady { package_count: usize }, + PackageCompleted { package_name: SmolStr, duration: Duration }, + Finalizing { duration: Duration }, + Finished { duration: Duration, outcome: BuildOutcome }, +} + +pub trait BuildEventSink: Sync { + /// A `Finished` event must not return while the sink can still write progress output. + fn send(&self, event: BuildEvent); +} + +pub struct SilentBuildEvents; + +impl BuildEventSink for SilentBuildEvents { + fn send(&self, _: BuildEvent) {} +} + +pub struct ProgressEventSink { + reporter: ProgressReporter, +} + +impl ProgressEventSink { + pub fn new(reporter: ProgressReporter) -> ProgressEventSink { + ProgressEventSink { reporter } + } +} + +impl BuildEventSink for ProgressEventSink { + fn send(&self, event: BuildEvent) { + let event = match event { + BuildEvent::Preparing => ProgressEvent::Preparing, + BuildEvent::PlanReady { package_count } => ProgressEvent::PlanReady { package_count }, + BuildEvent::PackageCompleted { package_name, duration } => { + ProgressEvent::PackageCompleted { package_name, duration } + } + BuildEvent::Finalizing { duration } => ProgressEvent::Finalizing { duration }, + BuildEvent::Finished { duration, outcome } => ProgressEvent::Finished { + duration, + outcome: match outcome { + BuildOutcome::Succeeded => ProgressOutcome::Succeeded, + BuildOutcome::Diagnostics => ProgressOutcome::Diagnostics, + BuildOutcome::NoInputs => ProgressOutcome::Succeeded, + }, + }, + }; + self.reporter.report(event); + } +} + +#[derive(Default)] +pub struct RecordedBuildEvents { + events: Mutex>, +} + +impl RecordedBuildEvents { + pub fn into_events(self) -> Vec { + self.events + .into_inner() + .expect("invariant violated: recorded build events are not poisoned") + } +} + +impl BuildEventSink for RecordedBuildEvents { + fn send(&self, event: BuildEvent) { + self.events + .lock() + .expect("invariant violated: recorded build events are not poisoned") + .push(event); + } +} diff --git a/compiler-bin/iris-build/src/executor.rs b/compiler-bin/iris-build/src/executor.rs new file mode 100644 index 000000000..df9db2329 --- /dev/null +++ b/compiler-bin/iris-build/src/executor.rs @@ -0,0 +1,250 @@ +use std::sync::Mutex; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::time::Instant; + +use itertools::Itertools; +use rayon::prelude::*; +use smol_str::SmolStr; + +use super::events::{BuildEvent, BuildEventSink}; +use super::plan::{BuildPlan, PackageGroup, PackageGroupId, PlannedPackage}; + +struct ParallelExecutor<'a, E, F, S: ?Sized> { + plan: &'a BuildPlan, + remaining_dependencies: Vec, + events: &'a S, + execute: &'a F, + failed: AtomicBool, + error: Mutex>, +} + +impl<'a, E, F, S> ParallelExecutor<'a, E, F, S> +where + E: Send, + F: Fn(&PlannedPackage) -> Result<(), E> + Sync, + S: BuildEventSink + ?Sized, +{ + fn spawn<'scope>(&'scope self, scope: &rayon::Scope<'scope>, group_id: PackageGroupId) { + scope.spawn(move |scope| self.execute_group(scope, group_id)); + } + + fn execute_group<'scope>(&'scope self, scope: &rayon::Scope<'scope>, group_id: PackageGroupId) { + let group = self.plan.group(group_id); + let result = group.packages.par_iter().try_for_each(|package_id| { + execute_package(self.plan.package(*package_id), self.events, self.execute) + }); + if let Err(package_error) = result { + self.failed.store(true, Ordering::Release); + let mut error = + self.error.lock().expect("invariant violated: package build error is not poisoned"); + error.get_or_insert(package_error); + return; + } + if self.failed.load(Ordering::Acquire) { + return; + } + for dependent in &group.dependents { + let index = dependent.index(); + if self.remaining_dependencies[index].fetch_sub(1, Ordering::AcqRel) == 1 { + self.spawn(scope, *dependent); + } + } + } +} + +pub fn execute_parallel(plan: &BuildPlan, events: &S, execute: &F) -> Result<(), E> +where + E: Send, + F: Fn(&PlannedPackage) -> Result<(), E> + Sync, + S: BuildEventSink + ?Sized, +{ + let remaining_dependencies = + plan.groups().map(|group| AtomicUsize::new(group.dependencies.len())); + let remaining_dependencies = remaining_dependencies.collect_vec(); + let executor = ParallelExecutor { + plan, + remaining_dependencies, + events, + execute, + failed: AtomicBool::new(false), + error: Mutex::new(None), + }; + + rayon::scope(|scope| { + for group in executor.plan.groups() { + if group.dependencies.is_empty() { + executor.spawn(scope, group.id); + } + } + }); + + executor + .error + .into_inner() + .expect("invariant violated: package build error is not poisoned") + .map_or(Ok(()), Err) +} + +pub fn execute_serial(plan: &BuildPlan, events: &S, execute: &F) -> Result<(), E> +where + F: Fn(&PlannedPackage) -> Result<(), E>, + S: BuildEventSink + ?Sized, +{ + let mut completed_groups = vec![false; plan.groups().len()]; + while completed_groups.iter().any(|completed| !completed) { + let group = plan + .groups() + .find(|group| group_is_ready(group, &completed_groups)) + .expect("invariant violated: build plan contains an unschedulable condensed graph"); + for package_id in &group.packages { + let package = plan.package(*package_id); + execute_package(package, events, execute)?; + } + let index = group.id.index(); + completed_groups[index] = true; + } + Ok(()) +} + +fn execute_package(package: &PlannedPackage, events: &S, execute: &F) -> Result<(), E> +where + F: Fn(&PlannedPackage) -> Result<(), E> + ?Sized, + S: BuildEventSink + ?Sized, +{ + let started = Instant::now(); + execute(package)?; + events.send(BuildEvent::PackageCompleted { + package_name: SmolStr::clone(&package.name), + duration: started.elapsed(), + }); + Ok(()) +} + +fn group_is_ready(group: &PackageGroup, completed_groups: &[bool]) -> bool { + let dependencies_complete = group.dependencies.iter().all(|dependency| { + let index = dependency.index(); + completed_groups[index] + }); + let index = group.id.index(); + !completed_groups[index] && dependencies_complete +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + use std::path::PathBuf; + use std::sync::Mutex; + use std::sync::atomic::{AtomicBool, Ordering}; + + use itertools::Itertools; + + use super::*; + use crate::events::{BuildEvent, RecordedBuildEvents}; + use crate::plan::{PackageInput, SelectedSource}; + + fn plan() -> BuildPlan { + let selected_sources = ["root", "independent", "dependent"].into_iter().map(|path| { + SelectedSource { path: PathBuf::from(path), identity: PathBuf::from(path) } + }); + let selected_sources = selected_sources.collect_vec(); + let packages = vec![ + PackageInput { + name: SmolStr::new("root"), + source_identities: vec![PathBuf::from("root")], + dependencies: vec![], + }, + PackageInput { + name: SmolStr::new("independent"), + source_identities: vec![PathBuf::from("independent")], + dependencies: vec![], + }, + PackageInput { + name: SmolStr::new("dependent"), + source_identities: vec![PathBuf::from("dependent")], + dependencies: vec![SmolStr::new("root"), SmolStr::new("independent")], + }, + ]; + BuildPlan::new(selected_sources, packages).unwrap() + } + + #[test] + fn parallel_execution_runs_each_package_once_after_its_dependencies() { + let plan = plan(); + let events = RecordedBuildEvents::default(); + let root_completed = AtomicBool::new(false); + let independent_completed = AtomicBool::new(false); + let executed = Mutex::new(vec![]); + + execute_parallel(&plan, &events, &|package| { + if package.name == "dependent" { + assert!(root_completed.load(Ordering::Acquire)); + assert!(independent_completed.load(Ordering::Acquire)); + } + if package.name == "root" { + root_completed.store(true, Ordering::Release); + } + if package.name == "independent" { + independent_completed.store(true, Ordering::Release); + } + executed.lock().unwrap().push(SmolStr::clone(&package.name)); + Ok::<_, ()>(()) + }) + .unwrap(); + + let executed = executed.into_inner().unwrap(); + assert_eq!(executed.len(), 3); + let executed = executed.into_iter().collect::>(); + assert_eq!( + executed, + HashSet::from([ + SmolStr::new("root"), + SmolStr::new("independent"), + SmolStr::new("dependent"), + ]) + ); + let completions = events.into_events().into_iter().filter_map(|event| match event { + BuildEvent::PackageCompleted { package_name, .. } => Some(package_name), + _ => None, + }); + let completions = completions.collect_vec(); + assert_eq!(completions.len(), 3); + assert_eq!(completions.into_iter().collect::>(), executed); + } + + #[test] + fn serial_execution_obeys_the_same_plan_and_emits_the_same_packages() { + let plan = plan(); + let recorded_events = RecordedBuildEvents::default(); + let events: &dyn BuildEventSink = &recorded_events; + let executed = Mutex::new(vec![]); + + execute_serial(&plan, events, &|package| { + executed.lock().unwrap().push(SmolStr::clone(&package.name)); + Ok::<_, ()>(()) + }) + .unwrap(); + + assert_eq!(executed.into_inner().unwrap(), ["root", "independent", "dependent"]); + assert_eq!(recorded_events.into_events().len(), 3); + } + + #[test] + fn failed_dependencies_do_not_release_dependents() { + let plan = plan(); + let events = RecordedBuildEvents::default(); + let dependent_executed = AtomicBool::new(false); + + let result = execute_parallel(&plan, &events, &|package| { + if package.name == "root" { + return Err("root failed"); + } + if package.name == "dependent" { + dependent_executed.store(true, Ordering::Release); + } + Ok(()) + }); + + assert_eq!(result, Err("root failed")); + assert!(!dependent_executed.load(Ordering::Acquire)); + } +} diff --git a/compiler-bin/iris-build/src/lib.rs b/compiler-bin/iris-build/src/lib.rs new file mode 100644 index 000000000..40228895d --- /dev/null +++ b/compiler-bin/iris-build/src/lib.rs @@ -0,0 +1,18 @@ +pub mod events; +pub mod executor; +pub mod plan; + +pub mod compilation; +pub mod compile; +mod project; +mod session; +mod walk; +mod workspace; + +pub use project::{ + BuildConfig, BuildError, ExecutionError, InitializedProject, PreparedProject, ProjectConfig, + ProjectError, RunConfig, TestConfig, build, initialize_project, prepare_project, run, test, +}; +pub use session::{ + BuildSession, BuildSessionConfig, InputChange, InputChanges, RebuildOutcome, SessionError, +}; diff --git a/compiler-bin/iris-build/src/plan.rs b/compiler-bin/iris-build/src/plan.rs new file mode 100644 index 000000000..63a6ecede --- /dev/null +++ b/compiler-bin/iris-build/src/plan.rs @@ -0,0 +1,344 @@ +//! Pure package dependency planning over normalized source identities. + +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use std::slice; + +use itertools::Itertools; +use petgraph::algo::tarjan_scc; +use petgraph::prelude::DiGraphMap; +use smol_str::SmolStr; +use thiserror::Error; + +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct PackageId(usize); + +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct PackageGroupId(usize); + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SelectedSource { + pub path: PathBuf, + pub identity: PathBuf, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PackageInput { + pub name: SmolStr, + pub source_identities: Vec, + pub dependencies: Vec, +} + +#[derive(Debug)] +pub struct PlannedPackage { + pub name: SmolStr, + pub source_paths: Vec, +} + +#[derive(Debug)] +pub struct PackageGroup { + pub id: PackageGroupId, + pub packages: Vec, + pub dependencies: Vec, + pub dependents: Vec, +} + +#[derive(Debug)] +pub struct BuildPlan { + packages: Vec, + groups: Vec, +} + +#[derive(Debug, Error, Eq, PartialEq)] +pub enum BuildPlanError { + #[error("Spago package '{0}' appears more than once in the build plan")] + DuplicatePackage(SmolStr), + #[error("source {path} belongs to both Spago packages {first_package} and {second_package}")] + ConflictingPackageSource { path: PathBuf, first_package: SmolStr, second_package: SmolStr }, + #[error("Spago selected source {0}, but the lockfile does not assign it to a package")] + UnownedPackageSource(PathBuf), +} + +impl BuildPlan { + pub fn new( + selected_sources: Vec, + package_inputs: Vec, + ) -> Result { + let mut owners: HashMap = HashMap::new(); + let mut package_names = HashSet::new(); + for package in &package_inputs { + if !package_names.insert(SmolStr::clone(&package.name)) { + return Err(BuildPlanError::DuplicatePackage(SmolStr::clone(&package.name))); + } + for source_identity in &package.source_identities { + if let Some(first_package) = owners.get(source_identity) { + if first_package != &package.name { + return Err(BuildPlanError::ConflictingPackageSource { + path: PathBuf::clone(source_identity), + first_package: SmolStr::clone(first_package), + second_package: SmolStr::clone(&package.name), + }); + } + } else { + owners.insert(PathBuf::clone(source_identity), SmolStr::clone(&package.name)); + } + } + } + + let mut selected_by_package: HashMap> = HashMap::new(); + for source in selected_sources { + let Some(owner) = owners.get(&source.identity) else { + return Err(BuildPlanError::UnownedPackageSource(source.path)); + }; + selected_by_package.entry(SmolStr::clone(owner)).or_default().push(source.path); + } + + let retained = selected_by_package.keys().cloned().collect::>(); + let packages_by_name = + package_inputs.iter().map(|package| (package.name.as_str(), package)); + let packages_by_name = packages_by_name.collect::>(); + let contracted_dependencies = package_inputs + .iter() + .filter(|package| retained.contains(&package.name)) + .map(|package| { + let dependencies = contract_dependencies(package, &retained, &packages_by_name); + (SmolStr::clone(&package.name), dependencies) + }); + let contracted_dependencies = contracted_dependencies.collect::>(); + + let retained_inputs = + package_inputs.into_iter().filter(|package| retained.contains(&package.name)); + let retained_inputs = retained_inputs.collect_vec(); + let package_ids = retained_inputs + .iter() + .enumerate() + .map(|(index, package)| (package.name.as_str(), PackageId(index))); + let package_ids = package_ids.collect::>(); + let package_dependencies = retained_inputs.iter().map(|package| { + let dependencies = contracted_dependencies[&package.name] + .iter() + .filter_map(|dependency| package_ids.get(dependency.as_str()).copied()); + dependencies.collect_vec() + }); + let package_dependencies = package_dependencies.collect_vec(); + let packages = retained_inputs.into_iter().map(|package| { + let source_paths = selected_by_package + .remove(&package.name) + .expect("invariant violated: retained package has no selected sources"); + PlannedPackage { source_paths, name: package.name } + }); + let packages = packages.collect_vec(); + let groups = package_groups(&package_dependencies); + + Ok(BuildPlan { packages, groups }) + } + + pub fn packages(&self) -> slice::Iter<'_, PlannedPackage> { + self.packages.iter() + } + + pub fn package_count(&self) -> usize { + self.packages.len() + } + + pub fn is_empty(&self) -> bool { + self.packages.is_empty() + } + + pub fn package(&self, id: PackageId) -> &PlannedPackage { + &self.packages[id.0] + } + + pub fn groups(&self) -> slice::Iter<'_, PackageGroup> { + self.groups.iter() + } + + pub fn group(&self, id: PackageGroupId) -> &PackageGroup { + &self.groups[id.0] + } +} + +fn contract_dependencies( + package: &PackageInput, + retained: &HashSet, + packages_by_name: &HashMap<&str, &PackageInput>, +) -> Vec { + let mut dependencies = vec![]; + let mut visited = HashSet::new(); + let pending = package.dependencies.iter().rev().map(SmolStr::as_str); + let mut pending = pending.collect_vec(); + while let Some(dependency) = pending.pop() { + if !visited.insert(dependency) { + continue; + } + if retained.contains(dependency) { + dependencies.push(SmolStr::new(dependency)); + } else if let Some(package) = packages_by_name.get(dependency) { + pending.extend(package.dependencies.iter().rev().map(SmolStr::as_str)); + } + } + dependencies +} + +impl PackageGroupId { + pub fn index(self) -> usize { + self.0 + } +} + +fn package_groups(package_dependencies: &[Vec]) -> Vec { + let mut graph = DiGraphMap::::default(); + for index in 0..package_dependencies.len() { + graph.add_node(PackageId(index)); + } + for (index, dependencies) in package_dependencies.iter().enumerate() { + for dependency in dependencies { + graph.add_edge(PackageId(index), *dependency, ()); + } + } + + let mut grouped_packages = tarjan_scc(&graph); + for packages in &mut grouped_packages { + packages.sort_unstable(); + } + grouped_packages.sort_unstable_by_key(|packages| packages[0]); + + let mut package_groups = HashMap::new(); + for (group_index, packages) in grouped_packages.iter().enumerate() { + for package in packages { + package_groups.insert(*package, PackageGroupId(group_index)); + } + } + + let dependencies = grouped_packages.iter().enumerate().map(|(group_index, packages)| { + let group_id = PackageGroupId(group_index); + let dependencies = packages + .iter() + .flat_map(|package| &package_dependencies[package.0]) + .map(|dependency| package_groups[dependency]) + .filter(|dependency| *dependency != group_id) + .sorted_unstable() + .dedup(); + dependencies.collect_vec() + }); + let dependencies = dependencies.collect_vec(); + let mut dependents = vec![vec![]; grouped_packages.len()]; + for (group_index, group_dependencies) in dependencies.iter().enumerate() { + for dependency in group_dependencies { + dependents[dependency.0].push(PackageGroupId(group_index)); + } + } + + let groups = grouped_packages.into_iter().zip(dependencies).zip(dependents).enumerate().map( + |(index, ((packages, dependencies), dependents))| PackageGroup { + id: PackageGroupId(index), + packages, + dependencies, + dependents, + }, + ); + groups.collect_vec() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn source(path: &str) -> SelectedSource { + SelectedSource { path: PathBuf::from(path), identity: PathBuf::from(path) } + } + + fn package(name: &str, sources: &[&str], dependencies: &[&str]) -> PackageInput { + let source_identities = sources.iter().map(PathBuf::from); + let source_identities = source_identities.collect_vec(); + let dependencies = dependencies.iter().map(|dependency| SmolStr::new(dependency)); + PackageInput { + name: SmolStr::new(name), + source_identities, + dependencies: dependencies.collect_vec(), + } + } + + #[test] + fn source_ownership_must_be_unique() { + let result = BuildPlan::new( + vec![source("Shared.purs")], + vec![package("first", &["Shared.purs"], &[]), package("second", &["Shared.purs"], &[])], + ); + + assert!(matches!( + result, + Err(BuildPlanError::ConflictingPackageSource { first_package, second_package, .. }) + if first_package == "first" && second_package == "second" + )); + } + + #[test] + fn package_names_must_be_unique() { + let result = BuildPlan::new( + vec![source("First.purs")], + vec![package("duplicate", &["First.purs"], &[]), package("duplicate", &[], &[])], + ); + + assert_eq!( + result.unwrap_err(), + BuildPlanError::DuplicatePackage(SmolStr::new("duplicate")) + ); + } + + #[test] + fn source_empty_packages_preserve_dependency_reachability() { + let plan = BuildPlan::new( + vec![source("Library.purs"), source("Application.purs")], + vec![ + package("library", &["Library.purs"], &[]), + package("metadata-only", &[], &["library"]), + package("application", &["Application.purs"], &["metadata-only"]), + ], + ) + .unwrap(); + + assert_eq!(plan.groups.len(), 2); + assert!(plan.groups[0].dependencies.is_empty()); + assert_eq!(plan.groups[1].dependencies, [PackageGroupId(0)]); + } + + #[test] + fn groups_preserve_dependency_edges_and_independent_roots() { + let plan = BuildPlan::new( + vec![source("a"), source("b"), source("middle"), source("leaf")], + vec![ + package("root-a", &["a"], &[]), + package("root-b", &["b"], &[]), + package("middle", &["middle"], &["root-a"]), + package("leaf", &["leaf"], &["root-b", "middle"]), + ], + ) + .unwrap(); + + assert_eq!(plan.groups.len(), 4); + assert!(plan.groups[0].dependencies.is_empty()); + assert!(plan.groups[1].dependencies.is_empty()); + assert_eq!(plan.groups[2].dependencies, [PackageGroupId(0)]); + assert_eq!(plan.groups[3].dependencies, [PackageGroupId(1), PackageGroupId(2)]); + } + + #[test] + fn cycles_are_one_scheduling_group() { + let plan = BuildPlan::new( + vec![source("root"), source("cycle-a"), source("cycle-b"), source("downstream")], + vec![ + package("root", &["root"], &[]), + package("cycle-a", &["cycle-a"], &["root", "cycle-b"]), + package("cycle-b", &["cycle-b"], &["cycle-a"]), + package("downstream", &["downstream"], &["cycle-b"]), + ], + ) + .unwrap(); + + assert_eq!(plan.groups.len(), 3); + assert_eq!(plan.groups[1].packages, [PackageId(1), PackageId(2)]); + assert_eq!(plan.groups[1].dependencies, [PackageGroupId(0)]); + assert_eq!(plan.groups[2].dependencies, [PackageGroupId(1)]); + } +} diff --git a/compiler-bin/iris-build/src/project.rs b/compiler-bin/iris-build/src/project.rs new file mode 100644 index 000000000..0bf459364 --- /dev/null +++ b/compiler-bin/iris-build/src/project.rs @@ -0,0 +1,347 @@ +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitStatus}; +use std::{env, io}; + +use iris_progress::ProgressRuntime; +use itertools::Itertools; +use path_absolutize::Absolutize; +use thiserror::Error; +use url::Url; + +use super::compile::{self, CompileError}; +use super::events::{BuildEvent, BuildEventSink, ProgressEventSink, SilentBuildEvents}; +use super::plan::PackageInput; +use super::workspace::{Workspace, WorkspaceError}; + +const NODE_RUNNER: &str = include_str!("../bundled/runner.mjs"); + +pub struct BuildConfig { + pub package: Option, + pub output: Option, + pub quiet: bool, + pub color: bool, + pub resilient: bool, + pub diagnostics: bool, +} + +pub struct ProjectConfig { + pub package: Option, + pub output: Option, + pub quiet: bool, +} + +pub struct RunConfig { + pub project: ProjectConfig, + pub color: bool, + pub main: Option, + pub arguments: Vec, +} + +pub struct TestConfig { + pub project: ProjectConfig, + pub color: bool, + pub main: Option, + pub arguments: Vec, +} + +#[derive(Debug, Error)] +enum ProjectFailure { + #[error(transparent)] + Compile(#[from] CompileError), + #[error(transparent)] + Spago(#[from] spago::SpagoError), + #[error(transparent)] + SpagoLock(#[from] spago::LockfileGlobSetError), + #[error(transparent)] + Workspace(#[from] WorkspaceError), + #[error("failed to determine the current directory: {0}")] + CurrentDirectory(io::Error), + #[error("failed to normalize output directory {}: {error}", path.display())] + NormalizeOutput { path: PathBuf, error: io::Error }, + #[error("failed to execute Node.js: {source}")] + Node { source: io::Error }, + #[error("Node.js exited without a status code")] + MissingStatus, + #[error("Node.js exited with status {status}")] + NodeFailed { status: ExitStatus }, + #[error("module output does not exist: {path}")] + MissingModule { path: PathBuf }, + #[error("failed to canonicalize module output {path}: {source}")] + CanonicalizeModule { + path: PathBuf, + #[source] + source: io::Error, + }, + #[error("failed to convert module output to a file URL: {path}")] + ModuleUrl { path: PathBuf }, + #[error("no selected packages contain tests")] + NoTests, +} + +#[derive(Debug, Error)] +#[error(transparent)] +pub struct BuildError(ProjectFailure); + +impl BuildError { + pub fn diagnostics_were_suppressed(&self) -> bool { + matches!(self.0, ProjectFailure::Compile(CompileError::Diagnostics { reported: false })) + } +} + +#[derive(Debug, Error)] +#[error(transparent)] +pub struct ProjectError(ProjectFailure); + +#[derive(Debug, Error)] +#[error(transparent)] +pub struct ExecutionError(ProjectFailure); + +impl ExecutionError { + pub fn exit_code(&self) -> i32 { + match &self.0 { + ProjectFailure::NodeFailed { status } => status.code().unwrap_or(1), + _ => 1, + } + } +} + +pub struct PreparedProject { + pub(crate) root: PathBuf, + pub(crate) output: PathBuf, + pub(crate) source_globs: Vec, +} + +pub struct InitializedProject { + pub(crate) project: PreparedProject, + pub(crate) build: compile::InitialBuild<(), ()>, +} + +impl PreparedProject { + pub fn root_directory(&self) -> &std::path::Path { + &self.root + } + + pub fn source_roots(&self) -> Result, ProjectError> { + let walked = super::walk::walk_filtered(&self.root, &self.source_globs, [&self.output]) + .map_err(CompileError::from) + .map_err(ProjectFailure::from) + .map_err(ProjectError)?; + Ok(walked.roots.into_iter().collect_vec()) + } +} + +pub fn build(config: BuildConfig) -> Result<(), BuildError> { + build_project(config).map_err(BuildError) +} + +pub fn prepare_project(config: ProjectConfig) -> Result { + prepare_project_inner(config).map_err(ProjectError) +} + +pub fn initialize_project(project: PreparedProject) -> Result { + initialize_project_inner(project).map_err(ProjectError) +} + +pub fn run(config: RunConfig) -> Result<(), ExecutionError> { + run_project(config).map_err(ExecutionError) +} + +pub fn test(config: TestConfig) -> Result<(), ExecutionError> { + test_project(config).map_err(ExecutionError) +} + +fn build_project(config: BuildConfig) -> Result<(), ProjectFailure> { + let project = prepare_project_inner(ProjectConfig { + package: Option::clone(&config.package), + output: Option::clone(&config.output), + quiet: config.quiet, + })?; + compile_project(project, &config) +} + +fn compile_project(project: PreparedProject, config: &BuildConfig) -> Result<(), ProjectFailure> { + let progress = ProgressRuntime::start(!config.quiet, config.color); + let events = ProgressEventSink::new(progress.reporter()); + events.send(BuildEvent::Preparing); + let packages = package_inputs(&project.root)?; + compile::build(compile::BuildConfig { + root: project.root, + output: project.output, + source_globs: project.source_globs, + packages, + color: config.color, + diagnostics: config.diagnostics, + resilient: config.resilient, + events: &events, + })?; + Ok(()) +} + +fn initialize_project_inner( + project: PreparedProject, +) -> Result { + let PreparedProject { root, output, source_globs } = &project; + let packages = package_inputs(root)?; + let excluded = [PathBuf::clone(output)]; + let build = compile::build_initial(compile::InitialBuildConfig { + root, + source_globs, + excluded: &excluded, + packages, + prim_metadata: (), + source_metadata: |_: &Path| (), + execution: compile::PackageExecution::Parallel, + events: &SilentBuildEvents, + })?; + Ok(InitializedProject { project, build }) +} + +fn package_inputs(root: &Path) -> Result, ProjectFailure> { + let package_sources = spago::source_files_by_package(root)?; + let packages = package_sources.into_iter().map(|(name, package)| PackageInput { + name, + source_identities: package.sources, + dependencies: package.dependencies.into_iter().collect_vec(), + }); + Ok(packages.collect_vec()) +} + +fn run_project(config: RunConfig) -> Result<(), ProjectFailure> { + let current_directory = env::current_dir().map_err(ProjectFailure::CurrentDirectory)?; + let workspace = Workspace::discover(¤t_directory, config.project.package.as_deref())?; + let package = workspace.require_selected()?; + let execution = Option::clone(&package.manifest.run).unwrap_or_default(); + let main = config.main.or(execution.main).unwrap_or_else(|| "Main".to_owned()); + let arguments = + if config.arguments.is_empty() { execution.exec_args } else { config.arguments }; + let output = project_output(&workspace.root, config.project.output.as_deref())?; + + let project = prepare_workspace( + &workspace, + ¤t_directory, + config.project.quiet, + PathBuf::clone(&output), + )?; + compile_project(project, &execution_build_config(config.project, config.color))?; + execute_module(&workspace.root, &output, &main, &arguments) +} + +fn test_project(config: TestConfig) -> Result<(), ProjectFailure> { + let current_directory = env::current_dir().map_err(ProjectFailure::CurrentDirectory)?; + let workspace = Workspace::discover(¤t_directory, config.project.package.as_deref())?; + if workspace.selected.is_some() { + if !workspace.require_selected()?.has_tests { + return Err(ProjectFailure::NoTests); + } + } else if !workspace.packages.values().any(|package| package.has_tests) { + return Err(ProjectFailure::NoTests); + } + + let output = project_output(&workspace.root, config.project.output.as_deref())?; + let project = prepare_workspace( + &workspace, + ¤t_directory, + config.project.quiet, + PathBuf::clone(&output), + )?; + compile_project(project, &execution_build_config(config.project, config.color))?; + + let packages = workspace.packages.values().filter(|package| { + workspace.selected.as_ref().is_some_and(|selected| selected == &package.manifest.name) + || (workspace.selected.is_none() && package.has_tests) + }); + for package in packages { + let execution = Option::clone(&package.manifest.test).unwrap_or_default(); + let main = Option::clone(&config.main) + .or(execution.main) + .unwrap_or_else(|| "Test.Main".to_owned()); + let arguments = if config.arguments.is_empty() { + execution.exec_args + } else { + Vec::clone(&config.arguments) + }; + execute_module(&workspace.root, &output, &main, &arguments)?; + } + Ok(()) +} + +fn execution_build_config(project: ProjectConfig, color: bool) -> BuildConfig { + BuildConfig { + package: project.package, + output: project.output, + quiet: project.quiet, + color, + resilient: false, + diagnostics: true, + } +} + +fn prepare_project_inner(config: ProjectConfig) -> Result { + let current_directory = env::current_dir().map_err(ProjectFailure::CurrentDirectory)?; + let workspace = Workspace::discover(¤t_directory, config.package.as_deref())?; + let output = project_output(&workspace.root, config.output.as_deref())?; + prepare_workspace(&workspace, ¤t_directory, config.quiet, output) +} + +fn prepare_workspace( + workspace: &Workspace, + current_directory: &Path, + quiet: bool, + output: PathBuf, +) -> Result { + let spago = spago::SpagoCommand::new(current_directory)?; + spago.fetch(workspace.selected.as_deref(), !quiet)?; + let source_globs = spago.source_globs(workspace.selected.as_deref(), !quiet)?; + Ok(PreparedProject { root: PathBuf::clone(&workspace.root), output, source_globs }) +} + +fn project_output(root: &Path, configured: Option<&Path>) -> Result { + let output = if let Some(output) = configured { + output + .absolutize() + .map_err(|error| ProjectFailure::NormalizeOutput { path: output.to_path_buf(), error })? + .into_owned() + } else { + root.join("output") + }; + Ok(output) +} + +fn execute_module( + workspace_root: &Path, + output: &Path, + module: &str, + arguments: &[String], +) -> Result<(), ProjectFailure> { + let module = output.join(module).join("index.js"); + if !module.is_file() { + return Err(ProjectFailure::MissingModule { path: module }); + } + let canonicalization_failure = + |source| ProjectFailure::CanonicalizeModule { path: PathBuf::clone(&module), source }; + let module = dunce::canonicalize(&module).map_err(canonicalization_failure)?; + let module_url = Url::from_file_path(&module) + .map_err(|_| ProjectFailure::ModuleUrl { path: PathBuf::clone(&module) })? + .to_string(); + let status = Command::new("node") + .arg("--input-type=module") + .arg("--eval") + .arg(NODE_RUNNER) + .arg(module_url) + .arg("--") + .args(arguments) + .current_dir(workspace_root) + .status() + .map_err(|source| ProjectFailure::Node { source })?; + ensure_node_success(status) +} + +fn ensure_node_success(status: ExitStatus) -> Result<(), ProjectFailure> { + if status.success() { + return Ok(()); + } + if status.code().is_none() { + return Err(ProjectFailure::MissingStatus); + } + Err(ProjectFailure::NodeFailed { status }) +} diff --git a/compiler-bin/iris-build/src/session.rs b/compiler-bin/iris-build/src/session.rs new file mode 100644 index 000000000..d6c5cdd24 --- /dev/null +++ b/compiler-bin/iris-build/src/session.rs @@ -0,0 +1,354 @@ +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use std::{fs, io}; + +use building::{DiskObservation, LifecycleChange, QueryError, ReloadFailure, SourceUnitKey}; +use files::ForeignSourceKind; +use itertools::Itertools; +use thiserror::Error; +use url::Url; + +use super::compilation::CompilationState; +use super::compile::{self, CompileError, InitialBuildReport}; +use super::events::BuildOutcome; +use super::project::InitializedProject; +use super::walk; + +pub struct BuildSessionConfig { + pub color: bool, + pub diagnostics: bool, +} + +#[derive(Debug, Eq, PartialEq)] +pub struct InputChange { + pub source_path: PathBuf, + pub module_name: Option, +} + +#[derive(Debug, Default, Eq, PartialEq)] +pub struct InputChanges { + pub inputs: Vec, + pub warnings: Vec, +} + +impl InputChanges { + pub fn is_empty(&self) -> bool { + self.inputs.is_empty() + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RebuildOutcome { + Succeeded, + Diagnostics, + NoInputs, +} + +#[derive(Debug, Error)] +enum SessionFailure { + #[error(transparent)] + Compile(#[from] CompileError), + #[error("failed to convert path to a file URL: {0}")] + InvalidPath(PathBuf), + #[error(transparent)] + Io(#[from] io::Error), + #[error(transparent)] + Query(#[from] QueryError), + #[error(transparent)] + Walk(#[from] walk::Error), +} + +#[derive(Debug, Error)] +#[error(transparent)] +pub struct SessionError(SessionFailure); + +pub struct BuildSession { + root: PathBuf, + output: PathBuf, + inputs: Vec, + source_roots: Vec, + source_globs: globset::GlobSet, + source_paths: BTreeSet, + generated_outputs: BTreeSet, + compilation: CompilationState, + initial_report: Option, + initial_inputs: Vec, + color: bool, + diagnostics: bool, +} + +impl BuildSession { + pub fn new( + project: InitializedProject, + config: BuildSessionConfig, + ) -> Result { + BuildSession::create(project, config).map_err(SessionError) + } + + fn create( + initialized: InitializedProject, + config: BuildSessionConfig, + ) -> Result { + let project = initialized.project; + let walked = walk::walk_filtered(&project.root, &project.source_globs, [&project.output])?; + let source_roots = walked.roots.into_iter().collect_vec(); + let initial = initialized.build.into_parts(); + let initial_inputs = initial.source_paths.iter().map(|path| { + let source_path = PathBuf::clone(path); + let unit = source_unit(path)?; + let module_name = initial.compilation.module_name(unit.source())?; + Ok::<_, SessionFailure>(InputChange { source_path, module_name }) + }); + let initial_inputs = initial_inputs.process_results(|inputs| inputs.collect_vec())?; + Ok(BuildSession { + root: project.root, + output: project.output, + inputs: project.source_globs, + source_roots, + source_globs: walked.globs, + source_paths: initial.source_paths, + generated_outputs: BTreeSet::new(), + compilation: initial.compilation, + initial_report: Some(initial.report), + initial_inputs, + color: config.color, + diagnostics: config.diagnostics, + }) + } + + pub fn source_roots(&self) -> &[PathBuf] { + &self.source_roots + } + + pub fn root_directory(&self) -> &Path { + &self.root + } + + pub fn take_initial_inputs(&mut self) -> Vec { + std::mem::take(&mut self.initial_inputs) + } + + pub fn synchronize_paths(&mut self, paths: &[PathBuf]) -> Result { + let change = self.synchronize(paths).map_err(SessionError)?; + if !change.inputs.is_empty() { + self.initial_report = None; + } + Ok(change.into_public()) + } + + pub fn rescan(&mut self) -> Result { + let change = self.rescan_inputs().map_err(SessionError)?; + if !change.inputs.is_empty() { + self.initial_report = None; + } + Ok(change.into_public()) + } + + pub fn rebuild(&mut self) -> Result { + self.rebuild_inputs().map_err(SessionError) + } + + fn synchronize(&mut self, paths: &[PathBuf]) -> Result { + let mut source_paths = BTreeSet::new(); + let mut foreign_paths = BTreeSet::new(); + for path in paths { + if path.starts_with(&self.output) { + continue; + } + match path.extension().and_then(|extension| extension.to_str()) { + Some("purs") => { + if self.source_paths.contains(path) + || !self.source_globs.matches(path).is_empty() + { + source_paths.insert(PathBuf::clone(path)); + } + } + Some("js" | "jsx") => { + let source_path = path.with_extension("purs"); + if self.source_paths.contains(&source_path) { + foreign_paths.insert(source_path); + } + } + _ => {} + } + } + + let mut change = SessionChange::default(); + for path in source_paths { + change.combine(observe_source_unit(&mut self.compilation, &path)?); + if path.exists() { + self.source_paths.insert(path); + } else { + self.source_paths.remove(&path); + } + } + for source_path in foreign_paths { + change.combine(observe_foreign(&mut self.compilation, &source_path)?); + } + Ok(change) + } + + fn rescan_inputs(&mut self) -> Result { + let walked = walk::walk_filtered(&self.root, &self.inputs, [&self.output])?; + let current_paths = walked.files.into_iter().collect::>(); + let affected_paths = self.source_paths.union(¤t_paths).cloned().collect_vec(); + + let mut change = SessionChange::default(); + for path in affected_paths { + change.combine(observe_source_unit(&mut self.compilation, &path)?); + } + self.source_globs = walked.globs; + self.source_paths = current_paths; + Ok(change) + } + + fn rebuild_inputs(&mut self) -> Result { + if let Some(report) = &mut self.initial_report { + let result = compile::finish_initial( + &self.compilation, + report, + &self.root, + &self.output, + self.color, + self.diagnostics, + &mut self.generated_outputs, + )?; + self.initial_report = None; + return self.finish_rebuild(result); + } + if self.compilation.source_ids().next().is_none() { + self.reconcile_outputs(BTreeSet::new())?; + return Ok(RebuildOutcome::NoInputs); + } + + let result = compile::rebuild( + &self.compilation, + &self.root, + &self.output, + self.color, + self.diagnostics, + &mut self.generated_outputs, + )?; + self.finish_rebuild(result) + } + + fn finish_rebuild( + &mut self, + result: compile::RebuildResult, + ) -> Result { + match result.outcome { + BuildOutcome::Succeeded => { + self.reconcile_outputs(result.outputs)?; + Ok(RebuildOutcome::Succeeded) + } + BuildOutcome::Diagnostics => Ok(RebuildOutcome::Diagnostics), + BuildOutcome::NoInputs => Ok(RebuildOutcome::NoInputs), + } + } + + fn reconcile_outputs(&mut self, current: BTreeSet) -> io::Result<()> { + let stale_outputs = self.generated_outputs.difference(¤t).cloned().collect_vec(); + let mut retained_outputs = current; + let mut failure = None; + for stale in stale_outputs { + match fs::remove_file(&stale) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => { + retained_outputs.insert(stale); + if failure.is_none() { + failure = Some(error); + } + } + } + } + self.generated_outputs = retained_outputs; + failure.map_or(Ok(()), Err) + } +} + +#[derive(Default)] +struct SessionChange { + lifecycle: LifecycleChange, + inputs: Vec, +} + +impl SessionChange { + fn combine(&mut self, other: SessionChange) { + self.lifecycle.combine(other.lifecycle); + self.inputs.extend(other.inputs); + } + + fn into_public(self) -> InputChanges { + let warnings = self.lifecycle.warnings().iter().map(|warning| warning.to_string()); + let warnings = warnings.collect_vec(); + InputChanges { inputs: self.inputs, warnings } + } +} + +fn observe_source_unit( + compilation: &mut CompilationState, + source_path: &Path, +) -> Result { + let unit = source_unit(source_path)?; + let previous_source = compilation.source_content(unit.source())?; + let previous_foreign = + ForeignSourceKind::ALL.map(|kind| compilation.foreign_content(unit.foreign_for(kind))); + let previous_name = compilation.module_name(unit.source())?; + + let source = observe_disk(source_path); + let mut lifecycle = compilation.observe_source(SourceUnitKey::clone(&unit), source, ()); + for kind in ForeignSourceKind::ALL { + let foreign = observe_disk(&source_path.with_extension(kind.extension())); + lifecycle.combine(compilation.observe_foreign(SourceUnitKey::clone(&unit), kind, foreign)); + } + + let current_source = compilation.source_content(unit.source())?; + let current_foreign = + ForeignSourceKind::ALL.map(|kind| compilation.foreign_content(unit.foreign_for(kind))); + let mut inputs = vec![]; + if previous_source != current_source || previous_foreign != current_foreign { + let module_name = compilation.module_name(unit.source())?.or(previous_name); + inputs.push(InputChange { source_path: source_path.to_path_buf(), module_name }); + } + Ok(SessionChange { lifecycle, inputs }) +} + +fn observe_foreign( + compilation: &mut CompilationState, + source_path: &Path, +) -> Result { + let unit = source_unit(source_path)?; + let previous_foreign = + ForeignSourceKind::ALL.map(|kind| compilation.foreign_content(unit.foreign_for(kind))); + let mut lifecycle = LifecycleChange::default(); + for kind in ForeignSourceKind::ALL { + let foreign = observe_disk(&source_path.with_extension(kind.extension())); + lifecycle.combine(compilation.observe_foreign(SourceUnitKey::clone(&unit), kind, foreign)); + } + let current_foreign = + ForeignSourceKind::ALL.map(|kind| compilation.foreign_content(unit.foreign_for(kind))); + let mut inputs = vec![]; + if previous_foreign != current_foreign { + let module_name = compilation.module_name(unit.source())?; + inputs.push(InputChange { source_path: source_path.to_path_buf(), module_name }); + } + Ok(SessionChange { lifecycle, inputs }) +} + +fn source_unit(source_path: &Path) -> Result { + let source_url = Url::from_file_path(source_path) + .map_err(|_| SessionFailure::InvalidPath(source_path.to_path_buf()))?; + let foreign_path = source_path.with_extension("js"); + let foreign_url = Url::from_file_path(&foreign_path) + .map_err(|_| SessionFailure::InvalidPath(PathBuf::clone(&foreign_path)))?; + Ok(SourceUnitKey::new(source_url.as_str(), foreign_url.as_str())) +} + +fn observe_disk(path: &Path) -> DiskObservation { + match fs::read_to_string(path) { + Ok(content) => DiskObservation::Found(content.into()), + Err(error) if error.kind() == io::ErrorKind::NotFound => DiskObservation::NotFound, + Err(error) => DiskObservation::Failed(ReloadFailure::new(error.kind(), error.to_string())), + } +} diff --git a/compiler-bin/iris-build/src/walk.rs b/compiler-bin/iris-build/src/walk.rs new file mode 100644 index 000000000..b201f3eb7 --- /dev/null +++ b/compiler-bin/iris-build/src/walk.rs @@ -0,0 +1,231 @@ +//! Source glob expansion for build preparation. + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + +use globset::{Glob, GlobSet, GlobSetBuilder}; +use path_absolutize::Absolutize; +use thiserror::Error; +use walkdir::WalkDir; + +pub struct Walk { + pub roots: BTreeSet, + pub globs: GlobSet, + pub files: Vec, +} + +#[derive(Debug, Error)] +pub enum Error { + #[error(transparent)] + GlobSetError(#[from] globset::Error), + #[error(transparent)] + WalkDirError(#[from] walkdir::Error), +} + +pub fn walk_filtered( + root: &Path, + includes: impl IntoIterator>, + excludes: impl IntoIterator>, +) -> Result { + let mut files = vec![]; + + let mut roots = BTreeSet::default(); + let mut globs = GlobSetBuilder::new(); + + for path in includes { + let path = dunce::simplified(root).join(path); + if let Ok(path) = path.absolutize() + && let Some(path) = path.to_str() + && let Ok(glob) = Glob::new(path) + { + roots.insert(glob_literal_base(path)); + globs.add(glob); + } else { + files.push(path); + } + } + + let globs = globs.build()?; + let excludes = build_excludes(root, excludes)?; + files.retain(|path| !excludes.is_match(path)); + let mut files_from_glob = BTreeSet::default(); + + for root in &roots { + if !root.exists() { + continue; + } + + let entries = + WalkDir::new(root).into_iter().filter_entry(|entry| !excludes.is_match(entry.path())); + for entry in entries { + let path = entry?.into_path(); + if globs.is_match(&path) { + files_from_glob.insert(path); + } + } + } + + files.extend(files_from_glob); + + Ok(Walk { roots, globs, files }) +} + +fn build_excludes( + root: &Path, + excludes: impl IntoIterator>, +) -> Result { + let mut globs = GlobSetBuilder::new(); + + for path in excludes { + let path = dunce::simplified(root).join(path); + if let Ok(path) = path.absolutize() + && let Some(path) = path.to_str() + { + globs.add(Glob::new(path)?); + } + } + + Ok(globs.build()?) +} + +fn glob_literal_base(pattern: &str) -> PathBuf { + let mut base = PathBuf::new(); + for component in Path::new(pattern).components() { + if component.as_os_str().to_string_lossy().chars().any(glob_syntax_character) { + break; + } + base.push(component); + } + base +} + +fn glob_syntax_character(character: char) -> bool { + matches!(character, '*' | '?' | '[' | '{') + || (character == '\\' && !std::path::is_separator('\\')) +} + +#[cfg(test)] +mod tests { + use super::*; + use itertools::Itertools; + use std::fs; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn temporary_directory() -> PathBuf { + let nanos = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); + let directory = std::env::temp_dir().join(format!("iris-walk-{nanos}")); + fs::create_dir_all(&directory).unwrap(); + directory + } + + fn touch(path: &Path) { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, "").unwrap(); + } + + fn relative_files(root: &Path, files: Vec) -> Vec { + let files = files + .into_iter() + .map(|file| file.strip_prefix(root).unwrap().to_string_lossy().replace('\\', "/")); + let mut files = files.collect_vec(); + files.sort(); + files + } + + #[test] + fn filtered_walk_excludes_matching_files() { + let root = temporary_directory(); + touch(&root.join("package/src/Main.purs")); + touch(&root.join("package/test/Test.Main.purs")); + touch(&root.join("package/test/Excluded.purs")); + + let walk = walk_filtered( + &root, + ["package/src/**/*.purs", "package/test/**/*.purs"], + ["package/test/Excluded.purs"], + ) + .unwrap(); + + assert_eq!( + relative_files(&root, walk.files), + vec!["package/src/Main.purs", "package/test/Test.Main.purs"] + ); + + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn filtered_walk_excludes_directory_trees() { + let root = temporary_directory(); + touch(&root.join("package/src/Main.purs")); + touch(&root.join("package/src/generated/Ignored.purs")); + + let walk = + walk_filtered(&root, ["package/src/**/*.purs"], ["package/src/generated"]).unwrap(); + + assert_eq!(relative_files(&root, walk.files), vec!["package/src/Main.purs"]); + + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn literal_base_stops_at_the_first_wildcard() { + let base = glob_literal_base("/workspace/src/**/*.purs"); + assert_eq!(base, PathBuf::from("/workspace/src")); + } + + #[test] + fn literal_base_excludes_a_wildcard_in_the_final_component() { + let base = glob_literal_base("/workspace/src/*.purs"); + assert_eq!(base, PathBuf::from("/workspace/src")); + } + + #[test] + fn literal_base_retains_parent_directories() { + let base = glob_literal_base("/workspace/../shared/src/**/*.purs"); + assert_eq!(base, PathBuf::from("/workspace/../shared/src")); + } + + #[test] + fn literal_base_of_a_pattern_without_wildcards_is_the_whole_path() { + let base = glob_literal_base("/workspace/src/Main.purs"); + assert_eq!(base, PathBuf::from("/workspace/src/Main.purs")); + } + + #[test] + fn literal_base_recognises_every_metacharacter() { + for pattern in ["/a/b/*.purs", "/a/b/?.purs", "/a/b/[abc].purs", "/a/b/{x,y}.purs"] { + assert_eq!(glob_literal_base(pattern), PathBuf::from("/a/b"), "pattern: {pattern}"); + } + } + + #[test] + fn literal_base_keeps_class_closer_as_a_literal() { + let pattern = "/workspace/src/Main].purs"; + + assert!(Glob::new(pattern).is_ok()); + assert_eq!(glob_literal_base(pattern), PathBuf::from(pattern)); + } + + #[cfg(unix)] + #[test] + fn literal_base_stops_at_backslash_escape() { + let pattern = "/workspace/src/\\*.purs"; + + assert!(Glob::new(pattern).is_ok()); + assert_eq!(glob_literal_base(pattern), PathBuf::from("/workspace/src")); + } + + #[cfg(windows)] + #[test] + fn walks_windows_style_globs() { + let root = temporary_directory(); + touch(&root.join("src/Main.purs")); + let canonical_root = dunce::canonicalize(&root).unwrap(); + + let walk = walk(&canonical_root, [r"src\**\*.purs"]).unwrap(); + + assert_eq!(relative_files(&canonical_root, walk.files), vec!["src/Main.purs"]); + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/compiler-bin/src/workspace.rs b/compiler-bin/iris-build/src/workspace.rs similarity index 58% rename from compiler-bin/src/workspace.rs rename to compiler-bin/iris-build/src/workspace.rs index c39942a90..b8081c084 100644 --- a/compiler-bin/src/workspace.rs +++ b/compiler-bin/iris-build/src/workspace.rs @@ -1,8 +1,11 @@ +//! Spago workspace discovery for project builds. + use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::{fs, io}; use ignore::WalkBuilder; +use itertools::Itertools; use serde::Deserialize; use thiserror::Error; @@ -79,21 +82,11 @@ pub struct Workspace { } impl Workspace { - pub fn find_ancestor(current_directory: &Path) -> Result, WorkspaceError> { - for directory in current_directory.ancestors().skip(1) { - let path = directory.join(MANIFEST); - if path.is_file() && read_manifest(&path)?.workspace.is_some() { - return Ok(Some(directory.to_path_buf())); - } - } - Ok(None) - } - pub fn discover( current_directory: &Path, requested_package: Option<&str>, ) -> Result { - let current_directory = current_directory.canonicalize().map_err(|source| { + let current_directory = dunce::canonicalize(current_directory).map_err(|source| { WorkspaceError::CanonicalizeDirectory { path: current_directory.to_path_buf(), source } })?; let (root, inferred_package) = find_root(¤t_directory)?; @@ -104,7 +97,7 @@ impl Workspace { let selected = if let Some(requested) = requested_package { if !packages.contains_key(requested) { - let available = packages.keys().cloned().collect::>().join(", "); + let available = packages.keys().cloned().collect_vec().join(", "); return Err(WorkspaceError::UnknownPackage { requested: requested.to_owned(), available, @@ -173,13 +166,14 @@ fn discover_packages(root: &Path) -> Result, } Some(Ok(entry.into_path())) }); - let mut manifests = manifests.collect::, _>>()?; + let mut manifests = manifests.process_results(|entries| entries.collect_vec())?; manifests.sort_by_key(|path| path.components().count()); - let mut nested_workspaces = Vec::new(); + let mut nested_workspaces = vec![]; let mut packages = BTreeMap::new(); for path in manifests { - let package_root = path.parent().expect("spago.yaml path has no parent"); + let package_root = + path.parent().expect("invariant violated: spago.yaml path has no parent"); if package_root != root && nested_workspaces.iter().any(|nested: &PathBuf| package_root.starts_with(nested)) { @@ -194,13 +188,13 @@ fn discover_packages(root: &Path) -> Result, let Some(package) = manifest.package else { continue; }; - let name = package.name.clone(); + let name = String::clone(&package.name); let workspace_package = WorkspacePackage { root: package_root.to_path_buf(), has_tests: package_root.join("test").is_dir(), manifest: package, }; - if let Some(previous) = packages.insert(name.clone(), workspace_package) { + if let Some(previous) = packages.insert(String::clone(&name), workspace_package) { return Err(WorkspaceError::DuplicatePackage { name, first: previous.root, @@ -227,146 +221,3 @@ fn read_manifest(path: &Path) -> Result { serde_yml::from_str(&source) .map_err(|source| WorkspaceError::ParseManifest { path: path.to_path_buf(), source }) } - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::tempdir; - - fn write(path: &Path, content: &str) { - fs::create_dir_all(path.parent().unwrap()).unwrap(); - fs::write(path, content).unwrap(); - } - - #[test] - fn discovers_current_package_in_workspace() { - let temporary = tempdir().unwrap(); - write(&temporary.path().join("spago.yaml"), "workspace: {}\n"); - write( - &temporary.path().join("packages/app/spago.yaml"), - r#"package: - name: app -"#, - ); - write( - &temporary.path().join("packages/library/spago.yaml"), - r#"package: - name: library -"#, - ); - let current = temporary.path().join("packages/app/src"); - fs::create_dir_all(¤t).unwrap(); - - let workspace = Workspace::discover(¤t, None).unwrap(); - assert_eq!(workspace.selected.as_deref(), Some("app")); - assert_eq!(workspace.packages.keys().collect::>(), vec!["app", "library"]); - } - - #[test] - fn workspace_root_does_not_select_its_package() { - let temporary = tempdir().unwrap(); - write( - &temporary.path().join("spago.yaml"), - r#"workspace: {} -package: - name: root -"#, - ); - write( - &temporary.path().join("packages/library/spago.yaml"), - r#"package: - name: library -"#, - ); - - let workspace = Workspace::discover(temporary.path(), None).unwrap(); - assert_eq!(workspace.selected, None); - } - - #[test] - fn root_package_subdirectory_does_not_select_it() { - let temporary = tempdir().unwrap(); - write( - &temporary.path().join("spago.yaml"), - r#"workspace: {} -package: - name: root -"#, - ); - write( - &temporary.path().join("packages/library/spago.yaml"), - r#"package: - name: library -"#, - ); - let current = temporary.path().join("src"); - fs::create_dir_all(¤t).unwrap(); - - let workspace = Workspace::discover(¤t, None).unwrap(); - assert_eq!(workspace.selected, None); - } - - #[test] - fn gitignored_packages_are_not_discovered() { - let temporary = tempdir().unwrap(); - write( - &temporary.path().join("spago.yaml"), - r#"workspace: {} -package: - name: root -"#, - ); - write(&temporary.path().join(".gitignore"), "ignored/\n"); - write( - &temporary.path().join("ignored/spago.yaml"), - r#"package: - name: ignored -"#, - ); - - let workspace = Workspace::discover(temporary.path(), None).unwrap(); - assert_eq!(workspace.packages.keys().collect::>(), vec!["root"]); - } - - #[test] - fn nested_workspace_is_not_part_of_parent() { - let temporary = tempdir().unwrap(); - write( - &temporary.path().join("spago.yaml"), - r#"workspace: {} -package: - name: root -"#, - ); - write( - &temporary.path().join("nested/spago.yaml"), - r#"workspace: {} -package: - name: nested -"#, - ); - write( - &temporary.path().join("nested/packages/child/spago.yaml"), - r#"package: - name: child -"#, - ); - - let workspace = Workspace::discover(temporary.path(), None).unwrap(); - assert_eq!(workspace.packages.keys().collect::>(), vec!["root"]); - } - - #[test] - fn finds_ancestor_workspace() { - let temporary = tempdir().unwrap(); - write(&temporary.path().join("spago.yaml"), "workspace: {}\n"); - let nested = temporary.path().join("packages/application"); - fs::create_dir_all(&nested).unwrap(); - - assert_eq!( - Workspace::find_ancestor(&nested).unwrap(), - Some(temporary.path().to_path_buf()) - ); - assert_eq!(Workspace::find_ancestor(temporary.path()).unwrap(), None); - } -} diff --git a/compiler-bin/iris-cli/Cargo.toml b/compiler-bin/iris-cli/Cargo.toml new file mode 100644 index 000000000..90268b402 --- /dev/null +++ b/compiler-bin/iris-cli/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "iris-cli" +version = "0.1.0" +edition = "2024" +authors = ["Justin Garcia "] +license = "BSD-3-Clause" +description = "PureScript compiler command-line interface." +readme = "../../README.md" +homepage = "https://github.com/purefunctor/purescript-iris" +repository = "https://github.com/purefunctor/purescript-iris" +keywords = ["purescript", "compiler"] +categories = ["compilers"] + +[[bin]] +name = "iris" +path = "src/bin/iris.rs" + +[dependencies] +configuration = { version = "0.1.0", path = "../../compiler-lsp/configuration" } +iris-build = { version = "0.1.0", path = "../iris-build" } +iris-lsp = { version = "0.1.0", path = "../iris-lsp" } +iris-package = { version = "0.1.0", path = "../iris-package" } +iris-watch = { version = "0.1.0", path = "../iris-watch" } +itertools = "0.15.0" +serde_json = "1.0.151" +thiserror = "2.0.20" +tracing = "0.1.44" +tracing-subscriber = "0.3.23" +usage = { package = "usage-rs", version = "6.8.0" } diff --git a/compiler-bin/build.rs b/compiler-bin/iris-cli/build.rs similarity index 84% rename from compiler-bin/build.rs rename to compiler-bin/iris-cli/build.rs index 8fe5a5ae8..1be63a34e 100644 --- a/compiler-bin/build.rs +++ b/compiler-bin/iris-cli/build.rs @@ -3,8 +3,8 @@ use std::env; fn main() { println!("cargo::rerun-if-env-changed=IRIS_BUILD_REVISION"); - let package_version = - env::var("CARGO_PKG_VERSION").expect("Cargo must provide package version"); + let package_version = env::var("CARGO_PKG_VERSION") + .expect("invariant violated: Cargo must provide package version"); let version = match env::var("IRIS_BUILD_REVISION") { Ok(revision) => { assert!( diff --git a/compiler-bin/iris-cli/src/bin/iris.rs b/compiler-bin/iris-cli/src/bin/iris.rs new file mode 100644 index 000000000..4662be38d --- /dev/null +++ b/compiler-bin/iris-cli/src/bin/iris.rs @@ -0,0 +1,3 @@ +fn main() { + std::process::exit(iris_cli::run()); +} diff --git a/compiler-bin/iris-cli/src/cli.rs b/compiler-bin/iris-cli/src/cli.rs new file mode 100644 index 000000000..e272fa35f --- /dev/null +++ b/compiler-bin/iris-cli/src/cli.rs @@ -0,0 +1,404 @@ +use std::ffi::OsStr; +use std::io::{self, IsTerminal}; +use std::path::PathBuf; +use std::{env, fs}; + +use configuration::{Configuration, ConfigurationSettings}; +use iris_build::{BuildConfig, ProjectConfig, RunConfig, TestConfig}; +use iris_package::{AddConfig, NewConfig}; +use itertools::Itertools; +use thiserror::Error; +use tracing::level_filters::LevelFilter; +use usage::{Args, Subcommands, ValueEnum}; + +use crate::logging::LoggingFilters; + +#[derive(Debug, usage::Cli)] +#[usage( + bin = "iris", + about = env!("CARGO_PKG_DESCRIPTION"), + version = crate::VERSION, + unknown_flags = "error", + args_override_self = false +)] +pub struct Program { + #[usage(subcommand)] + pub command: Command, +} + +#[derive(Debug, Subcommands)] +pub enum Command { + /// Create a Spago project in the current directory. + New(NewOptions), + /// Add dependencies to a Spago package. + Add(AddOptions), + /// Build a Spago workspace or package. + Build(BuildOptions), + /// Build a Spago workspace or package and rebuild when inputs change. + Watch(WatchOptions), + /// Run the language server over standard input and output. + Lsp(LspOptions), + /// Build and run a Spago package with Node.js. + Run { + #[usage(flatten)] + options: RunOptions, + }, + /// Build and test one or more Spago packages with Node.js. + Test { + #[usage(flatten)] + options: TestOptions, + }, +} + +pub struct LspConfig { + pub configuration: Configuration, + pub logging: LoggingFilters, +} + +pub struct WatchConfig { + pub project: ProjectConfig, + pub watch: iris_watch::WatchConfig, +} + +#[derive(Debug, Args)] +#[usage(args_override_self = false)] +pub struct NewOptions { + /// Package name. Defaults to the current directory name. + #[usage(long, value_name = "NAME")] + name: Option, +} + +impl NewOptions { + pub fn into_config(self) -> NewConfig { + NewConfig { name: self.name } + } +} + +#[derive(Debug, Args)] +#[usage(args_override_self = false)] +pub struct AddOptions { + /// Workspace package whose dependencies should change. + #[usage(short, long, value_name = "NAME")] + package: Option, + + /// Add packages as test dependencies. + #[usage(long)] + test: bool, + + /// Packages to add. + #[usage(value_name = "DEPENDENCY", required = true)] + dependencies: Vec, +} + +impl AddOptions { + pub fn into_config(self) -> AddConfig { + AddConfig { + package: self.package, + dependencies: self.dependencies, + test_dependencies: self.test, + } + } +} + +#[derive(Debug, Args)] +#[usage(args_override_self = false)] +pub struct BuildOptions { + #[usage(flatten)] + project: ProjectBuildOptions, + + /// Write JavaScript output even when compilation reports errors. + #[usage(long)] + resilient: bool, + + /// Suppress compiler warnings and errors without hiding build progress. + #[usage(long)] + no_diagnostics: bool, +} + +#[derive(Debug, Args)] +#[usage(args_override_self = false)] +pub struct ProjectBuildOptions { + /// Workspace package to build. + #[usage(short, long, value_name = "NAME")] + package: Option, + + /// Output directory. Defaults to output in the workspace root. + #[usage(short, long, value_name = "DIR")] + output: Option, + + /// Suppress build progress and Spago output. + #[usage(short, long)] + quiet: bool, + + /// When to use colors in diagnostics and progress output. + #[usage(long, value_enum, default = "auto")] + color: ColorChoice, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +pub enum ColorChoice { + Auto, + Always, + Never, +} + +impl BuildOptions { + pub fn into_config(self) -> BuildConfig { + BuildConfig { + package: self.project.package, + output: self.project.output, + quiet: self.project.quiet, + color: use_color(self.project.color), + resilient: self.resilient, + diagnostics: !self.no_diagnostics, + } + } +} + +impl ProjectBuildOptions { + fn into_project_config(self) -> (ProjectConfig, bool) { + let project = + ProjectConfig { package: self.package, output: self.output, quiet: self.quiet }; + (project, use_color(self.color)) + } +} + +#[derive(Debug, Args)] +#[usage(args_override_self = false)] +pub struct RunOptions { + #[usage(flatten)] + build: ProjectBuildOptions, + + /// Module containing the program entry point. + #[usage(long, value_name = "MODULE")] + main: Option, + + /// Arguments passed to the program. + #[usage(double_dash = "required")] + arguments: Vec, +} + +impl RunOptions { + pub fn into_config(self) -> RunConfig { + let (project, color) = self.build.into_project_config(); + RunConfig { project, color, main: self.main, arguments: self.arguments } + } +} + +#[derive(Debug, Args)] +#[usage(args_override_self = false)] +pub struct TestOptions { + #[usage(flatten)] + build: ProjectBuildOptions, + + /// Module containing the test entry point. + #[usage(long, value_name = "MODULE")] + main: Option, + + /// Arguments passed to each test program. + #[usage(double_dash = "required")] + arguments: Vec, +} + +impl TestOptions { + pub fn into_config(self) -> TestConfig { + let (project, color) = self.build.into_project_config(); + TestConfig { project, color, main: self.main, arguments: self.arguments } + } +} + +#[derive(Debug, Args)] +#[usage(args_override_self = false)] +pub struct WatchOptions { + /// Workspace package to build. + #[usage(short, long, value_name = "NAME")] + package: Option, + + /// Output directory. Defaults to output in the workspace root. + #[usage(short, long, value_name = "DIR")] + output: Option, + + /// Suppress watch summaries and Spago output. + #[usage(short, long)] + quiet: bool, + + /// When to use colors in diagnostics and watch output. + #[usage(long, value_enum, default = "auto")] + color: ColorChoice, + + /// Suppress compiler warnings and errors without hiding watch summaries. + #[usage(long)] + no_diagnostics: bool, +} + +impl WatchOptions { + pub fn into_config(self) -> WatchConfig { + WatchConfig { + project: ProjectConfig { + package: self.package, + output: self.output, + quiet: self.quiet, + }, + watch: iris_watch::WatchConfig { + quiet: self.quiet, + color: use_color(self.color), + diagnostics: !self.no_diagnostics, + }, + } + } +} + +#[derive(Debug, Args)] +#[usage(args_override_self = false)] +pub struct LspOptions { + /// Use standard input and output for LSP transport. + #[usage(long)] + stdio: bool, + + /// Log level for the query engine. + #[usage( + long, + value_name = "LEVEL", + default = "off", + choices("off", "error", "warn", "info", "debug", "trace") + )] + query_log: LevelFilter, + + /// Log level for the type checker. + #[usage( + long, + value_name = "LEVEL", + default = "off", + choices("off", "error", "warn", "info", "debug", "trace") + )] + checking_log: LevelFilter, + + /// Log level for the language server. + #[usage( + long, + value_name = "LEVEL", + default = "info", + choices("off", "error", "warn", "info", "debug", "trace") + )] + lsp_log: LevelFilter, + + /// Language server configuration as a JSON object, read once at startup. + #[usage(long, value_name = "JSON", conflicts = "--config-file")] + config: Option, + + /// Language server configuration file, relative to the working directory. + #[usage(long, value_name = "PATH", conflicts = "--config")] + config_file: Option, +} + +#[derive(Debug, Error)] +pub enum ConfigurationError { + #[error("failed to read configuration file {}: {error}", path.display())] + ReadFile { path: PathBuf, error: io::Error }, + #[error("invalid configuration in {input}: {error}")] + InvalidJson { input: String, error: serde_json::Error }, +} + +impl LspOptions { + pub fn into_config(self) -> Result { + let configuration = read_configuration(self.config, self.config_file)?; + let logging = LoggingFilters { + query: self.query_log, + checking: self.checking_log, + lsp: self.lsp_log, + }; + Ok(LspConfig { configuration, logging }) + } +} + +fn read_configuration( + config: Option, + config_file: Option, +) -> Result { + let (input, content) = if let Some(path) = config_file { + let content = fs::read_to_string(&path) + .map_err(|error| ConfigurationError::ReadFile { path: PathBuf::clone(&path), error })?; + (path.display().to_string(), content) + } else if let Some(content) = config { + ("--config".to_string(), content) + } else { + return Ok(Configuration::default()); + }; + let settings = serde_json::from_str::>(&content) + .map_err(|error| ConfigurationError::InvalidJson { input, error })? + .unwrap_or_default(); + Ok(settings.apply_to(&Configuration::default())) +} + +fn use_color(choice: ColorChoice) -> bool { + match choice { + ColorChoice::Auto => { + let no_color = env::var_os("NO_COLOR").is_some_and(|value| !value.is_empty()); + io::stderr().is_terminal() && !no_color + } + ColorChoice::Always => true, + ColorChoice::Never => false, + } +} + +impl Program { + pub fn parse_with_diagnostics() -> Program { + let arguments = std::env::args_os().collect_vec(); + let arguments = arguments.iter().map(|argument| argument.as_os_str()).collect_vec(); + match Program::try_parse_from(&arguments) { + Ok(program) => program, + Err(usage::Error::Help { cmd, long }) => { + let page = usage::help::render_styled( + Program::spec(), + cmd, + long, + usage::help::Style::auto(), + ); + print!("{}", page.unwrap_or_default()); + std::process::exit(0); + } + Err(usage::Error::HelpAll { cmd }) => { + let page = usage::help::render_all_styled( + Program::spec(), + cmd, + usage::help::Style::auto(), + ); + print!("{}", page.unwrap_or_default()); + std::process::exit(0); + } + Err(usage::Error::Version { .. }) => { + println!("iris {}", crate::VERSION); + std::process::exit(0); + } + Err(error) => { + report_error(&arguments, error); + std::process::exit(2); + } + } + } +} + +fn report_error(arguments: &[&OsStr], error: usage::Error) { + let report = usage::diagnostic::report(Program::spec(), &arguments[1..], &error); + eprint!("{}", report.rendered); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_build_options_to_configuration() { + let arguments = ["iris", "build", "--quiet", "--no-diagnostics", "--resilient"]; + let arguments = arguments.iter().map(OsStr::new).collect_vec(); + let program = Program::try_parse_from(&arguments).unwrap(); + let Command::Build(options) = program.command else { + panic!("invariant violated: expected build command"); + }; + let config = options.into_config(); + + assert!(config.quiet); + assert!(!config.diagnostics); + assert!(config.resilient); + } +} diff --git a/compiler-bin/iris-cli/src/lib.rs b/compiler-bin/iris-cli/src/lib.rs new file mode 100644 index 000000000..05bbd8898 --- /dev/null +++ b/compiler-bin/iris-cli/src/lib.rs @@ -0,0 +1,88 @@ +mod cli; +mod logging; + +pub(crate) const PACKAGE_NAME: &str = env!("CARGO_PKG_NAME"); +pub(crate) const VERSION: &str = env!("IRIS_VERSION"); + +pub fn run() -> i32 { + let program = cli::Program::parse_with_diagnostics(); + match program.command { + cli::Command::New(options) => match iris_package::create(options.into_config()) { + Ok(()) => 0, + Err(error) => { + eprintln!("{error}"); + 1 + } + }, + cli::Command::Add(options) => match iris_package::add(options.into_config()) { + Ok(()) => 0, + Err(error) => { + eprintln!("{error}"); + 1 + } + }, + cli::Command::Build(options) => match iris_build::build(options.into_config()) { + Ok(()) => 0, + Err(error) => { + if !error.diagnostics_were_suppressed() { + eprintln!("{error}"); + } + 1 + } + }, + cli::Command::Watch(options) => { + let config = options.into_config(); + let project = match iris_build::prepare_project(config.project) { + Ok(project) => project, + Err(error) => { + eprintln!("{error}"); + return 1; + } + }; + if let Err(error) = iris_watch::watch(project, config.watch) { + eprintln!("{error}"); + return 1; + } + 0 + } + cli::Command::Lsp(options) => { + let config = match options.into_config() { + Ok(config) => config, + Err(error) => { + eprintln!("error: {error}"); + return 2; + } + }; + if let Err(error) = logging::start(config.logging) { + eprintln!("error: failed to start logging: {error}"); + return 1; + } + let server = iris_lsp::ServerConfig { + configuration: config.configuration, + name: PACKAGE_NAME.to_string(), + version: VERSION.to_string(), + }; + match iris_lsp::start(server) { + Ok(()) => 0, + Err(error) => { + eprintln!("error: language server failed: {error}"); + 1 + } + } + } + cli::Command::Run { options } => match iris_build::run(options.into_config()) { + Ok(()) => 0, + Err(error) => { + eprintln!("{error}"); + error.exit_code() + } + }, + cli::Command::Test { options } => match iris_build::test(options.into_config()) { + Ok(()) => 0, + Err(error) => { + eprintln!("{error}"); + error.exit_code() + } + }, + } +} diff --git a/compiler-bin/iris-cli/src/logging.rs b/compiler-bin/iris-cli/src/logging.rs new file mode 100644 index 000000000..df2d8d18f --- /dev/null +++ b/compiler-bin/iris-cli/src/logging.rs @@ -0,0 +1,57 @@ +use std::time::Instant; +use std::{fs, io}; + +use tracing::level_filters::LevelFilter; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::{Layer, Registry, filter, fmt}; + +#[derive(Clone, Copy)] +pub struct LoggingFilters { + pub query: LevelFilter, + pub checking: LevelFilter, + pub lsp: LevelFilter, +} + +struct SpanTimingLayer; + +impl tracing_subscriber::Layer for SpanTimingLayer +where + S: tracing::Subscriber + for<'lookup> tracing_subscriber::registry::LookupSpan<'lookup>, +{ + fn on_enter(&self, id: &tracing::span::Id, context: tracing_subscriber::layer::Context<'_, S>) { + if let Some(span) = context.span(id) + && span.extensions().get::().is_none() + { + span.extensions_mut().insert(Instant::now()); + } + } + + fn on_close(&self, id: tracing::span::Id, context: tracing_subscriber::layer::Context<'_, S>) { + if let Some(span) = context.span(&id) + && let Some(start) = span.extensions().get::() + && context.enabled(span.metadata()) + { + tracing::info!(target: "meta", span = span.name(), span.duration = ?start.elapsed()); + } + } +} + +pub fn start(filters: LoggingFilters) -> io::Result<()> { + let path = std::env::temp_dir().join("iris.log"); + let file = fs::OpenOptions::new().create(true).append(true).open(path)?; + + let output_filter = filter::Targets::new() + .with_target("building::engine", filters.query) + .with_target("checking", filters.checking) + .with_target("iris_lsp", filters.lsp) + .with_target("meta", filters.lsp) + .with_default(LevelFilter::OFF); + let output = fmt::layer().with_writer(file).with_filter(output_filter); + + let timing_filter = + filter::Targets::new().with_target("iris_lsp", filters.lsp).with_default(LevelFilter::OFF); + let timing = SpanTimingLayer.with_filter(timing_filter); + + let subscriber = Registry::default().with(output).with(timing); + tracing::subscriber::set_global_default(subscriber).map_err(io::Error::other) +} diff --git a/compiler-bin/iris-lsp/Cargo.toml b/compiler-bin/iris-lsp/Cargo.toml new file mode 100644 index 000000000..8c1fd5bfe --- /dev/null +++ b/compiler-bin/iris-lsp/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "iris-lsp" +version = "0.1.0" +edition = "2024" +authors = ["Justin Garcia "] +license = "BSD-3-Clause" +description = "PureScript language server implementation for Iris." +repository = "https://github.com/purefunctor/purescript-iris" + +[dependencies] +analyzer = { version = "0.1.0", path = "../../compiler-lsp/analyzer" } +async-lsp = { version = "0.2.4", features = ["tokio"] } +building = { version = "0.1.0", path = "../../compiler-core/building" } +configuration = { version = "0.1.0", path = "../../compiler-lsp/configuration" } +dunce = "1.0.5" +files = { version = "0.1.0", path = "../../compiler-core/files" } +globset = "0.4.20" +iris-build = { version = "0.1.0", path = "../iris-build" } +itertools = "0.15.0" +lsp-types.workspace = true +parking_lot = "0.12.5" +path-absolutize = "4.0.1" +rustc-hash = "2.1.3" +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.151" +smol_str = "0.3.6" +spago = { version = "0.1.0", path = "../../compiler-lsp/spago" } +thiserror = "2.0.20" +tokio = { version = "1.53.1", features = ["io-std", "macros", "rt", "sync", "time"] } +tower = "0.5.3" +tracing = "0.1.44" +url = "2.5.8" +walkdir = "2.5.0" + +[dev-dependencies] +tempfile = "3.27.0" + +[target.'cfg(not(unix))'.dependencies] +tokio-util = { version = "0.7.19", features = ["compat"] } diff --git a/compiler-bin/iris-lsp/src/lib.rs b/compiler-bin/iris-lsp/src/lib.rs new file mode 100644 index 000000000..81a6a0d7d --- /dev/null +++ b/compiler-bin/iris-lsp/src/lib.rs @@ -0,0 +1,42 @@ +use std::error::Error; +use std::fmt; + +use configuration::Configuration; + +mod server; +mod walk; + +pub struct ServerConfig { + pub configuration: Configuration, + pub name: String, + pub version: String, +} + +#[derive(Debug)] +pub struct ServerError(Box); + +impl ServerError { + pub(crate) fn new(error: impl Error + Send + Sync + 'static) -> ServerError { + ServerError(Box::new(error)) + } +} + +impl fmt::Display for ServerError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(formatter) + } +} + +impl Error for ServerError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + Some(self.0.as_ref()) + } +} + +pub fn start(config: ServerConfig) -> Result<(), ServerError> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(ServerError::new)?; + runtime.block_on(server::async_start(config)) +} diff --git a/compiler-bin/src/lsp.rs b/compiler-bin/iris-lsp/src/server.rs similarity index 64% rename from compiler-bin/src/lsp.rs rename to compiler-bin/iris-lsp/src/server.rs index 1ab67ec0f..88b653e9b 100644 --- a/compiler-bin/src/lsp.rs +++ b/compiler-bin/iris-lsp/src/server.rs @@ -10,7 +10,7 @@ use std::borrow::BorrowMut; use std::collections::BTreeMap; use std::ops::ControlFlow; use std::path::PathBuf; -use std::sync::{Arc, LazyLock}; +use std::sync::Arc; use std::{env, fs, io, mem, process}; use analyzer::completion::SuggestionsCache; @@ -30,50 +30,71 @@ use building::lifecycle::{ }; use configuration::{Configuration, ConfigurationSettings, SourceDiscovery}; use files::{FileId, ForeignSourceKind}; +use iris_build::compilation::{CompilationParts, CompilationState, MaterializedPrim}; +use iris_build::compile::{InitialBuildConfig, PackageExecution, build_initial}; +use iris_build::events::SilentBuildEvents; +use iris_build::plan::PackageInput; use itertools::Itertools; use lsp_types::notification::Notification; use lsp_types::request::Request; use lsp_types::*; use parking_lot::{RwLock, RwLockReadGuard}; -use prim_constants::MODULE_MAP; +use path_absolutize::Absolutize; use rustc_hash::FxHashSet; -use tempfile::TempDir; +use smol_str::SmolStr; use tokio::task; use tower::ServiceBuilder; -use crate::lsp::capabilities::{ +use crate::server::capabilities::{ ConfigurationCapabilities, negotiate_analyzer_capabilities, negotiate_configuration_capabilities, negotiate_position_encoding, }; -use crate::lsp::error::{AnalyzerResultExt, LspError}; -use crate::walk; +use crate::server::error::{AnalyzerResultExt, LspError}; +use crate::{ServerConfig, ServerError, walk}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) enum SourceMetadata { + Builtin, + Package { editable: bool }, + Unmanaged { editable: bool }, +} -static PRIM_DIRECTORY: LazyLock = - LazyLock::new(|| TempDir::new().expect("invariant violated: failed to create PRIM_DIRECTORY")); +impl SourceMetadata { + fn editable(&self) -> bool { + match self { + SourceMetadata::Builtin => false, + SourceMetadata::Package { editable } | SourceMetadata::Unmanaged { editable } => { + *editable + } + } + } +} -fn configure_materialized_prim(engine: &QueryEngine, files: &mut FileLifecycle) { - for (name, content) in MODULE_MAP { - let path = PRIM_DIRECTORY.path().join(format!("{name}.purs")); - fs::write(&path, content).expect("invariant violated: failed to materialize Prim module"); +struct LspWorkspace { + engine: QueryEngine, + files: Arc>>, + source_roots: Vec, + selected_sources: FxHashSet>, + excluded_sources: FxHashSet>, + _prim: MaterializedPrim, +} - let uri = Url::from_file_path(path) - .expect("invariant violated: failed to create Prim module file URL"); - let unit = source_unit_from_source_uri(&uri) - .expect("invariant violated: failed to create Prim source unit"); - let event = LifecycleEvent::Source { - unit, - event: SourceEvent::DiskObserved { - disk: DiskObservation::Found(Arc::from(*content)), - metadata: false, - }, - }; - let change = files.apply(engine, event); - let id = change - .changed_sources() - .next() - .expect("invariant violated: Prim source lifecycle did not insert a source"); - engine.set_module_file(name, id); - } +struct SourceRoot { + path: PathBuf, + metadata: SourceMetadata, +} + +enum WorkspaceState { + WaitingForConfiguration { pending: Vec }, + Ready { workspace: LspWorkspace }, +} + +enum PendingNotification { + DidOpen(DidOpenTextDocumentParams), + DidSave(DidSaveTextDocumentParams), + DidClose(DidCloseTextDocumentParams), + DidChange(DidChangeTextDocumentParams), + DidChangeWatchedFiles(DidChangeWatchedFilesParams), } pub struct State { @@ -81,8 +102,7 @@ pub struct State { pub config: Arc, pub client: ClientSocket, - pub engine: QueryEngine, - pub files: Arc>>, + workspace: WorkspaceState, pub diagnostics: event::DiagnosticScheduler, pub workspace_symbols_cache: Arc>, @@ -92,21 +112,20 @@ pub struct State { pub configuration_scope: Option, pub configuration_capabilities: ConfigurationCapabilities, pub configuration_generation: u64, - pub workspace_loaded: bool, - pub selected_sources: FxHashSet>, - pub excluded_sources: FxHashSet>, pub position_encoding: PositionEncoding, pub analyzer_capabilities: AnalyzerCapabilities, pub watched_files_dynamic_registration: bool, + pub name: String, + pub version: String, } impl State { - fn new(config: Arc, client: ClientSocket) -> State { - let engine = QueryEngine::default(); - let mut files = FileLifecycle::default(); - configure_materialized_prim(&engine, &mut files); - - let files = Arc::new(RwLock::new(files)); + fn new( + config: Arc, + client: ClientSocket, + name: String, + version: String, + ) -> State { let diagnostics = event::DiagnosticScheduler::default(); let workspace_symbols_cache = WorkspaceSymbolsCache::default(); @@ -119,9 +138,6 @@ impl State { let configuration_scope = None; let configuration_capabilities = ConfigurationCapabilities::default(); let configuration_generation = 0; - let workspace_loaded = false; - let selected_sources = FxHashSet::default(); - let excluded_sources = FxHashSet::default(); let position_encoding = PositionEncoding::Utf16; let analyzer_capabilities = AnalyzerCapabilities::default(); let watched_files_dynamic_registration = false; @@ -130,8 +146,7 @@ impl State { startup_config: Arc::clone(&config), config, client, - engine, - files, + workspace: WorkspaceState::WaitingForConfiguration { pending: vec![] }, diagnostics, workspace_symbols_cache, suggestions_cache, @@ -139,28 +154,78 @@ impl State { configuration_scope, configuration_capabilities, configuration_generation, - workspace_loaded, - selected_sources, - excluded_sources, position_encoding, analyzer_capabilities, watched_files_dynamic_registration, + name, + version, } } - fn spawn(&self, f: impl FnOnce(StateSnapshot) -> T + Send + 'static) -> task::JoinHandle + fn workspace(&self) -> Result<&LspWorkspace, LspError> { + match &self.workspace { + WorkspaceState::WaitingForConfiguration { .. } => Err(LspError::WorkspaceNotReady), + WorkspaceState::Ready { workspace } => Ok(workspace), + } + } + + fn workspace_loaded(&self) -> bool { + matches!(self.workspace, WorkspaceState::Ready { .. }) + } + + fn install_compilation( + &mut self, + compilation: CompilationState, + source_roots: Vec, + selected_sources: FxHashSet>, + ) -> Vec { + let CompilationParts { engine, files, prim } = compilation.into_parts(); + let workspace = LspWorkspace { + engine, + files: Arc::new(RwLock::new(files)), + source_roots, + selected_sources, + excluded_sources: FxHashSet::default(), + _prim: prim, + }; + let previous = mem::replace(&mut self.workspace, WorkspaceState::Ready { workspace }); + match previous { + WorkspaceState::WaitingForConfiguration { pending } => pending, + WorkspaceState::Ready { .. } => vec![], + } + } + + fn engine(&self) -> &QueryEngine { + &self + .workspace() + .expect("invariant violated: LSP operation requires a ready workspace") + .engine + } + + fn files(&self) -> &Arc>> { + &self + .workspace() + .expect("invariant violated: LSP operation requires a ready workspace") + .files + } + + fn spawn( + &self, + action: impl FnOnce(StateSnapshot) -> T + Send + 'static, + ) -> Result, LspError> where T: Send + 'static, { + let workspace = self.workspace()?; let snapshot = StateSnapshot { - engine: self.engine.snapshot(), - files: Arc::clone(&self.files), + engine: workspace.engine.snapshot(), + files: Arc::clone(&workspace.files), workspace_symbols_cache: Arc::clone(&self.workspace_symbols_cache), suggestions_cache: Arc::clone(&self.suggestions_cache), position_encoding: self.position_encoding, analyzer_capabilities: self.analyzer_capabilities, }; - task::spawn_blocking(move || f(snapshot)) + Ok(task::spawn_blocking(move || action(snapshot))) } fn invalidate_workspace_symbols(&self) { @@ -176,7 +241,7 @@ impl State { struct StateSnapshot { engine: QueryEngine, - files: Arc>>, + files: Arc>>, workspace_symbols_cache: Arc>, suggestions_cache: Arc>, position_encoding: PositionEncoding, @@ -186,19 +251,19 @@ struct StateSnapshot { impl StateSnapshot { fn with_analyzer_context( &self, - f: impl FnOnce(&AnalyzerContext>) -> T, + action: impl FnOnce(&AnalyzerContext>) -> T, ) -> T { let files = self.files.read(); let host = LspAnalyzerHost { queries: &self.engine, files }; let context = AnalyzerContext::new(&host, self.position_encoding, self.analyzer_capabilities); - f(&context) + action(&context) } } struct LspAnalyzerHost<'a> { queries: &'a QueryEngine, - files: RwLockReadGuard<'a, FileLifecycle>, + files: RwLockReadGuard<'a, FileLifecycle>, } impl AnalyzerHost for LspAnalyzerHost<'_> { @@ -224,22 +289,23 @@ impl AnalyzerHost for LspAnalyzerHost<'_> { } fn is_editable(&self, file_id: FileId) -> bool { - self.files.source_metadata(file_id).copied().unwrap_or(false) + self.files.source_metadata(file_id).is_some_and(SourceMetadata::editable) } } fn initialize( state: &mut State, - p: extension::CustomInitializeParams, + parameters: extension::CustomInitializeParams, ) -> impl Future> + use<> { - let position_encoding = negotiate_position_encoding(&p.initialize_params); + let position_encoding = negotiate_position_encoding(¶meters.initialize_params); state.position_encoding = position_encoding; - state.analyzer_capabilities = negotiate_analyzer_capabilities(&p.initialize_params); - state.configuration_capabilities = negotiate_configuration_capabilities(&p.initialize_params); + state.analyzer_capabilities = negotiate_analyzer_capabilities(¶meters.initialize_params); + state.configuration_capabilities = + negotiate_configuration_capabilities(¶meters.initialize_params); state.watched_files_dynamic_registration = - watched_files_dynamic_registration(&p.initialize_params.capabilities); + watched_files_dynamic_registration(¶meters.initialize_params.capabilities); - state.configuration_scope = p + state.configuration_scope = parameters .initialize_params .workspace_folders .and_then(|folders| folders.first().map(|folder| Url::clone(&folder.uri))); @@ -248,12 +314,13 @@ fn initialize( .as_ref() .and_then(|uri| uri.to_file_path().ok()) .or_else(|| env::current_dir().ok()); + let server_info = ServerInfo { + name: String::clone(&state.name), + version: Some(String::clone(&state.version)), + }; async move { Ok(InitializeResult { - server_info: Some(ServerInfo { - name: crate::PACKAGE_NAME.to_owned(), - version: Some(crate::VERSION.to_owned()), - }), + server_info: Some(server_info), capabilities: ServerCapabilities { completion_provider: Some(CompletionOptions { resolve_provider: Some(true), @@ -417,7 +484,7 @@ fn finish_workspace_configuration( if let Err(error) = apply_configuration(state, Arc::new(configuration)) { let error = format!("Failed to apply Iris settings: {error}"); report_configuration_error(state, &error); - if !state.workspace_loaded { + if !state.workspace_loaded() { apply_configuration(state, Arc::clone(&state.startup_config))?; } } @@ -425,7 +492,7 @@ fn finish_workspace_configuration( } Err(error) => { report_configuration_error(state, &error); - if state.workspace_loaded { + if state.workspace_loaded() { Ok(()) } else { apply_configuration(state, Arc::clone(&state.startup_config)) @@ -446,7 +513,7 @@ fn did_change_configuration( fn report_configuration_error(state: &mut State, error: &str) { tracing::error!("{error}"); - let message = if state.workspace_loaded { + let message = if state.workspace_loaded() { format!("{error}. The previous Iris settings remain active.") } else { format!("{error}. Iris will use its startup settings.") @@ -503,11 +570,18 @@ fn exit(_state: &mut State, (): ()) -> Result<(), LspError> { Ok(()) } +struct DiscoveredWorkspace { + source_globs: Vec, + packages: Vec, + metadata: BTreeMap, + source_roots: Vec, +} + fn discover_manual( root: &std::path::Path, program: &str, arguments: &[String], -) -> Result, LspError> { +) -> Result { tracing::info!("Using '{}'", program); let mut command = process::Command::new(program); @@ -520,74 +594,189 @@ fn discover_manual( let output = str::from_utf8(&output.stdout)?; let walk::Walk { files, .. } = walk::walk(root, output.lines())?; - let files = files.into_iter().map(|file| { + + let metadata = files.iter().map(|file| { let editable = file.starts_with(root); - (file, editable) + (PathBuf::clone(file), SourceMetadata::Unmanaged { editable }) }); - Ok(files.collect()) + let metadata = metadata.collect(); + + let package = PackageInput { + name: SmolStr::new("unmanaged"), + source_identities: Vec::clone(&files), + dependencies: vec![], + }; + + let source_roots = vec![SourceRoot { + path: root.to_path_buf(), + metadata: SourceMetadata::Unmanaged { editable: true }, + }]; + + Ok(DiscoveredWorkspace { source_globs: files, packages: vec![package], metadata, source_roots }) } -fn discover_spago(root: &std::path::Path) -> Result, LspError> { +fn discover_spago(root: &std::path::Path) -> Result { tracing::info!("Using 'spago.lock'"); let packages = spago::source_files_by_package(root).map_err(LspError::SpagoLock)?; - let files = packages.into_values().flat_map(|package| { - let editable = package.reference == spago::PackageReference::Workspace; - package.sources.into_iter().map(move |file| (file, editable)) + + let package_inputs = packages.iter().map(|(name, package)| PackageInput { + name: SmolStr::clone(name), + source_identities: Vec::clone(&package.sources), + dependencies: package.dependencies.iter().cloned().collect_vec(), }); + let package_inputs = package_inputs.collect_vec(); + + let metadata = packages.values().flat_map(|package| { + let editable = matches!( + package.reference, + spago::PackageReference::Workspace | spago::PackageReference::Local + ); + package + .sources + .iter() + .map(move |file| (PathBuf::clone(file), SourceMetadata::Package { editable })) + }); + let metadata = metadata.collect::>(); + + let source_roots = packages.values().map(|package| package_source_roots(root, package)); + let source_root_groups = + source_roots.process_results(|source_roots| source_roots.collect_vec())?; + let mut source_roots = source_root_groups.into_iter().flatten().collect_vec(); + source_roots + .sort_by_key(|source_root| std::cmp::Reverse(source_root.path.components().count())); - Ok(files.sorted().collect_vec()) + let source_globs = metadata.keys().cloned().collect_vec(); + Ok(DiscoveredWorkspace { source_globs, packages: package_inputs, metadata, source_roots }) +} + +fn package_source_roots( + workspace_root: &std::path::Path, + package: &spago::PackageSources, +) -> io::Result> { + let editable = matches!( + package.reference, + spago::PackageReference::Workspace | spago::PackageReference::Local + ); + let metadata = SourceMetadata::Package { editable }; + + let mut roots = vec![]; + for root in &package.roots { + let root = workspace_root.join(root).absolutize()?.to_path_buf(); + roots.push(SourceRoot { + path: PathBuf::clone(&root), + metadata: SourceMetadata::clone(&metadata), + }); + + if let Ok(canonical) = dunce::canonicalize(&root) + && canonical != root + { + roots.push(SourceRoot { path: canonical, metadata: SourceMetadata::clone(&metadata) }); + } + } + + Ok(roots) } fn apply_configuration( state: &mut State, configuration: Arc, ) -> Result<(), LspError> { - if state.workspace_loaded && configuration.sources == state.config.sources { + if state.workspace_loaded() && configuration.sources == state.config.sources { state.config = configuration; return Ok(()); } + let root = state.root.as_ref().ok_or(LspError::MissingRoot)?; - let files = match &configuration.sources { + let discovered = match &configuration.sources { SourceDiscovery::Spago {} => discover_spago(root)?, SourceDiscovery::Command { program, arguments } => { discover_manual(root, program, arguments)? } }; - let mut prepared = BTreeMap::new(); - for (path, editable) in files { - let content = fs::read_to_string(&path)?; - prepared.insert(path, (Arc::::from(content), editable)); + + if state.workspace_loaded() { + let mut files = BTreeMap::new(); + for path in &discovered.source_globs { + let content = Arc::from(fs::read_to_string(path)?); + let metadata = discovered + .metadata + .get(path) + .cloned() + .expect("invariant violated: discovered source has no LSP metadata"); + files.insert(PathBuf::clone(path), (content, metadata)); + } + + reconcile_files(state, &files)?; + let workspace = match &mut state.workspace { + WorkspaceState::WaitingForConfiguration { .. } => { + return Err(LspError::WorkspaceNotReady); + } + WorkspaceState::Ready { workspace } => workspace, + }; + workspace.source_roots = discovered.source_roots; + state.config = configuration; + return Ok(()); } - let refresh_diagnostics = state.workspace_loaded; - reconcile_files(state, &prepared, refresh_diagnostics)?; + let selected_sources = discovered.source_globs.iter().map(source_uri); + let selected_sources = selected_sources.collect::, _>>()?; + + let initial = build_initial::(InitialBuildConfig { + root, + source_globs: &discovered.source_globs, + excluded: &[], + packages: discovered.packages, + prim_metadata: SourceMetadata::Builtin, + source_metadata: |path: &std::path::Path| { + discovered + .metadata + .get(path) + .cloned() + .expect("invariant violated: discovered source has no LSP metadata") + }, + execution: PackageExecution::Parallel, + events: &SilentBuildEvents, + })?; + + let pending = state.install_compilation( + initial.into_compilation(), + discovered.source_roots, + selected_sources, + ); state.config = configuration; - state.workspace_loaded = true; + + replay_pending_notifications(state, pending); + tracing::info!("Loaded {} files.", discovered.source_globs.len()); Ok(()) } +fn source_uri(path: &PathBuf) -> Result, LspError> { + let uri = + Url::from_file_path(path).map_err(|_| LspError::PathParseFail(PathBuf::clone(path)))?; + Ok(Arc::from(uri.as_str())) +} + fn reconcile_files( state: &mut State, - files: &BTreeMap, bool)>, - refresh_diagnostics: bool, + files: &BTreeMap, SourceMetadata)>, ) -> Result<(), LspError> { tracing::info!("Loading {} files.", files.len()); - let mut selected_sources = FxHashSet::default(); - for path in files.keys() { - let uri = - Url::from_file_path(path).map_err(|_| LspError::PathParseFail(PathBuf::clone(path)))?; - selected_sources.insert(Arc::from(uri.as_str())); - } - + let selected_sources = files.keys().map(source_uri); + let selected_sources = selected_sources.collect::, _>>()?; + let previous_sources = FxHashSet::clone(&state.workspace()?.selected_sources); + let removed_sources = previous_sources.difference(&selected_sources).cloned().collect_vec(); let mut lifecycle_change = LifecycleChange::default(); - for source in state.selected_sources.difference(&selected_sources).cloned().collect_vec() { + for source in removed_sources { let uri = Url::parse(&source)?; let unit = source_unit_from_source_uri(&uri)?; let event = LifecycleEvent::Source { unit: SourceUnitKey::clone(&unit), - event: SourceEvent::DiskObserved { disk: DiskObservation::NotFound, metadata: false }, + event: SourceEvent::DiskObserved { + disk: DiskObservation::NotFound, + metadata: SourceMetadata::Unmanaged { editable: false }, + }, }; lifecycle_change.combine(apply_lifecycle_event(state, event)); for kind in ForeignSourceKind::ALL { @@ -599,7 +788,7 @@ fn reconcile_files( lifecycle_change.combine(apply_lifecycle_event(state, event)); } } - for (file, (content, editable)) in files { + for (file, (content, metadata)) in files { let uri = Url::from_file_path(file).map_err(|_| LspError::PathParseFail(PathBuf::clone(file)))?; let unit = source_unit_from_source_uri(&uri)?; @@ -607,35 +796,37 @@ fn reconcile_files( unit: SourceUnitKey::clone(&unit), event: SourceEvent::DiskObserved { disk: DiskObservation::Found(Arc::clone(content)), - metadata: *editable, + metadata: SourceMetadata::clone(metadata), }, }; lifecycle_change.combine(apply_lifecycle_event(state, event)); lifecycle_change.combine(observe_sibling_foreign(state, &unit)?); } finish_lifecycle_change(state, &lifecycle_change)?; - if refresh_diagnostics { - emit_diagnostics_for_change(state, &lifecycle_change)?; - } + emit_diagnostics_for_change(state, &lifecycle_change)?; - state.excluded_sources.extend(state.selected_sources.difference(&selected_sources).cloned()); + let workspace = match &mut state.workspace { + WorkspaceState::WaitingForConfiguration { .. } => { + return Err(LspError::WorkspaceNotReady); + } + WorkspaceState::Ready { workspace } => workspace, + }; + workspace.excluded_sources.extend(previous_sources.difference(&selected_sources).cloned()); for selected in &selected_sources { - state.excluded_sources.remove(selected); + workspace.excluded_sources.remove(selected); } - state.selected_sources = selected_sources; - + workspace.selected_sources = selected_sources; tracing::info!("Loaded {} files.", files.len()); - Ok(()) } fn definition( snapshot: StateSnapshot, - p: GotoDefinitionParams, + parameters: GotoDefinitionParams, ) -> Result, LspError> { let _span = tracing::info_span!("definition").entered(); - let uri = p.text_document_position_params.text_document.uri; - let position = p.text_document_position_params.position; + let uri = parameters.text_document_position_params.text_document.uri; + let position = parameters.text_document_position_params.position; let result = snapshot.with_analyzer_context(|context| { analyzer::definition::implementation(context, uri, position) @@ -644,10 +835,10 @@ fn definition( result.on_non_fatal(None) } -fn hover(snapshot: StateSnapshot, p: HoverParams) -> Result, LspError> { +fn hover(snapshot: StateSnapshot, parameters: HoverParams) -> Result, LspError> { let _span = tracing::info_span!("hover").entered(); - let uri = p.text_document_position_params.text_document.uri; - let position = p.text_document_position_params.position; + let uri = parameters.text_document_position_params.text_document.uri; + let position = parameters.text_document_position_params.position; let result = snapshot .with_analyzer_context(|context| analyzer::hover::implementation(context, uri, position)); @@ -657,12 +848,12 @@ fn hover(snapshot: StateSnapshot, p: HoverParams) -> Result, LspEr fn code_action( snapshot: StateSnapshot, - p: CodeActionParams, + parameters: CodeActionParams, ) -> Result, LspError> { let _span = tracing::info_span!("code_action").entered(); - let uri = p.text_document.uri; - let range = p.range; - let action_context = p.context; + let uri = parameters.text_document.uri; + let range = parameters.range; + let action_context = parameters.context; let result = snapshot.with_analyzer_context(|context| { analyzer::code_action::implementation(context, uri, range, action_context) @@ -673,11 +864,11 @@ fn code_action( fn completion( snapshot: StateSnapshot, - p: CompletionParams, + parameters: CompletionParams, ) -> Result, LspError> { let _span = tracing::info_span!("completion").entered(); - let uri = p.text_document_position.text_document.uri; - let position = p.text_document_position.position; + let uri = parameters.text_document_position.text_document.uri; + let position = parameters.text_document_position.position; let mut cache = snapshot.suggestions_cache.write(); @@ -699,11 +890,11 @@ fn resolve_completion_item( fn references( snapshot: StateSnapshot, - p: ReferenceParams, + parameters: ReferenceParams, ) -> Result>, LspError> { let _span = tracing::info_span!("references").entered(); - let uri = p.text_document_position.text_document.uri; - let position = p.text_document_position.position; + let uri = parameters.text_document_position.text_document.uri; + let position = parameters.text_document_position.position; let result = snapshot.with_analyzer_context(|context| { analyzer::references::implementation(context, uri, position) @@ -712,11 +903,14 @@ fn references( result.on_non_fatal(None) } -fn rename(snapshot: StateSnapshot, p: RenameParams) -> Result, LspError> { +fn rename( + snapshot: StateSnapshot, + parameters: RenameParams, +) -> Result, LspError> { let _span = tracing::info_span!("rename").entered(); - let uri = p.text_document_position.text_document.uri; - let position = p.text_document_position.position; - let new_name = p.new_name; + let uri = parameters.text_document_position.text_document.uri; + let position = parameters.text_document_position.position; + let new_name = parameters.new_name; let result = snapshot.with_analyzer_context(|context| { analyzer::rename::implementation(context, uri, position, new_name) @@ -727,11 +921,11 @@ fn rename(snapshot: StateSnapshot, p: RenameParams) -> Result Result, LspError> { let _span = tracing::info_span!("prepare_rename").entered(); - let uri = p.text_document.uri; - let position = p.position; + let uri = parameters.text_document.uri; + let position = parameters.position; let result = snapshot.with_analyzer_context(|context| analyzer::rename::prepare(context, uri, position)); @@ -741,11 +935,11 @@ fn prepare_rename( fn document_highlight( snapshot: StateSnapshot, - p: DocumentHighlightParams, + parameters: DocumentHighlightParams, ) -> Result>, LspError> { let _span = tracing::info_span!("document_highlight").entered(); - let uri = p.text_document_position_params.text_document.uri; - let position = p.text_document_position_params.position; + let uri = parameters.text_document_position_params.text_document.uri; + let position = parameters.text_document_position_params.position; let result = snapshot.with_analyzer_context(|context| { analyzer::document_highlight::implementation(context, uri, position) }); @@ -755,14 +949,14 @@ fn document_highlight( fn workspace_symbols( snapshot: StateSnapshot, - p: WorkspaceSymbolParams, + parameters: WorkspaceSymbolParams, ) -> Result, LspError> { let _span = tracing::info_span!("workspace_symbols").entered(); let mut cache = snapshot.workspace_symbols_cache.write(); let result = snapshot.with_analyzer_context(|context| { - analyzer::symbols::workspace(context, &mut cache, &p.query) + analyzer::symbols::workspace(context, &mut cache, ¶meters.query) }); result.on_non_fatal(None) @@ -770,10 +964,10 @@ fn workspace_symbols( fn document_symbols( snapshot: StateSnapshot, - p: DocumentSymbolParams, + parameters: DocumentSymbolParams, ) -> Result, LspError> { let _span = tracing::info_span!("document_symbols").entered(); - let uri = p.text_document.uri; + let uri = parameters.text_document.uri; let result = snapshot.with_analyzer_context(|context| analyzer::symbols::document(context, uri)); @@ -782,10 +976,10 @@ fn document_symbols( fn semantic_tokens( snapshot: StateSnapshot, - p: SemanticTokensParams, + parameters: SemanticTokensParams, ) -> Result, LspError> { let _span = tracing::info_span!("semantic_tokens").entered(); - let uri = p.text_document.uri; + let uri = parameters.text_document.uri; let result = snapshot.with_analyzer_context(|context| { analyzer::semantic_tokens::implementation(context, uri) .map(|tokens| tokens.map(SemanticTokensResult::Tokens)) @@ -799,20 +993,20 @@ fn document_content( document: DocumentKind, uri: &Url, ) -> Result, LspError> { - let files = state.files.read(); + let files = state.files().read(); match document { DocumentKind::Source => { let file_id = files .source_id(uri.as_str()) .ok_or_else(|| LspError::InvalidContentChange(Url::clone(uri)))?; - state.engine.content(file_id).map_err(LspError::from) + state.engine().content(file_id).map_err(LspError::from) } DocumentKind::Foreign(_) => { let file_id = files .foreign_id(uri.as_str()) .ok_or_else(|| LspError::InvalidContentChange(Url::clone(uri)))?; state - .engine + .engine() .foreign_content(file_id) .ok_or_else(|| LspError::InvalidContentChange(Url::clone(uri))) } @@ -828,7 +1022,7 @@ fn apply_content_changes( let mut content = content.to_string(); for content_change in content_changes { let Some(range) = content_change.range else { - content = content_change.text.clone(); + content = String::clone(&content_change.text); continue; }; @@ -857,38 +1051,44 @@ fn apply_content_changes( Ok(Arc::from(content)) } -fn did_change(state: &mut State, p: DidChangeTextDocumentParams) -> Result<(), LspError> { - let uri = &p.text_document.uri; - if p.content_changes.is_empty() { +fn did_change(state: &mut State, parameters: DidChangeTextDocumentParams) -> Result<(), LspError> { + let uri = ¶meters.text_document.uri; + if parameters.content_changes.is_empty() { return Ok(()); } let (document, unit) = source_unit_from_document_uri(uri)?; let content = document_content(state, document, uri)?; let content = - apply_content_changes(uri, &content, &p.content_changes, state.position_encoding)?; + apply_content_changes(uri, &content, ¶meters.content_changes, state.position_encoding)?; let event = match document { DocumentKind::Foreign(kind) => LifecycleEvent::Foreign { unit, kind, - event: ForeignEvent::Changed { text: content, version: p.text_document.version }, + event: ForeignEvent::Changed { + text: content, + version: parameters.text_document.version, + }, }, DocumentKind::Source => LifecycleEvent::Source { unit, - event: SourceEvent::Changed { text: content, version: p.text_document.version }, + event: SourceEvent::Changed { + text: content, + version: parameters.text_document.version, + }, }, }; let change = apply_lifecycle_event(state, event); finish_lifecycle_change(state, &change)?; if state.config.diagnostics.on_change { - emit_associated_diagnostics(state, Url::clone(&p.text_document.uri))?; + emit_associated_diagnostics(state, Url::clone(¶meters.text_document.uri))?; } Ok(()) } -fn did_open(state: &mut State, p: DidOpenTextDocumentParams) -> Result<(), LspError> { - let uri = &p.text_document.uri; +fn did_open(state: &mut State, parameters: DidOpenTextDocumentParams) -> Result<(), LspError> { + let uri = ¶meters.text_document.uri; let (document, unit) = source_unit_from_document_uri(uri)?; let change = match document { @@ -897,20 +1097,20 @@ fn did_open(state: &mut State, p: DidOpenTextDocumentParams) -> Result<(), LspEr unit, kind, event: ForeignEvent::Opened { - text: Arc::from(p.text_document.text.as_str()), - version: p.text_document.version, + text: Arc::from(parameters.text_document.text.as_str()), + version: parameters.text_document.version, }, }; apply_lifecycle_event(state, event) } DocumentKind::Source => { - let editable = source_editable(state, &unit, uri); + let metadata = source_metadata(state, &unit, uri); let event = LifecycleEvent::Source { unit: SourceUnitKey::clone(&unit), event: SourceEvent::Opened { - text: Arc::from(p.text_document.text.as_str()), - version: p.text_document.version, - metadata: editable, + text: Arc::from(parameters.text_document.text.as_str()), + version: parameters.text_document.version, + metadata, }, }; let mut change = apply_lifecycle_event(state, event); @@ -921,21 +1121,18 @@ fn did_open(state: &mut State, p: DidOpenTextDocumentParams) -> Result<(), LspEr finish_lifecycle_change(state, &change)?; if state.config.diagnostics.on_open { - emit_associated_diagnostics(state, p.text_document.uri)?; + emit_associated_diagnostics(state, parameters.text_document.uri)?; } Ok(()) } -fn did_close(state: &mut State, p: DidCloseTextDocumentParams) -> Result<(), LspError> { - let uri = p.text_document.uri; +fn did_close(state: &mut State, parameters: DidCloseTextDocumentParams) -> Result<(), LspError> { + let uri = parameters.text_document.uri; let (document, unit) = source_unit_from_document_uri(&uri)?; let source_uri = Arc::::from(unit.source()); - let disk = if state.excluded_sources.contains(&source_uri) { - DiskObservation::NotFound - } else { - observe_disk(&uri) - }; + let excluded = state.workspace()?.excluded_sources.contains(&source_uri); + let disk = if excluded { DiskObservation::NotFound } else { observe_disk(&uri) }; let change = match document { DocumentKind::Foreign(kind) => { let event = @@ -944,7 +1141,7 @@ fn did_close(state: &mut State, p: DidCloseTextDocumentParams) -> Result<(), Lsp } DocumentKind::Source => { let document = DocumentKey::Source(SourceUnitKey::clone(&unit)); - let was_open = state.files.read().is_open(&document); + let was_open = state.files().read().is_open(&document); let event = LifecycleEvent::Source { unit: SourceUnitKey::clone(&unit), event: SourceEvent::Closed { disk }, @@ -961,33 +1158,33 @@ fn did_close(state: &mut State, p: DidCloseTextDocumentParams) -> Result<(), Lsp Ok(()) } -fn did_save(state: &mut State, p: DidSaveTextDocumentParams) -> Result<(), LspError> { +fn did_save(state: &mut State, parameters: DidSaveTextDocumentParams) -> Result<(), LspError> { state.invalidate_suggestions_cache(); if state.config.diagnostics.on_save { - emit_associated_diagnostics(state, p.text_document.uri)?; + emit_associated_diagnostics(state, parameters.text_document.uri)?; } Ok(()) } fn did_change_watched_files( state: &mut State, - p: DidChangeWatchedFilesParams, + parameters: DidChangeWatchedFilesParams, ) -> Result<(), LspError> { let mut source_units = FxHashSet::default(); let mut foreign_units = FxHashSet::default(); - for change in p.changes { + for change in parameters.changes { match document_kind(&change.uri) { Some(DocumentKind::Foreign(kind)) => { let unit = source_unit_from_foreign_uri(&change.uri)?; - if state.excluded_sources.contains(unit.source()) { + if state.workspace()?.excluded_sources.contains(unit.source()) { continue; } foreign_units.insert((unit, kind)); } Some(DocumentKind::Source) => { let unit = source_unit_from_source_uri(&change.uri)?; - if state.excluded_sources.contains(unit.source()) { + if state.workspace()?.excluded_sources.contains(unit.source()) { continue; } source_units.insert(unit); @@ -1000,13 +1197,16 @@ fn did_change_watched_files( let mut observed_foreign = FxHashSet::default(); for unit in source_units { let document = DocumentKey::Source(SourceUnitKey::clone(&unit)); - if state.files.read().is_open(&document) { + if state.files().read().is_open(&document) { continue; } let uri = Url::parse(unit.source())?; + if !source_editable(state, &unit, &uri) { + continue; + } let disk = observe_disk(&uri); let source_found = matches!(disk, DiskObservation::Found(_)); - let metadata = source_editable(state, &unit, &uri); + let metadata = source_metadata(state, &unit, &uri); let event = LifecycleEvent::Source { unit: SourceUnitKey::clone(&unit), event: SourceEvent::DiskObserved { disk, metadata }, @@ -1023,11 +1223,15 @@ fn did_change_watched_files( continue; } let document = DocumentKey::Foreign(SourceUnitKey::clone(&unit), kind); - if state.files.read().is_open(&document) { + if state.files().read().is_open(&document) { + continue; + } + let source_uri = Url::parse(unit.source())?; + if !source_editable(state, &unit, &source_uri) { continue; } let tracked = { - let files = state.files.read(); + let files = state.files().read(); files.source_id(unit.source()).is_some() || files.foreign_id(unit.foreign_for(kind)).is_some() }; @@ -1109,15 +1313,19 @@ fn emit_associated_diagnostics(state: &mut State, uri: Url) -> Result<(), LspErr event::emit_collect_diagnostics(state, Url::parse(unit.source())?) } -fn apply_lifecycle_event(state: &mut State, event: LifecycleEvent) -> LifecycleChange { +fn apply_lifecycle_event( + state: &mut State, + event: LifecycleEvent, +) -> LifecycleChange { // Cancel in-flight queries so that threads holding a read lock over the // lifecycle finish before this write waits for expensive LSP requests. - state.engine.request_cancel(); - state.files.write().apply(&state.engine, event) + state.engine().request_cancel(); + state.files().write().apply(state.engine(), event) } fn finish_lifecycle_change(state: &mut State, change: &LifecycleChange) -> Result<(), LspError> { - state.diagnostics.invalidate(change, &state.files.read()); + let files = Arc::clone(state.files()); + state.diagnostics.invalidate(change, &files.read()); if !matches!(change.analysis(), AnalysisInvalidation::None) { state.invalidate_workspace_symbols(); state.invalidate_suggestions_cache(); @@ -1128,7 +1336,7 @@ fn finish_lifecycle_change(state: &mut State, change: &LifecycleChange) -> Resul for removed in change.removed_sources() { state.client.publish_diagnostics(PublishDiagnosticsParams { uri: Url::parse(&removed.locator)?, - diagnostics: Vec::new(), + diagnostics: vec![], version: None, })?; } @@ -1142,7 +1350,7 @@ fn observe_sibling_foreign( let mut change = LifecycleChange::default(); for kind in ForeignSourceKind::ALL { let document = DocumentKey::Foreign(SourceUnitKey::clone(unit), kind); - if state.files.read().is_open(&document) { + if state.files().read().is_open(&document) { continue; } let uri = Url::parse(unit.foreign_for(kind))?; @@ -1168,19 +1376,37 @@ fn observe_disk(uri: &Url) -> DiskObservation { } } -fn source_editable(state: &State, unit: &SourceUnitKey, uri: &Url) -> bool { +fn source_metadata(state: &State, unit: &SourceUnitKey, uri: &Url) -> SourceMetadata { let previous = { - let files = state.files.read(); + let files = state.files().read(); let file_id = files.source_id(unit.source()); - file_id.and_then(|file_id| files.source_metadata(file_id)).copied() + file_id.and_then(|file_id| files.source_metadata(file_id)).cloned() }; - previous.unwrap_or_else(|| match (&state.root, uri.to_file_path()) { - (Some(root), Ok(path)) => path.starts_with(root), - (Some(_), Err(())) => false, - (None, _) => true, + previous.unwrap_or_else(|| { + let path = uri.to_file_path().ok(); + let package_metadata = path.as_ref().and_then(|path| { + state + .workspace() + .ok()? + .source_roots + .iter() + .find(|source_root| path.starts_with(&source_root.path)) + .map(|source_root| SourceMetadata::clone(&source_root.metadata)) + }); + package_metadata.unwrap_or_else(|| match (&state.root, path) { + (Some(root), Some(path)) => { + SourceMetadata::Unmanaged { editable: path.starts_with(root) } + } + (Some(_), None) => SourceMetadata::Unmanaged { editable: false }, + (None, _) => SourceMetadata::Unmanaged { editable: true }, + }) }) } +fn source_editable(state: &State, unit: &SourceUnitKey, uri: &Url) -> bool { + source_metadata(state, unit, uri).editable() +} + fn emit_diagnostics_for_change( state: &mut State, change: &LifecycleChange, @@ -1197,6 +1423,23 @@ fn emit_diagnostics_for_change( } } +fn replay_pending_notifications(state: &mut State, pending: Vec) { + for notification in pending { + let result = match notification { + PendingNotification::DidOpen(parameters) => did_open(state, parameters), + PendingNotification::DidSave(parameters) => did_save(state, parameters), + PendingNotification::DidClose(parameters) => did_close(state, parameters), + PendingNotification::DidChange(parameters) => did_change(state, parameters), + PendingNotification::DidChangeWatchedFiles(parameters) => { + did_change_watched_files(state, parameters) + } + }; + if let Err(error) = result { + error.emit_trace(); + } + } +} + trait RequestExtension: BorrowMut> { fn request_snapshot( &mut self, @@ -1205,12 +1448,11 @@ trait RequestExtension: BorrowMut> { self.borrow_mut().request::(move |state, parameters| { let task = state.spawn(move |snapshot| action(snapshot, parameters)); async move { - task.await.map_err(LspError::JoinError).flatten().map_err(|error| { - error.emit_trace(); - let code = error.code(); - let message = error.message(); - ResponseError::new(code, message) - }) + let task = task.map_err(response_error)?; + task.await + .map_err(LspError::JoinError) + .flatten() + .map_err(|error| response_error(error)) } }); self @@ -1227,6 +1469,26 @@ trait RequestExtension: BorrowMut> { }); self } + + fn workspace_notification( + &mut self, + pending: fn(N::Params) -> PendingNotification, + action: impl Fn(&mut State, N::Params) -> Result<(), LspError> + Send + Copy + 'static, + ) -> &mut Self { + let this: &mut Router = self.borrow_mut(); + this.notification::(move |state, parameters| { + let result = match &mut state.workspace { + WorkspaceState::WaitingForConfiguration { pending: notifications } => { + notifications.push(pending(parameters)); + Ok(()) + } + WorkspaceState::Ready { .. } => action(state, parameters), + }; + let _ = result.inspect_err(|error| error.emit_trace()); + ControlFlow::Continue(()) + }); + self + } fn event_ext( &mut self, action: impl Fn(&mut State, E) -> Result<(), LspError> + Send + Copy + 'static, @@ -1245,11 +1507,22 @@ trait RequestExtension: BorrowMut> { impl RequestExtension for Router {} -pub async fn async_start(config: Arc) { +fn response_error(error: LspError) -> ResponseError { + error.emit_trace(); + ResponseError::new(error.code(), error.message()) +} + +pub(crate) async fn async_start(config: ServerConfig) -> Result<(), ServerError> { + let ServerConfig { configuration, name, version } = config; + let config = Arc::new(configuration); let (server, _) = async_lsp::MainLoop::new_server(move |client| { let client_socket = ClientSocket::clone(&client); - let mut router: Router = - Router::new(State::new(config, client_socket)); + let mut router: Router = Router::new(State::new( + Arc::clone(&config), + client_socket, + String::clone(&name), + String::clone(&version), + )); router .request::(initialize) @@ -1268,12 +1541,27 @@ pub async fn async_start(config: Arc) { .request_snapshot::(semantic_tokens) .notification_ext::(initialized) .notification_ext::(exit) - .notification_ext::(did_open) - .notification_ext::(did_save) - .notification_ext::(did_close) + .workspace_notification::( + PendingNotification::DidOpen, + did_open, + ) + .workspace_notification::( + PendingNotification::DidSave, + did_save, + ) + .workspace_notification::( + PendingNotification::DidClose, + did_close, + ) .notification_ext::(did_change_configuration) - .notification_ext::(did_change) - .notification_ext::(did_change_watched_files) + .workspace_notification::( + PendingNotification::DidChange, + did_change, + ) + .workspace_notification::( + PendingNotification::DidChangeWatchedFiles, + did_change_watched_files, + ) .event_ext::(event::collect_diagnostics) .event_ext::(event::finish_diagnostics) .event_ext::(finish_workspace_configuration); @@ -1288,8 +1576,8 @@ pub async fn async_start(config: Arc) { #[cfg(unix)] let (stdin, stdout) = ( - async_lsp::stdio::PipeStdin::lock_tokio().unwrap(), - async_lsp::stdio::PipeStdout::lock_tokio().unwrap(), + async_lsp::stdio::PipeStdin::lock_tokio().map_err(ServerError::new)?, + async_lsp::stdio::PipeStdout::lock_tokio().map_err(ServerError::new)?, ); #[cfg(not(unix))] @@ -1298,14 +1586,5 @@ pub async fn async_start(config: Arc) { tokio_util::compat::TokioAsyncWriteCompatExt::compat_write(tokio::io::stdout()), ); - if let Err(error) = server.run_buffered(stdin, stdout).await { - tracing::error!(?error, "LSP main loop exited"); - process::exit(1); - } -} - -#[tokio::main(flavor = "current_thread")] -pub async fn start(config: Configuration) { - let config = Arc::new(config); - async_start(Arc::clone(&config)).await + server.run_buffered(stdin, stdout).await.map_err(ServerError::new) } diff --git a/compiler-bin/src/lsp/capabilities.rs b/compiler-bin/iris-lsp/src/server/capabilities.rs similarity index 77% rename from compiler-bin/src/lsp/capabilities.rs rename to compiler-bin/iris-lsp/src/server/capabilities.rs index f43b12c67..9f7a71b96 100644 --- a/compiler-bin/src/lsp/capabilities.rs +++ b/compiler-bin/iris-lsp/src/server/capabilities.rs @@ -9,9 +9,9 @@ pub struct ConfigurationCapabilities { } pub fn negotiate_configuration_capabilities( - params: &InitializeParams, + parameters: &InitializeParams, ) -> ConfigurationCapabilities { - let Some(workspace) = ¶ms.capabilities.workspace else { + let Some(workspace) = ¶meters.capabilities.workspace else { return ConfigurationCapabilities::default(); }; let workspace_configuration = workspace.configuration == Some(true); @@ -23,13 +23,13 @@ pub fn negotiate_configuration_capabilities( ConfigurationCapabilities { workspace_configuration, dynamic_registration } } -pub fn negotiate_analyzer_capabilities(params: &InitializeParams) -> AnalyzerCapabilities { - let workspace_edit = params +pub fn negotiate_analyzer_capabilities(parameters: &InitializeParams) -> AnalyzerCapabilities { + let workspace_edit = parameters .capabilities .workspace .as_ref() .and_then(|workspace| workspace.workspace_edit.as_ref()); - let honors_rename_annotations = params + let honors_rename_annotations = parameters .capabilities .text_document .as_ref() @@ -48,8 +48,8 @@ pub fn negotiate_analyzer_capabilities(params: &InitializeParams) -> AnalyzerCap } } -pub fn negotiate_position_encoding(params: &InitializeParams) -> PositionEncoding { - let Some(encodings) = params +pub fn negotiate_position_encoding(parameters: &InitializeParams) -> PositionEncoding { + let Some(encodings) = parameters .capabilities .general .as_ref() @@ -78,7 +78,9 @@ mod tests { use super::*; - fn params(position_encodings: Option>) -> InitializeParams { + fn initialize_parameters( + position_encodings: Option>, + ) -> InitializeParams { InitializeParams { capabilities: ClientCapabilities { general: Some(GeneralClientCapabilities { @@ -93,43 +95,46 @@ mod tests { #[test] fn defaults_to_utf16_without_client_preference() { - let params = InitializeParams::default(); + let parameters = InitializeParams::default(); - let encoding = negotiate_position_encoding(¶ms); + let encoding = negotiate_position_encoding(¶meters); assert_eq!(encoding, PositionEncoding::Utf16); } #[test] fn prefers_utf8_when_available() { - let params = params(Some(vec![ + let parameters = initialize_parameters(Some(vec![ PositionEncodingKind::UTF32, PositionEncodingKind::UTF16, PositionEncodingKind::UTF8, ])); - let encoding = negotiate_position_encoding(¶ms); + let encoding = negotiate_position_encoding(¶meters); assert_eq!(encoding, PositionEncoding::Utf8); } #[test] fn falls_back_to_utf16_before_utf32() { - let params = params(Some(vec![PositionEncodingKind::UTF32, PositionEncodingKind::UTF16])); + let parameters = initialize_parameters(Some(vec![ + PositionEncodingKind::UTF32, + PositionEncodingKind::UTF16, + ])); - let encoding = negotiate_position_encoding(¶ms); + let encoding = negotiate_position_encoding(¶meters); assert_eq!(encoding, PositionEncoding::Utf16); } #[test] fn supports_utf32_when_it_is_the_only_known_option() { - let params = params(Some(vec![PositionEncodingKind::UTF32])); + let parameters = initialize_parameters(Some(vec![PositionEncodingKind::UTF32])); - let encoding = negotiate_position_encoding(¶ms); + let encoding = negotiate_position_encoding(¶meters); assert_eq!(encoding, PositionEncoding::Utf32); } #[test] fn workspace_configuration_and_registration_are_negotiated_independently() { - let params = InitializeParams { + let parameters = InitializeParams { capabilities: ClientCapabilities { workspace: Some(WorkspaceClientCapabilities { configuration: Some(true), @@ -144,11 +149,11 @@ mod tests { }; assert_eq!( - negotiate_configuration_capabilities(¶ms), + negotiate_configuration_capabilities(¶meters), ConfigurationCapabilities { workspace_configuration: true, dynamic_registration: true } ); - let params = InitializeParams { + let parameters = InitializeParams { capabilities: ClientCapabilities { workspace: Some(WorkspaceClientCapabilities { configuration: Some(false), @@ -163,7 +168,7 @@ mod tests { }; assert_eq!( - negotiate_configuration_capabilities(¶ms), + negotiate_configuration_capabilities(¶meters), ConfigurationCapabilities::default() ); } diff --git a/compiler-bin/src/lsp/error.rs b/compiler-bin/iris-lsp/src/server/error.rs similarity index 84% rename from compiler-bin/src/lsp/error.rs rename to compiler-bin/iris-lsp/src/server/error.rs index ccb035034..16f5aee7b 100644 --- a/compiler-bin/src/lsp/error.rs +++ b/compiler-bin/iris-lsp/src/server/error.rs @@ -4,6 +4,7 @@ use std::{io, process, str}; use analyzer::AnalyzerError; use async_lsp::ErrorCode; use building::QueryError; +use iris_build::compile::CompileError; use lsp_types::Url; use spago::LockfileGlobSetError; use thiserror::Error; @@ -17,6 +18,8 @@ pub enum LspError { AnalyzerError(#[from] AnalyzerError), #[error("QueryError: {0}")] QueryError(#[from] QueryError), + #[error("CompileError: {0}")] + CompileError(#[from] CompileError), #[error("Failed to parse file {0}")] PathParseFail(PathBuf), #[error("Expected a file URI, received {0}")] @@ -29,6 +32,8 @@ pub enum LspError { UrlParseError(#[from] url::ParseError), #[error("Invalid or missing workspace root")] MissingRoot, + #[error("The Iris workspace is not ready")] + WorkspaceNotReady, #[error("SpagoError: {0}")] SpagoLock(#[from] LockfileGlobSetError), #[error("IoError: {0}")] @@ -51,13 +56,16 @@ impl LspError { #[inline] fn as_query_error(&self) -> Option<&QueryError> { match self { - LspError::AnalyzerError(AnalyzerError::QueryError(q)) => Some(q), - LspError::QueryError(q) => Some(q), + LspError::AnalyzerError(AnalyzerError::QueryError(query_error)) => Some(query_error), + LspError::QueryError(query_error) => Some(query_error), _ => None, } } pub fn code(&self) -> ErrorCode { + if matches!(self, LspError::WorkspaceNotReady) { + return ErrorCode::REQUEST_CANCELLED; + } if let Some(QueryError::Cancelled) = self.as_query_error() { return ErrorCode::REQUEST_CANCELLED; } @@ -68,6 +76,9 @@ impl LspError { } pub fn message(&self) -> &str { + if matches!(self, LspError::WorkspaceNotReady) { + return "Workspace is loading"; + } if let Some(QueryError::Cancelled) = self.as_query_error() { return "Request cancelled"; } diff --git a/compiler-bin/src/lsp/event.rs b/compiler-bin/iris-lsp/src/server/event.rs similarity index 90% rename from compiler-bin/src/lsp/event.rs rename to compiler-bin/iris-lsp/src/server/event.rs index ecfe81fe8..75843224b 100644 --- a/compiler-bin/src/lsp/event.rs +++ b/compiler-bin/iris-lsp/src/server/event.rs @@ -4,12 +4,13 @@ use analyzer::diagnostics::CollectedDiagnostics; use async_lsp::{ClientSocket, LanguageClient}; use building::lifecycle::{AnalysisInvalidation, FileLifecycle, LifecycleChange}; use files::FileId; +use itertools::Itertools; use lsp_types::{PublishDiagnosticsParams, Url}; use rustc_hash::FxHashMap; use tokio::task; -use crate::lsp::error::LspError; -use crate::lsp::{State, StateSnapshot}; +use crate::server::error::LspError; +use crate::server::{SourceMetadata, State, StateSnapshot}; #[derive(Default)] pub struct DiagnosticScheduler { @@ -30,7 +31,11 @@ struct DiagnosticTicket { } impl DiagnosticScheduler { - pub fn invalidate(&mut self, change: &LifecycleChange, files: &FileLifecycle) { + pub fn invalidate( + &mut self, + change: &LifecycleChange, + files: &FileLifecycle, + ) { match change.analysis() { AnalysisInvalidation::None => {} AnalysisInvalidation::Sources(sources) => { @@ -40,7 +45,7 @@ impl DiagnosticScheduler { } AnalysisInvalidation::Workspace => { let source_ids = files.source_ids(); - let source_ids = source_ids.collect::>(); + let source_ids = source_ids.collect_vec(); for file_id in source_ids { self.invalidate_source(file_id); } @@ -103,7 +108,7 @@ impl DiagnosticScheduler { } pub fn emit_collect_diagnostics(state: &mut State, uri: Url) -> Result<(), LspError> { - let files = state.files.read(); + let files = state.files().read(); let uri = uri.as_str(); if let Some(file_id) = files.source_id(uri) { @@ -114,17 +119,17 @@ pub fn emit_collect_diagnostics(state: &mut State, uri: Url) -> Result<(), LspEr } pub fn emit_collect_diagnostics_id(state: &mut State, file_id: FileId) -> Result<(), LspError> { - if state.files.read().contains_source(file_id) { + if state.files().read().contains_source(file_id) { state.client.emit(CollectDiagnostics(file_id))?; } Ok(()) } pub fn emit_collect_all_diagnostics(state: &mut State) -> Result<(), LspError> { - let files = state.files.read(); + let files = state.files().read(); let editable_files = files .source_ids() - .filter(|file_id| files.source_metadata(*file_id).copied().unwrap_or(false)); + .filter(|file_id| files.source_metadata(*file_id).is_some_and(SourceMetadata::editable)); for file_id in editable_files { state.client.emit(CollectDiagnostics(file_id))?; } @@ -138,7 +143,7 @@ pub fn collect_diagnostics( CollectDiagnostics(file_id): CollectDiagnostics, ) -> Result<(), LspError> { let version = { - let files = state.files.read(); + let files = state.files().read(); if !files.contains_source(file_id) { return Ok(()); } @@ -155,6 +160,9 @@ fn start_diagnostics(state: &State, ticket: DiagnosticTicket) { let _span = tracing::info_span!("collect_diagnostics").entered(); collect_diagnostics_core(snapshot, ticket) }); + let worker = + worker.expect("invariant violated: diagnostics started before the workspace was ready"); + let client = ClientSocket::clone(&state.client); task::spawn(async move { let collected = await_diagnostics(worker).await; @@ -204,7 +212,7 @@ pub fn finish_diagnostics( ) -> Result<(), LspError> { let running = state.diagnostics.is_running(ticket); let current = running && state.diagnostics.is_current(ticket) && { - let files = state.files.read(); + let files = state.files().read(); files.contains_source(ticket.file_id) && files.source_version(ticket.file_id) == ticket.version }; @@ -235,7 +243,7 @@ mod tests { }; use files::Files; - use super::{DiagnosticScheduler, await_diagnostics}; + use super::{DiagnosticScheduler, SourceMetadata, await_diagnostics}; fn file_id() -> files::FileId { let mut files = Files::default(); @@ -280,7 +288,7 @@ mod tests { unit: first_unit, event: SourceEvent::DiskObserved { disk: DiskObservation::Found(Arc::from("module Main where\n")), - metadata: true, + metadata: SourceMetadata::Unmanaged { editable: true }, }, }; lifecycle.apply(&engine, event); @@ -293,7 +301,7 @@ mod tests { unit: second_unit, event: SourceEvent::DiskObserved { disk: DiskObservation::Found(Arc::from("module Library where\n")), - metadata: true, + metadata: SourceMetadata::Unmanaged { editable: true }, }, }; let change = lifecycle.apply(&engine, event); diff --git a/compiler-bin/src/lsp/extension.rs b/compiler-bin/iris-lsp/src/server/extension.rs similarity index 100% rename from compiler-bin/src/lsp/extension.rs rename to compiler-bin/iris-lsp/src/server/extension.rs diff --git a/compiler-bin/src/lsp/tests.rs b/compiler-bin/iris-lsp/src/server/tests.rs similarity index 81% rename from compiler-bin/src/lsp/tests.rs rename to compiler-bin/iris-lsp/src/server/tests.rs index 6b788f9d6..400f0edf9 100644 --- a/compiler-bin/src/lsp/tests.rs +++ b/compiler-bin/iris-lsp/src/server/tests.rs @@ -10,6 +10,7 @@ use building::lifecycle::{ }; use configuration::{Configuration, Diagnostics}; use files::ForeignSourceKind; +use iris_build::compilation::{CompilationState, MaterializedPrim}; use lsp_types::{ DidCloseTextDocumentParams, Position, Range, TextDocumentContentChangeEvent, TextDocumentIdentifier, Url, @@ -17,8 +18,9 @@ use lsp_types::{ use tempfile::tempdir; use super::{ - State, apply_content_changes, apply_lifecycle_event, did_close, document_kind, observe_disk, - source_unit_from_document_uri, source_unit_from_foreign_uri, source_unit_from_source_uri, + SourceMetadata, State, apply_content_changes, apply_lifecycle_event, did_close, document_kind, + observe_disk, package_source_roots, source_unit_from_document_uri, + source_unit_from_foreign_uri, source_unit_from_source_uri, }; fn test_config() -> Arc { @@ -28,6 +30,15 @@ fn test_config() -> Arc { }) } +fn test_state(config: Arc, client: async_lsp::ClientSocket) -> State { + let mut state = State::new(config, client, "iris-lsp".to_string(), "test".to_string()); + let prim = MaterializedPrim::new().unwrap(); + let compilation = CompilationState::new(prim, SourceMetadata::Builtin); + let pending = state.install_compilation(compilation, vec![], Default::default()); + assert!(pending.is_empty()); + state +} + fn assert_source_close_result( source_uri: Url, foreign_uri: Url, @@ -35,15 +46,14 @@ fn assert_source_close_result( ) { let unit = source_unit_from_source_uri(&source_uri).unwrap(); let config = test_config(); - let (_server, _) = async_lsp::MainLoop::new_server(move |client| { - let mut state = State::new(Arc::clone(&config), client); + let mut state = test_state(Arc::clone(&config), client); let event = LifecycleEvent::Source { unit: SourceUnitKey::clone(&unit), event: SourceEvent::Opened { text: Arc::from("module Main where\n"), version: 1, - metadata: true, + metadata: SourceMetadata::Unmanaged { editable: true }, }, }; apply_lifecycle_event(&mut state, event); @@ -61,7 +71,7 @@ fn assert_source_close_result( }; did_close(&mut state, parameters).unwrap(); - let files = state.files.read(); + let files = state.files().read(); assert_eq!(files.source_authority(&unit), source_authority); assert_eq!(files.foreign_id(foreign_uri.as_str()), None); drop(files); @@ -161,15 +171,14 @@ fn duplicate_source_close_does_not_reconcile_foreign() { let foreign_uri = Url::from_file_path(&foreign_path).unwrap(); let unit = source_unit_from_source_uri(&source_uri).unwrap(); let config = test_config(); - let (_server, _) = async_lsp::MainLoop::new_server(move |client| { - let mut state = State::new(Arc::clone(&config), client); + let mut state = test_state(Arc::clone(&config), client); let event = LifecycleEvent::Source { unit: SourceUnitKey::clone(&unit), event: SourceEvent::Opened { text: Arc::from("module Main where\n"), version: 1, - metadata: true, + metadata: SourceMetadata::Unmanaged { editable: true }, }, }; apply_lifecycle_event(&mut state, event); @@ -188,19 +197,19 @@ fn duplicate_source_close_does_not_reconcile_foreign() { did_close(&mut state, parameters).unwrap(); fs::remove_file(foreign_path).unwrap(); - let source_id = state.files.read().source_id(source_uri.as_str()).unwrap(); - let foreign_id = state.files.read().foreign_id(foreign_uri.as_str()).unwrap(); + let source_id = state.files().read().source_id(source_uri.as_str()).unwrap(); + let foreign_id = state.files().read().foreign_id(foreign_uri.as_str()).unwrap(); let parameters = DidCloseTextDocumentParams { text_document: TextDocumentIdentifier { uri: Url::clone(&source_uri) }, }; did_close(&mut state, parameters).unwrap(); - let files = state.files.read(); + let files = state.files().read(); assert_eq!(files.source_id(source_uri.as_str()), Some(source_id)); assert_eq!(files.foreign_id(foreign_uri.as_str()), Some(foreign_id)); - assert_eq!(state.engine.foreign_file(source_id), Some(foreign_id)); + assert_eq!(state.engine().foreign_file(source_id), Some(foreign_id)); assert_eq!( - state.engine.foreign_content(foreign_id).unwrap().as_ref(), + state.engine().foreign_content(foreign_id).unwrap().as_ref(), "export const life = 42;\n", ); drop(files); @@ -225,6 +234,29 @@ fn disk_observation_distinguishes_content_and_absence() { assert_eq!(observe_disk(&source_uri), DiskObservation::NotFound); } +#[cfg(unix)] +#[test] +fn package_roots_include_canonical_symlink_aliases() { + use std::collections::BTreeSet; + use std::os::unix::fs::symlink; + + let directory = tempdir().unwrap(); + let package_directory = directory.path().join("package"); + let linked_directory = directory.path().join("linked-package"); + fs::create_dir(&package_directory).unwrap(); + symlink(&package_directory, &linked_directory).unwrap(); + let package = spago::PackageSources { + reference: spago::PackageReference::Local, + roots: vec![linked_directory], + sources: vec![], + dependencies: BTreeSet::new(), + }; + + let roots = package_source_roots(directory.path(), &package).unwrap(); + let canonical = dunce::canonicalize(package_directory).unwrap(); + assert!(roots.iter().any(|root| root.path == canonical)); +} + #[test] fn incremental_content_changes_apply_sequentially() { let uri = Url::parse("file:///workspace/Main.purs").unwrap(); diff --git a/compiler-bin/src/walk.rs b/compiler-bin/iris-lsp/src/walk.rs similarity index 97% rename from compiler-bin/src/walk.rs rename to compiler-bin/iris-lsp/src/walk.rs index 687f5bad9..4d270dc7f 100644 --- a/compiler-bin/src/walk.rs +++ b/compiler-bin/iris-lsp/src/walk.rs @@ -7,8 +7,6 @@ use thiserror::Error; use walkdir::WalkDir; pub struct Walk { - pub roots: BTreeSet, - pub globs: GlobSet, pub files: Vec, } @@ -67,7 +65,7 @@ pub fn walk_filtered( files.extend(files_from_glob); - Ok(Walk { roots, globs, files }) + Ok(Walk { files }) } fn build_excludes( @@ -107,6 +105,7 @@ fn glob_syntax_character(character: char) -> bool { #[cfg(test)] mod tests { use super::*; + use itertools::Itertools; use std::fs; use std::time::{SystemTime, UNIX_EPOCH}; @@ -126,7 +125,7 @@ mod tests { let mut files = files .into_iter() .map(|file| file.strip_prefix(root).unwrap().to_string_lossy().replace('\\', "/")) - .collect::>(); + .collect_vec(); files.sort(); files } diff --git a/compiler-bin/iris-package/Cargo.toml b/compiler-bin/iris-package/Cargo.toml new file mode 100644 index 000000000..7d243ab87 --- /dev/null +++ b/compiler-bin/iris-package/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "iris-package" +version = "0.1.0" +edition = "2024" +authors = ["Justin Garcia "] +license = "BSD-3-Clause" +description = "PureScript package management commands for Iris." +repository = "https://github.com/purefunctor/purescript-iris" + +[dependencies] +ignore = "0.4.33" +itertools = "0.15.0" +serde = { version = "1.0.229", features = ["derive"] } +serde_yml = "0.0.12" +spago = { version = "0.1.0", path = "../../compiler-lsp/spago" } +thiserror = "2.0.20" diff --git a/compiler-bin/bundled/project/Main.purs b/compiler-bin/iris-package/bundled/project/Main.purs similarity index 100% rename from compiler-bin/bundled/project/Main.purs rename to compiler-bin/iris-package/bundled/project/Main.purs diff --git a/compiler-bin/bundled/project/Test.Main.purs b/compiler-bin/iris-package/bundled/project/Test.Main.purs similarity index 100% rename from compiler-bin/bundled/project/Test.Main.purs rename to compiler-bin/iris-package/bundled/project/Test.Main.purs diff --git a/compiler-bin/bundled/project/gitignore b/compiler-bin/iris-package/bundled/project/gitignore similarity index 100% rename from compiler-bin/bundled/project/gitignore rename to compiler-bin/iris-package/bundled/project/gitignore diff --git a/compiler-bin/iris-package/src/lib.rs b/compiler-bin/iris-package/src/lib.rs new file mode 100644 index 000000000..5b6e547ae --- /dev/null +++ b/compiler-bin/iris-package/src/lib.rs @@ -0,0 +1,139 @@ +use std::path::{Path, PathBuf}; +use std::{env, fs, io}; + +use itertools::Itertools; +use spago::{SpagoCommand, SpagoError}; +use thiserror::Error; + +use crate::workspace::{Workspace, WorkspaceError}; + +mod workspace; + +const MAIN_SOURCE: &str = include_str!("../bundled/project/Main.purs"); +const TEST_SOURCE: &str = include_str!("../bundled/project/Test.Main.purs"); +const GITIGNORE: &str = include_str!("../bundled/project/gitignore"); + +pub struct NewConfig { + pub name: Option, +} + +pub struct AddConfig { + pub package: Option, + pub dependencies: Vec, + pub test_dependencies: bool, +} + +#[derive(Debug, Error)] +pub enum PackageManagerError { + #[error(transparent)] + Spago(#[from] SpagoError), + #[error(transparent)] + Workspace(#[from] WorkspaceError), + #[error("invalid package name '{0}'; use lowercase letters, digits, and hyphens")] + InvalidPackageName(String), + #[error("cannot create a project because these paths already exist: {0}")] + ExistingPaths(String), + #[error("cannot create a project inside the existing Spago workspace at {0}")] + ExistingWorkspace(PathBuf), + #[error("failed to determine the current directory: {0}")] + CurrentDirectory(io::Error), + #[error("failed to write {path}: {source}")] + Write { + path: PathBuf, + #[source] + source: io::Error, + }, +} + +pub fn create(config: NewConfig) -> Result<(), PackageManagerError> { + let current_directory = env::current_dir().map_err(PackageManagerError::CurrentDirectory)?; + if let Some(root) = Workspace::find_ancestor(¤t_directory)? { + return Err(PackageManagerError::ExistingWorkspace(root)); + } + let name = config.name.unwrap_or_else(|| { + current_directory.file_name().and_then(|name| name.to_str()).unwrap_or("main").to_owned() + }); + validate_package_name(&name)?; + + let targets = [ + current_directory.join("spago.yaml"), + current_directory.join("src/Main.purs"), + current_directory.join("test/Test/Main.purs"), + current_directory.join(".gitignore"), + ]; + let required_directories = [ + current_directory.join("src"), + current_directory.join("test"), + current_directory.join("test/Test"), + ]; + let existing_targets = targets.iter().filter(|path| path.exists()); + let invalid_directories = + required_directories.iter().filter(|path| path.exists() && !path.is_dir()); + let existing = existing_targets.chain(invalid_directories); + let existing = existing.map(|path| path.display().to_string()).collect_vec(); + if !existing.is_empty() { + return Err(PackageManagerError::ExistingPaths(existing.join(", "))); + } + + let manifest = format!( + r#"package: + name: {name} + dependencies: + - console + - effect + - prelude + test: + main: Test.Main + dependencies: + - assert +workspace: {{}} +"# + ); + write_file(&targets[0], &manifest)?; + write_file(&targets[1], MAIN_SOURCE)?; + write_file(&targets[2], TEST_SOURCE)?; + write_file(&targets[3], GITIGNORE) +} + +pub fn add(config: AddConfig) -> Result<(), PackageManagerError> { + let current_directory = env::current_dir().map_err(PackageManagerError::CurrentDirectory)?; + let workspace = Workspace::discover(¤t_directory, config.package.as_deref())?; + let selected = workspace.require_selected()?; + let spago = SpagoCommand::new(¤t_directory)?; + spago + .add(selected, &config.dependencies, config.test_dependencies) + .map_err(PackageManagerError::from) +} + +fn validate_package_name(name: &str) -> Result<(), PackageManagerError> { + let mut characters = name.chars(); + let starts_lowercase = + characters.next().is_some_and(|character| character.is_ascii_lowercase()); + let remaining_valid = characters.all(|character| { + character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-' + }); + if !starts_lowercase || !remaining_valid { + return Err(PackageManagerError::InvalidPackageName(name.to_owned())); + } + Ok(()) +} + +fn write_file(path: &Path, content: &str) -> Result<(), PackageManagerError> { + let parent = path.parent().expect("invariant violated: project file path has no parent"); + fs::create_dir_all(parent) + .map_err(|source| PackageManagerError::Write { path: parent.to_path_buf(), source })?; + fs::write(path, content) + .map_err(|source| PackageManagerError::Write { path: path.to_path_buf(), source }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validates_package_names() { + assert!(validate_package_name("my-project2").is_ok()); + assert!(validate_package_name("MyProject").is_err()); + assert!(validate_package_name("2project").is_err()); + } +} diff --git a/compiler-bin/iris-package/src/workspace.rs b/compiler-bin/iris-package/src/workspace.rs new file mode 100644 index 000000000..a11ad1885 --- /dev/null +++ b/compiler-bin/iris-package/src/workspace.rs @@ -0,0 +1,206 @@ +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::{fs, io}; + +use ignore::WalkBuilder; +use itertools::Itertools; +use serde::Deserialize; +use thiserror::Error; + +const MANIFEST: &str = "spago.yaml"; + +#[derive(Debug, Error)] +pub enum WorkspaceError { + #[error("no Spago workspace found from {0}")] + MissingWorkspace(PathBuf), + #[error("workspace package '{requested}' was not found; available packages: {available}")] + UnknownPackage { requested: String, available: String }, + #[error("workspace contains no packages")] + NoPackages, + #[error("workspace package selection is ambiguous; use --package ")] + AmbiguousPackage, + #[error("duplicate workspace package name '{name}' in {first} and {second}")] + DuplicatePackage { name: String, first: PathBuf, second: PathBuf }, + #[error("failed to read {path}: {source}")] + ReadManifest { + path: PathBuf, + #[source] + source: io::Error, + }, + #[error("failed to canonicalize working directory {path}: {source}")] + CanonicalizeDirectory { + path: PathBuf, + #[source] + source: io::Error, + }, + #[error("failed to parse {path}: {source}")] + ParseManifest { + path: PathBuf, + #[source] + source: serde_yml::Error, + }, + #[error(transparent)] + Walk(#[from] ignore::Error), +} + +#[derive(Deserialize)] +struct Manifest { + workspace: Option, + package: Option, +} + +#[derive(Deserialize)] +struct PackageManifest { + name: String, +} + +pub struct Workspace { + selected: Option, +} + +impl Workspace { + pub fn find_ancestor(current_directory: &Path) -> Result, WorkspaceError> { + for directory in current_directory.ancestors().skip(1) { + let path = directory.join(MANIFEST); + if path.is_file() && read_manifest(&path)?.workspace.is_some() { + return Ok(Some(directory.to_path_buf())); + } + } + Ok(None) + } + + pub fn discover( + current_directory: &Path, + requested_package: Option<&str>, + ) -> Result { + let current_directory = current_directory.canonicalize().map_err(|source| { + WorkspaceError::CanonicalizeDirectory { path: current_directory.to_path_buf(), source } + })?; + let (root, inferred_package) = find_root(¤t_directory)?; + let packages = discover_packages(&root)?; + if packages.is_empty() { + return Err(WorkspaceError::NoPackages); + } + + let selected = if let Some(requested) = requested_package { + if !packages.contains_key(requested) { + let available = packages.keys().cloned().collect_vec().join(", "); + return Err(WorkspaceError::UnknownPackage { + requested: requested.to_owned(), + available, + }); + } + Some(requested.to_owned()) + } else if let Some(inferred) = inferred_package.filter(|name| packages.contains_key(name)) { + Some(inferred) + } else if packages.len() == 1 { + packages.keys().next().cloned() + } else { + None + }; + + Ok(Workspace { selected }) + } + + pub fn require_selected(&self) -> Result<&str, WorkspaceError> { + self.selected.as_deref().ok_or(WorkspaceError::AmbiguousPackage) + } +} + +fn find_root(current_directory: &Path) -> Result<(PathBuf, Option), WorkspaceError> { + let mut inferred_package = None; + for directory in current_directory.ancestors() { + let path = directory.join(MANIFEST); + if !path.is_file() { + continue; + } + let manifest = read_manifest(&path)?; + if manifest.workspace.is_some() { + return Ok((directory.to_path_buf(), inferred_package)); + } + if inferred_package.is_none() + && let Some(package) = manifest.package + { + inferred_package = Some(package.name); + } + } + Err(WorkspaceError::MissingWorkspace(current_directory.to_path_buf())) +} + +fn discover_packages(root: &Path) -> Result, WorkspaceError> { + let mut builder = WalkBuilder::new(root); + builder.hidden(false); + builder.ignore(false); + builder.require_git(false); + builder.git_global(false); + builder.git_exclude(false); + builder.parents(false); + let filter_root = root.to_path_buf(); + builder.filter_entry(move |entry| !excluded_directory(entry.path(), &filter_root)); + let manifests = builder.build().filter_map(manifest_path); + let mut manifests = manifests.process_results(|entries| entries.collect_vec())?; + manifests.sort_by_key(|path| path.components().count()); + + let mut nested_workspaces = vec![]; + let mut packages = BTreeMap::new(); + for path in manifests { + let package_root = + path.parent().expect("invariant violated: spago.yaml path has no parent"); + if package_root != root + && nested_workspaces.iter().any(|nested: &PathBuf| package_root.starts_with(nested)) + { + continue; + } + + let manifest = read_manifest(&path)?; + if package_root != root && manifest.workspace.is_some() { + nested_workspaces.push(package_root.to_path_buf()); + continue; + } + let Some(package) = manifest.package else { + continue; + }; + let name = package.name; + if let Some(first) = packages.insert(String::clone(&name), package_root.to_path_buf()) { + return Err(WorkspaceError::DuplicatePackage { + name, + first, + second: package_root.to_path_buf(), + }); + } + } + Ok(packages) +} + +fn manifest_path( + entry: Result, +) -> Option> { + let entry = match entry { + Ok(entry) => entry, + Err(error) => return Some(Err(error)), + }; + if !entry.file_type().is_some_and(|file_type| file_type.is_file()) { + return None; + } + if entry.file_name() != MANIFEST { + return None; + } + Some(Ok(entry.into_path())) +} + +fn excluded_directory(path: &Path, root: &Path) -> bool { + if path == root { + return false; + } + matches!( + path.file_name().and_then(|name| name.to_str()), + Some(".git" | ".spago" | "node_modules") + ) +} + +fn read_manifest(path: &Path) -> Result { + let source = fs::read_to_string(path) + .map_err(|source| WorkspaceError::ReadManifest { path: path.to_path_buf(), source })?; + serde_yml::from_str(&source) + .map_err(|source| WorkspaceError::ParseManifest { path: path.to_path_buf(), source }) +} diff --git a/compiler-bin/iris-progress/Cargo.toml b/compiler-bin/iris-progress/Cargo.toml new file mode 100644 index 000000000..0b71f7d47 --- /dev/null +++ b/compiler-bin/iris-progress/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "iris-progress" +version = "0.1.0" +edition = "2024" +authors = ["Justin Garcia "] +license = "BSD-3-Clause" +description = "Terminal package progress reporting for Iris." +repository = "https://github.com/purefunctor/purescript-iris" + +[dependencies] +console = "0.16.4" +itertools = "0.15.0" +ratatui = { version = "0.30.2", default-features = false, features = ["crossterm"] } +smol_str = "0.3.6" +terminal-colorsaurus = "1.0.3" diff --git a/compiler-bin/iris-progress/examples/watch.rs b/compiler-bin/iris-progress/examples/watch.rs new file mode 100644 index 000000000..fd3e62c04 --- /dev/null +++ b/compiler-bin/iris-progress/examples/watch.rs @@ -0,0 +1,50 @@ +use std::thread; +use std::time::Duration; + +use iris_progress::{WatchOutcome, WatchSummary, render_watch_summary}; +use itertools::Itertools; + +fn main() { + let examples = [ + ( + "14:32:08", + true, + vec!["Application", "Data.Route", "Effect.Fetch", "Main", "UI.Component"], + Duration::from_millis(847), + WatchOutcome::Succeeded, + ), + ( + "14:32:14", + false, + vec!["UI.Component"], + Duration::from_millis(34), + WatchOutcome::Succeeded, + ), + ( + "14:32:21", + false, + vec!["Application", "Main"], + Duration::from_millis(91), + WatchOutcome::Diagnostics, + ), + ]; + + for (timestamp, initial, inputs, duration, outcome) in examples { + let changed_inputs = inputs.into_iter().map(str::to_owned).collect_vec(); + println!( + "{}", + render_watch_summary( + WatchSummary { + timestamp, + initial, + changed_inputs: &changed_inputs, + duration, + outcome + }, + true, + ) + ); + println!(); + thread::sleep(Duration::from_millis(350)); + } +} diff --git a/compiler-bin/iris-progress/src/lib.rs b/compiler-bin/iris-progress/src/lib.rs new file mode 100644 index 000000000..76784ea17 --- /dev/null +++ b/compiler-bin/iris-progress/src/lib.rs @@ -0,0 +1,1056 @@ +use std::collections::VecDeque; +use std::io::{self, IsTerminal, Write}; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +use console::{measure_text_width, truncate_str}; +use itertools::Itertools; +use ratatui::backend::CrosstermBackend; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Paragraph, Widget}; +use ratatui::{Terminal, TerminalOptions, Viewport}; +use smol_str::SmolStr; +use terminal_colorsaurus::QueryOptions; + +const ANIMATION_INTERVAL: Duration = Duration::from_millis(80); +pub const PACKAGE_HISTORY_LENGTH: usize = 10; +pub const PROGRESS_REGION_WIDTH: usize = 80; +pub const PROGRESS_REGION_HEIGHT: u16 = PACKAGE_HISTORY_LENGTH as u16 + 3; +const WATCH_INPUT_DISPLAY_LIMIT: usize = 4; + +type ProgressTerminal = Terminal>; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ProgressOutcome { + Succeeded, + Diagnostics, + Failed, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum WatchOutcome { + Succeeded, + Diagnostics, + Failed, + Waiting, +} + +pub struct WatchSummary<'a> { + pub timestamp: &'a str, + pub initial: bool, + pub changed_inputs: &'a [String], + pub duration: Duration, + pub outcome: WatchOutcome, +} + +pub fn render_watch_summary(summary: WatchSummary<'_>, color: bool) -> String { + let timestamp = + console::Style::new().cyan().dim().force_styling(color).apply_to(summary.timestamp); + let (status, style) = match (summary.initial, summary.outcome) { + (true, WatchOutcome::Succeeded) => ("Build succeeded", console::Style::new().green()), + (false, WatchOutcome::Succeeded) => ("Rebuild succeeded", console::Style::new().green()), + (true, WatchOutcome::Diagnostics) => { + ("Build completed with diagnostics", console::Style::new().yellow()) + } + (false, WatchOutcome::Diagnostics) => { + ("Rebuild completed with diagnostics", console::Style::new().yellow()) + } + (true, WatchOutcome::Failed) => ("Build failed", console::Style::new().red()), + (false, WatchOutcome::Failed) => ("Rebuild failed", console::Style::new().red()), + (_, WatchOutcome::Waiting) => { + ("No input files; waiting for changes", console::Style::new().cyan()) + } + }; + let status = style.bold().force_styling(color).apply_to(status); + let headline = if summary.outcome == WatchOutcome::Waiting { + format!("{timestamp} {status}") + } else { + let duration = format_watch_duration(summary.duration); + format!("{timestamp} {status} in {duration}") + }; + if summary.changed_inputs.is_empty() { + return headline; + } + + let count = summary.changed_inputs.len(); + let noun = if count == 1 { "input" } else { "inputs" }; + let action = if summary.initial { "Loaded" } else { "Changed" }; + let prefix = format!("{action} {count} {noun}: "); + let available = PROGRESS_REGION_WIDTH.saturating_sub(10 + measure_text_width(&prefix)); + let displayed = render_watch_inputs(summary.changed_inputs, available); + let details = + console::Style::new().dim().force_styling(color).apply_to(format!("{prefix}{displayed}")); + format!("{headline}\n {details}") +} + +fn render_watch_inputs(inputs: &[String], width: usize) -> String { + let sanitized = inputs.iter().map(|input| sanitize(input)); + let sanitized = sanitized.collect_vec(); + let mut displayed_count = inputs.len().min(WATCH_INPUT_DISPLAY_LIMIT); + loop { + let mut displayed = sanitized[..displayed_count].join(", "); + let omitted = inputs.len() - displayed_count; + if omitted > 0 { + if !displayed.is_empty() { + displayed.push_str(", "); + } + displayed.push_str(&format!("… +{omitted} more")); + } + if measure_text_width(&displayed) <= width || displayed_count == 0 { + return truncate_str(&displayed, width, "…").into_owned(); + } + displayed_count -= 1; + } +} + +fn format_watch_duration(duration: Duration) -> String { + if duration >= Duration::from_secs(1) { + format!("{:.2} s", duration.as_secs_f64()) + } else { + format!("{:.2} ms", duration.as_secs_f64() * 1_000.0) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ProgressEvent { + Preparing, + PlanReady { package_count: usize }, + PackageCompleted { package_name: SmolStr, duration: Duration }, + Finalizing { duration: Duration }, + Finished { duration: Duration, outcome: ProgressOutcome }, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CompletedPackage { + pub package_name: SmolStr, + pub duration: Duration, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ProgressPhase { + Preparing, + Compiling, + Finalizing { duration: Duration }, + Finished { duration: Duration, outcome: ProgressOutcome }, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProgressModel { + pub package_count: usize, + pub completed_count: usize, + pub completed_packages: VecDeque, + pub phase: ProgressPhase, +} + +impl Default for ProgressModel { + fn default() -> ProgressModel { + ProgressModel { + package_count: 0, + completed_count: 0, + completed_packages: VecDeque::with_capacity(PACKAGE_HISTORY_LENGTH), + phase: ProgressPhase::Preparing, + } + } +} + +impl ProgressModel { + pub fn apply(&mut self, event: ProgressEvent) { + match event { + ProgressEvent::Preparing => *self = ProgressModel::default(), + ProgressEvent::PlanReady { package_count } => { + self.package_count = package_count; + self.phase = ProgressPhase::Compiling; + } + ProgressEvent::PackageCompleted { package_name, duration } => { + if self.completed_packages.len() == PACKAGE_HISTORY_LENGTH { + self.completed_packages.pop_front(); + } + self.completed_packages.push_back(CompletedPackage { package_name, duration }); + self.completed_count += 1; + } + ProgressEvent::Finalizing { duration } => { + self.phase = ProgressPhase::Finalizing { duration }; + } + ProgressEvent::Finished { duration, outcome } => { + self.phase = ProgressPhase::Finished { duration, outcome }; + } + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ProgressAppearance { + TrueColor { foreground: (u8, u8, u8), background: (u8, u8, u8) }, + Ansi, + Plain, +} + +pub struct ProgressView<'a> { + pub model: &'a ProgressModel, + pub appearance: ProgressAppearance, + pub elapsed: Duration, +} + +impl Widget for ProgressView<'_> { + fn render(self, area: Rect, buffer: &mut Buffer) { + if area.is_empty() { + return; + } + let width = area.width.min(PROGRESS_REGION_WIDTH as u16) as usize; + let area = Rect { width: width as u16, ..area }; + let animation_frame = animation_frame(self.elapsed); + if matches!(self.model.phase, ProgressPhase::Preparing) { + Paragraph::new(render_preparation(width, self.appearance, animation_frame)) + .render(Rect::new(area.x, area.y, area.width, 1), buffer); + return; + } + if area.height < PROGRESS_REGION_HEIGHT { + let bar = render_progress_bar(self.model, width, self.appearance, animation_frame); + let bar_row = area.bottom() - area.height.min(2); + Paragraph::new(bar).render(Rect::new(area.x, bar_row, area.width, 1), buffer); + if area.height > 1 { + Paragraph::new(render_progress_status( + self.model, + width, + self.appearance, + self.elapsed, + )) + .render(Rect::new(area.x, area.bottom() - 1, area.width, 1), buffer); + } + return; + } + + let blank_rows = PACKAGE_HISTORY_LENGTH.saturating_sub(self.model.completed_packages.len()); + for (index, package) in self.model.completed_packages.iter().enumerate() { + let row = blank_rows + index; + let line = Line::styled( + render_completed_package(package, width), + history_style(self.appearance, row), + ); + Paragraph::new(line) + .render(Rect::new(area.x, area.y + row as u16, area.width, 1), buffer); + } + Paragraph::new("─".repeat(width)).render( + Rect::new(area.x, area.y + PACKAGE_HISTORY_LENGTH as u16, area.width, 1), + buffer, + ); + Paragraph::new(render_progress_bar(self.model, width, self.appearance, animation_frame)) + .render( + Rect::new(area.x, area.y + PACKAGE_HISTORY_LENGTH as u16 + 1, area.width, 1), + buffer, + ); + Paragraph::new(render_progress_status(self.model, width, self.appearance, self.elapsed)) + .render( + Rect::new(area.x, area.y + PACKAGE_HISTORY_LENGTH as u16 + 2, area.width, 1), + buffer, + ); + } +} + +enum RuntimeMessage { + Event(ProgressEvent), + Finish { duration: Duration, outcome: ProgressOutcome, acknowledged: Sender<()> }, + Stop, +} + +#[derive(Clone)] +pub struct ProgressReporter { + sender: Option>, +} + +impl ProgressReporter { + pub fn report(&self, event: ProgressEvent) { + match event { + ProgressEvent::Finished { duration, outcome } => self.finish(duration, outcome), + event => { + if let Some(sender) = &self.sender { + let _ = sender.send(RuntimeMessage::Event(event)); + } + } + } + } + + /// Commits the final inline frame and waits until stderr is safe for diagnostics. + fn finish(&self, duration: Duration, outcome: ProgressOutcome) { + let Some(sender) = &self.sender else { return }; + let (acknowledged_sender, acknowledged_receiver) = mpsc::channel(); + let message = + RuntimeMessage::Finish { duration, outcome, acknowledged: acknowledged_sender }; + if sender.send(message).is_ok() { + let _ = acknowledged_receiver.recv(); + } + } + + pub fn is_active(&self) -> bool { + self.sender.is_some() + } +} + +pub struct ProgressRuntime { + reporter: ProgressReporter, + thread: Option>, +} + +impl ProgressRuntime { + pub fn start(show: bool, color: bool) -> ProgressRuntime { + if !show || !io::stderr().is_terminal() { + return ProgressRuntime { reporter: ProgressReporter { sender: None }, thread: None }; + } + let (sender, receiver) = mpsc::channel(); + let thread = thread::spawn(move || run(receiver, color)); + ProgressRuntime { + reporter: ProgressReporter { sender: Some(sender) }, + thread: Some(thread), + } + } + + pub fn reporter(&self) -> ProgressReporter { + ProgressReporter::clone(&self.reporter) + } +} + +impl Drop for ProgressRuntime { + fn drop(&mut self) { + if let Some(sender) = &self.reporter.sender { + let _ = sender.send(RuntimeMessage::Stop); + } + self.reporter.sender = None; + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + +fn run(receiver: Receiver, color: bool) { + let appearance = detect_appearance(color); + let backend = CrosstermBackend::new(io::stderr()); + let options = TerminalOptions { viewport: Viewport::Inline(1) }; + let Ok(mut terminal) = Terminal::with_options(backend, options) else { return }; + let started = Instant::now(); + let mut model = ProgressModel::default(); + let _ = draw(&mut terminal, &model, appearance, started.elapsed()); + + loop { + match receiver.recv_timeout(ANIMATION_INTERVAL) { + Ok(RuntimeMessage::Event(event)) => { + let expand = matches!(event, ProgressEvent::PlanReady { .. }); + model.apply(event); + if expand { + let _ = clear_terminal(&mut terminal); + let backend = CrosstermBackend::new(io::stderr()); + let options = + TerminalOptions { viewport: Viewport::Inline(PROGRESS_REGION_HEIGHT) }; + if let Ok(expanded) = Terminal::with_options(backend, options) { + terminal = expanded; + } + } + let _ = draw(&mut terminal, &model, appearance, started.elapsed()); + } + Ok(RuntimeMessage::Finish { duration, outcome, acknowledged }) => { + model.apply(ProgressEvent::Finished { duration, outcome }); + let _ = draw(&mut terminal, &model, appearance, started.elapsed()); + let _ = finish_terminal(&mut terminal); + drop(terminal); + let _ = acknowledged.send(()); + return; + } + Ok(RuntimeMessage::Stop) | Err(mpsc::RecvTimeoutError::Disconnected) => { + let _ = clear_terminal(&mut terminal); + return; + } + Err(mpsc::RecvTimeoutError::Timeout) => { + let _ = draw(&mut terminal, &model, appearance, started.elapsed()); + } + } + } +} + +fn animation_frame(elapsed: Duration) -> usize { + (elapsed.as_millis() / ANIMATION_INTERVAL.as_millis()) as usize +} + +fn draw( + terminal: &mut ProgressTerminal, + model: &ProgressModel, + appearance: ProgressAppearance, + elapsed: Duration, +) -> io::Result { + let mut viewport = Rect::ZERO; + terminal.draw(|frame| { + viewport = frame.area(); + frame.render_widget(ProgressView { model, appearance, elapsed }, viewport); + })?; + Ok(viewport) +} + +fn clear_terminal(terminal: &mut ProgressTerminal) -> io::Result<()> { + let origin = terminal.get_frame().area().as_position(); + terminal.clear()?; + terminal.set_cursor_position(origin)?; + terminal.show_cursor()?; + Write::flush(terminal.backend_mut()) +} + +fn finish_terminal(terminal: &mut ProgressTerminal) -> io::Result<()> { + let area = terminal.get_frame().area(); + let screen_height = terminal.size()?.height; + if area.bottom() < screen_height { + terminal.set_cursor_position((0, area.bottom()))?; + terminal.show_cursor()?; + Write::flush(terminal.backend_mut()) + } else if screen_height > 0 { + terminal.set_cursor_position((0, screen_height - 1))?; + terminal.show_cursor()?; + terminal.backend_mut().write_all(b"\r\n")?; + Write::flush(terminal.backend_mut()) + } else { + Ok(()) + } +} + +fn detect_appearance(color: bool) -> ProgressAppearance { + if !color { + return ProgressAppearance::Plain; + } + if !console::true_colors_enabled_stderr() { + return ProgressAppearance::Ansi; + } + let mut options = QueryOptions::default(); + options.timeout = Duration::from_millis(100); + let Ok(palette) = terminal_colorsaurus::color_palette(options) else { + return ProgressAppearance::Ansi; + }; + ProgressAppearance::TrueColor { + foreground: palette.foreground.scale_to_8bit(), + background: palette.background.scale_to_8bit(), + } +} + +fn render_completed_package(package: &CompletedPackage, width: usize) -> String { + let milliseconds = package.duration.as_secs_f64() * 1_000.0; + let timing = format!("{milliseconds:.2} ms"); + let timing_width = measure_text_width(&timing); + if width <= timing_width + 2 { + return truncate_str(&timing, width, "").to_string(); + } + let name_width = width - timing_width - 2; + let package_name = sanitize(&package.package_name); + let package_name = truncate_str(&package_name, name_width, "…"); + let padding = name_width - measure_text_width(&package_name) + 2; + format!("{package_name}{}{timing}", " ".repeat(padding)) +} + +fn render_progress_bar( + model: &ProgressModel, + width: usize, + appearance: ProgressAppearance, + animation_frame: usize, +) -> Line<'static> { + if width <= 2 { + return Line::from(truncate_str("[]", width, "").to_string()); + } + let bar_width = width - 2; + let mut spans = vec![Span::raw("[")]; + spans.extend(render_bar( + appearance, + bar_width, + model.completed_count, + model.package_count, + animation_frame, + matches!(model.phase, ProgressPhase::Finished { .. }), + )); + spans.push(Span::raw("]")); + Line::from(spans) +} + +fn render_progress_status( + model: &ProgressModel, + width: usize, + appearance: ProgressAppearance, + elapsed: Duration, +) -> Line<'static> { + let status = match model.phase { + ProgressPhase::Finished { duration, .. } | ProgressPhase::Finalizing { duration } => { + format!( + "Finished {} of {} packages in {duration:.2?}", + model.completed_count, model.package_count + ) + } + ProgressPhase::Compiling => format!( + "Compiling {} of {} packages in {elapsed:.2?}", + model.completed_count, model.package_count + ), + ProgressPhase::Preparing => String::new(), + }; + let status = truncate_str(&status, width, "…").into_owned(); + Line::from(Span::styled(status, accent_style(appearance))) +} + +fn render_preparation(width: usize, appearance: ProgressAppearance, frame: usize) -> Line<'static> { + let status = "Preparing"; + if width <= status.len() + 3 { + return Line::from(truncate_str(status, width, "").to_string()); + } + let bar_width = width - status.len() - 3; + let mut spans = vec![Span::styled(status, accent_style(appearance)), Span::raw(" [")]; + spans.extend(render_activity_bar(appearance, bar_width, frame)); + spans.push(Span::raw("]")); + Line::from(spans) +} + +fn render_activity_bar( + appearance: ProgressAppearance, + width: usize, + frame: usize, +) -> Vec> { + if appearance == ProgressAppearance::Plain { + let mut bar = vec!['='; width]; + if width > 0 { + bar[frame % width] = '#'; + } + return vec![Span::raw(bar.into_iter().collect::())]; + } + let spans = (0..width) + .map(|index| Span::styled("⣿", bar_style(appearance, index, width, width, frame, false))); + spans.collect_vec() +} + +fn render_bar( + appearance: ProgressAppearance, + width: usize, + completed: usize, + total: usize, + frame: usize, + finished: bool, +) -> Vec> { + let filled_eighths = if total == 0 { width * 8 } else { width * 8 * completed / total }; + if appearance == ProgressAppearance::Plain { + let filled = filled_eighths / 8; + let mut bar = String::with_capacity(width); + bar.extend(std::iter::repeat_n('=', filled)); + if filled < width { + bar.push('>'); + bar.extend(std::iter::repeat_n('-', width - filled - 1)); + } + return vec![Span::raw(bar)]; + } + const BRAILLE_FILL: [&str; 9] = ["⠀", "⡀", "⡄", "⡆", "⡇", "⣇", "⣧", "⣷", "⣿"]; + let filled = filled_eighths.div_ceil(8); + let spans = (0..width).map(|index| { + let eighths = filled_eighths.saturating_sub(index * 8).min(8); + Span::styled( + BRAILLE_FILL[eighths], + bar_style(appearance, index, width, filled, frame, finished), + ) + }); + spans.collect_vec() +} + +fn bar_style( + appearance: ProgressAppearance, + index: usize, + width: usize, + filled: usize, + frame: usize, + finished: bool, +) -> Style { + let style = Style::default(); + match appearance { + ProgressAppearance::TrueColor { .. } if index < filled => { + let progress = index as f32 / width.saturating_sub(1).max(1) as f32; + let colors = [(240, 128, 136), (248, 152, 184), (184, 136, 224), (136, 72, 192)]; + let scaled = progress * (colors.len() - 1) as f32; + let segment = (scaled.floor() as usize).min(colors.len() - 2); + let progress = scaled - segment as f32; + let from = colors[segment]; + let to = colors[segment + 1]; + let interpolate = |from: u8, to: u8| { + (from as f32 + (to as f32 - from as f32) * progress).round() as u8 + }; + let mut color = + (interpolate(from.0, to.0), interpolate(from.1, to.1), interpolate(from.2, to.2)); + if !finished && filled > 0 { + let distance = index.abs_diff(frame % filled); + let brightness = match distance { + 0 => 0.88, + 1 => 0.55, + 2 => 0.25, + _ => 0.0, + }; + let lighten = |channel: u8| { + (channel as f32 + (255.0 - channel as f32) * brightness).round() as u8 + }; + color = (lighten(color.0), lighten(color.1), lighten(color.2)); + } + style.fg(Color::Rgb(color.0, color.1, color.2)) + } + ProgressAppearance::TrueColor { foreground, .. } if index == filled => style + .fg(Color::Rgb(foreground.0, foreground.1, foreground.2)) + .add_modifier(Modifier::BOLD), + ProgressAppearance::TrueColor { foreground, background } => { + let blend = |foreground: u8, background: u8| { + (background as f32 + (foreground as f32 - background as f32) * 0.16).round() as u8 + }; + style.fg(Color::Rgb( + blend(foreground.0, background.0), + blend(foreground.1, background.1), + blend(foreground.2, background.2), + )) + } + ProgressAppearance::Ansi if index < filled => { + let color = if index < filled / 3 { + Color::Blue + } else if index < filled * 2 / 3 { + Color::Cyan + } else { + Color::Green + }; + let style = style.fg(color); + if !finished && index == frame % filled.max(1) { + style.add_modifier(Modifier::BOLD) + } else { + style + } + } + ProgressAppearance::Ansi if index == filled => { + style.fg(Color::White).add_modifier(Modifier::BOLD) + } + ProgressAppearance::Ansi => style.fg(Color::DarkGray), + ProgressAppearance::Plain => unreachable!("plain bars are rendered without styles"), + } +} + +fn history_style(appearance: ProgressAppearance, row: usize) -> Style { + match appearance { + ProgressAppearance::TrueColor { foreground, background } => { + let opacity = 0.18 + 0.82 * row as f32 / (PACKAGE_HISTORY_LENGTH - 1) as f32; + let blend = |foreground: u8, background: u8| { + (background as f32 + (foreground as f32 - background as f32) * opacity).round() + as u8 + }; + Style::default().fg(Color::Rgb( + blend(foreground.0, background.0), + blend(foreground.1, background.1), + blend(foreground.2, background.2), + )) + } + ProgressAppearance::Ansi if row + 1 == PACKAGE_HISTORY_LENGTH => { + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD) + } + ProgressAppearance::Ansi if row * 2 < PACKAGE_HISTORY_LENGTH => { + Style::default().add_modifier(Modifier::DIM) + } + ProgressAppearance::Ansi | ProgressAppearance::Plain => Style::default(), + } +} + +fn accent_style(appearance: ProgressAppearance) -> Style { + match appearance { + ProgressAppearance::Plain => Style::default(), + ProgressAppearance::TrueColor { .. } | ProgressAppearance::Ansi => { + Style::default().fg(Color::White) + } + } +} + +fn sanitize(name: &str) -> String { + name.chars().map(|character| if character.is_control() { '�' } else { character }).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use itertools::Itertools; + + fn model_with_packages(names: &[&str], package_count: usize) -> ProgressModel { + let mut model = ProgressModel::default(); + model.apply(ProgressEvent::PlanReady { package_count }); + for name in names { + model.apply(ProgressEvent::PackageCompleted { + package_name: SmolStr::new(name), + duration: Duration::from_millis(125), + }); + } + model + } + + #[test] + fn watch_summaries_distinguish_builds_rebuilds_and_diagnostics() { + let inputs = vec!["Application".to_owned(), "Library".to_owned()]; + let initial = render_watch_summary( + WatchSummary { + timestamp: "12:34:56", + initial: true, + changed_inputs: &inputs, + duration: Duration::from_micros(12_500), + outcome: WatchOutcome::Succeeded, + }, + false, + ); + let colored_initial = render_watch_summary( + WatchSummary { + timestamp: "12:34:56", + initial: true, + changed_inputs: &inputs, + duration: Duration::from_micros(12_500), + outcome: WatchOutcome::Succeeded, + }, + true, + ); + let rebuilt = render_watch_summary( + WatchSummary { + timestamp: "12:35:01", + initial: false, + changed_inputs: &inputs[..1], + duration: Duration::from_millis(4), + outcome: WatchOutcome::Succeeded, + }, + false, + ); + let failed = render_watch_summary( + WatchSummary { + timestamp: "12:35:02", + initial: false, + changed_inputs: &inputs[..1], + duration: Duration::from_secs_f64(1.25), + outcome: WatchOutcome::Diagnostics, + }, + false, + ); + + assert_eq!( + initial, + concat!( + "12:34:56 Build succeeded in 12.50 ms\n", + " Loaded 2 inputs: Application, Library" + ) + ); + assert!(colored_initial.contains("\u{1b}[")); + assert_eq!(console::strip_ansi_codes(&colored_initial), initial); + assert_eq!( + rebuilt, + "12:35:01 Rebuild succeeded in 4.00 ms\n Changed 1 input: Application" + ); + assert_eq!( + failed, + concat!( + "12:35:02 Rebuild completed with diagnostics in 1.25 s\n", + " Changed 1 input: Application" + ) + ); + } + + #[test] + fn watch_summaries_limit_and_sanitize_inputs() { + let inputs = vec![ + "First".to_owned(), + "Second\nModule".to_owned(), + "Third".to_owned(), + "Fourth".to_owned(), + "Fifth".to_owned(), + ]; + let summary = render_watch_summary( + WatchSummary { + timestamp: "12:34:56", + initial: false, + changed_inputs: &inputs, + duration: Duration::ZERO, + outcome: WatchOutcome::Succeeded, + }, + false, + ); + + assert_eq!( + summary, + concat!( + "12:34:56 Rebuild succeeded in 0.00 ms\n", + " Changed 5 inputs: First, Second�Module, Third, Fourth, … +1 more" + ) + ); + } + + #[test] + fn watch_summaries_distinguish_waiting_and_operational_failure() { + let failed = render_watch_summary( + WatchSummary { + timestamp: "12:34:56", + initial: false, + changed_inputs: &[], + duration: Duration::from_millis(2), + outcome: WatchOutcome::Failed, + }, + false, + ); + let waiting = render_watch_summary( + WatchSummary { + timestamp: "12:34:57", + initial: false, + changed_inputs: &["src/Main.purs".to_owned()], + duration: Duration::ZERO, + outcome: WatchOutcome::Waiting, + }, + false, + ); + + assert_eq!(failed, "12:34:56 Rebuild failed in 2.00 ms"); + assert_eq!( + waiting, + concat!( + "12:34:57 No input files; waiting for changes\n", + " Changed 1 input: src/Main.purs" + ) + ); + } + + fn render_model( + model: &ProgressModel, + width: u16, + height: u16, + appearance: ProgressAppearance, + ) -> Buffer { + let area = Rect::new(0, 0, width, height); + let mut buffer = Buffer::empty(area); + ProgressView { model, appearance, elapsed: Duration::ZERO }.render(area, &mut buffer); + buffer + } + + fn row_text(buffer: &Buffer, row: u16) -> String { + (0..buffer.area.width).map(|column| buffer.cell((column, row)).unwrap().symbol()).collect() + } + + #[test] + fn model_transitions_are_deterministic_and_finished_is_authoritative() { + let mut model = ProgressModel::default(); + model.apply(ProgressEvent::PlanReady { package_count: 2 }); + model.apply(ProgressEvent::PackageCompleted { + package_name: SmolStr::new("diagnostic-package"), + duration: Duration::from_millis(5), + }); + model.apply(ProgressEvent::Finalizing { duration: Duration::from_millis(750) }); + model.apply(ProgressEvent::Finished { + duration: Duration::from_secs(1), + outcome: ProgressOutcome::Diagnostics, + }); + + assert_eq!(model.completed_count, 1); + assert_eq!(model.completed_packages[0].package_name, "diagnostic-package"); + assert_eq!( + model.phase, + ProgressPhase::Finished { + duration: Duration::from_secs(1), + outcome: ProgressOutcome::Diagnostics, + } + ); + } + + #[test] + fn model_keeps_only_latest_ten_completions() { + let names = (1..=12).map(|number| format!("package-{number}")).collect_vec(); + let mut model = ProgressModel::default(); + model.apply(ProgressEvent::PlanReady { package_count: names.len() }); + for package_name in names { + model.apply(ProgressEvent::PackageCompleted { + package_name: SmolStr::new(package_name), + duration: Duration::from_millis(1), + }); + } + + assert_eq!(model.completed_count, 12); + assert_eq!(model.completed_packages.len(), 10); + assert_eq!(model.completed_packages.front().unwrap().package_name, "package-3"); + assert_eq!(model.completed_packages.back().unwrap().package_name, "package-12"); + } + + #[test] + fn frame_has_ten_history_rows_separator_and_status() { + let model = model_with_packages(&["one", "two", "three"], 12); + let buffer = render_model(&model, 60, PROGRESS_REGION_HEIGHT, ProgressAppearance::Plain); + + assert!(row_text(&buffer, 7).starts_with("one ")); + assert!(row_text(&buffer, 9).starts_with("three ")); + assert!(row_text(&buffer, 10).chars().all(|character| character == '─')); + assert!(row_text(&buffer, 11).starts_with('[')); + assert!(row_text(&buffer, 12).starts_with("Compiling 3 of 12 packages in")); + } + + #[test] + fn frame_transitions_from_one_line_preparation_to_compilation() { + let preparing = ProgressModel::default(); + let compiling = model_with_packages(&[], 12); + let preparing = + render_model(&preparing, 60, PROGRESS_REGION_HEIGHT, ProgressAppearance::Plain); + let compiling = + render_model(&compiling, 60, PROGRESS_REGION_HEIGHT, ProgressAppearance::Plain); + + assert!(row_text(&preparing, 0).starts_with("Preparing [")); + assert!((1..PROGRESS_REGION_HEIGHT).all(|row| row_text(&preparing, row).trim().is_empty())); + assert!(row_text(&compiling, 11).starts_with('[')); + assert!(row_text(&compiling, 12).starts_with("Compiling 0 of 12 packages in")); + } + + #[test] + fn preparation_has_a_full_animated_bar_and_white_status() { + let appearance = + ProgressAppearance::TrueColor { foreground: (220, 220, 220), background: (20, 20, 20) }; + let first = render_preparation(20, appearance, 0); + let second = render_preparation(20, appearance, 1); + let first_bar = &first.spans[2..first.spans.len() - 1]; + let second_bar = &second.spans[2..second.spans.len() - 1]; + + assert_eq!(first.spans[0].style.fg, Some(Color::White)); + assert!(first_bar.iter().all(|span| span.content == "⣿")); + assert_eq!(first_bar[0].style.fg, Some(Color::Rgb(253, 240, 241))); + assert_ne!(first_bar[0].style.fg, second_bar[0].style.fg); + + let plain = render_preparation(20, ProgressAppearance::Plain, 0); + assert_eq!(plain.spans[2].content, "#======="); + } + + #[test] + fn compilation_and_finished_statuses_are_white() { + let mut model = model_with_packages(&["complete"], 2); + let compiling_bar = render_progress_bar(&model, 60, ProgressAppearance::Ansi, 0); + let compiling_status = + render_progress_status(&model, 60, ProgressAppearance::Ansi, Duration::from_secs(2)); + assert!(compiling_bar.to_string().starts_with('[')); + assert!(!compiling_bar.to_string().contains("1/2")); + assert_eq!(compiling_status.to_string(), "Compiling 1 of 2 packages in 2.00s"); + assert_eq!(compiling_status.spans.first().unwrap().style.fg, Some(Color::White)); + + model.apply(ProgressEvent::Finished { + duration: Duration::from_secs(1), + outcome: ProgressOutcome::Succeeded, + }); + let finished_status = + render_progress_status(&model, 60, ProgressAppearance::Ansi, Duration::from_secs(2)); + assert_eq!(finished_status.to_string(), "Finished 1 of 2 packages in 1.00s"); + assert_eq!(finished_status.spans.first().unwrap().style.fg, Some(Color::White)); + } + + #[test] + fn progress_bar_geometry_is_independent_of_package_count() { + let mut model = model_with_packages(&[], 100); + model.completed_count = 9; + let single_digit = + render_progress_bar(&model, 40, ProgressAppearance::Plain, 0).to_string(); + model.completed_count = 10; + let double_digit = + render_progress_bar(&model, 40, ProgressAppearance::Plain, 0).to_string(); + + assert_eq!(measure_text_width(&single_digit), 40); + assert_eq!(measure_text_width(&double_digit), 40); + assert!(single_digit.starts_with('[')); + assert!(double_digit.starts_with('[')); + assert!(!single_digit.contains("9/100")); + assert!(!double_digit.contains("10/100")); + } + + #[test] + fn frame_caps_width_and_aligns_timing_digits() { + let mut model = ProgressModel::default(); + model.apply(ProgressEvent::PlanReady { package_count: 2 }); + for (package_name, duration) in [ + ("short", Duration::from_micros(9_500)), + ("a-much-longer-package-name", Duration::from_micros(123_450)), + ] { + model.apply(ProgressEvent::PackageCompleted { + package_name: SmolStr::new(package_name), + duration, + }); + } + let buffer = render_model(&model, 100, PROGRESS_REGION_HEIGHT, ProgressAppearance::Plain); + let short = row_text(&buffer, 8); + let long = row_text(&buffer, 9); + + assert!(short[PROGRESS_REGION_WIDTH..].trim().is_empty()); + assert_eq!( + short[..PROGRESS_REGION_WIDTH].find('.'), + long[..PROGRESS_REGION_WIDTH].find('.') + ); + assert!(short[..PROGRESS_REGION_WIDTH].ends_with("9.50 ms")); + assert!(long[..PROGRESS_REGION_WIDTH].ends_with("123.45 ms")); + } + + #[test] + fn narrow_and_short_terminals_keep_status_visible() { + let model = model_with_packages(&["completed"], 12); + for width in 1..=8 { + let buffer = + render_model(&model, width, PROGRESS_REGION_HEIGHT, ProgressAppearance::Plain); + assert_eq!(measure_text_width(&row_text(&buffer, 11)), width as usize); + } + let buffer = render_model(&model, 40, 4, ProgressAppearance::Plain); + assert!(row_text(&buffer, 2).starts_with('[')); + assert!(row_text(&buffer, 3).contains("Compiling 1 of 12 packages in")); + assert!(!row_text(&buffer, 3).contains("completed")); + } + + #[test] + fn frame_sanitizes_and_truncates_names_without_wrapping() { + let model = model_with_packages(&["a\nvery-long-package-name"], 1); + let buffer = render_model(&model, 18, PROGRESS_REGION_HEIGHT, ProgressAppearance::Plain); + let package = row_text(&buffer, 9); + assert!(package.contains('�')); + assert!(package.contains('…')); + assert!(package.ends_with("125.00 ms")); + } + + #[test] + fn history_opacity_is_fixed_by_row() { + let appearance = + ProgressAppearance::TrueColor { foreground: (240, 220, 200), background: (20, 30, 40) }; + assert_eq!(history_style(appearance, 0).fg, Some(Color::Rgb(60, 64, 69))); + assert_eq!(history_style(appearance, 9).fg, Some(Color::Rgb(240, 220, 200))); + + let first = model_with_packages(&["first"], 2); + let second = model_with_packages(&["first", "second"], 2); + let first = render_model(&first, 60, PROGRESS_REGION_HEIGHT, appearance); + let second = render_model(&second, 60, PROGRESS_REGION_HEIGHT, appearance); + assert_eq!(first.cell((0, 9)).unwrap().style(), second.cell((0, 9)).unwrap().style()); + assert_ne!(second.cell((0, 8)).unwrap().style(), second.cell((0, 9)).unwrap().style()); + } + + #[test] + fn colored_bar_uses_braille_and_iris_costume_gradient() { + let appearance = + ProgressAppearance::TrueColor { foreground: (240, 240, 240), background: (20, 20, 20) }; + let bar = render_bar(appearance, 7, 7, 7, 0, true); + assert_eq!(bar.first().unwrap().style.fg, Some(Color::Rgb(240, 128, 136))); + assert_eq!(bar[2].style.fg, Some(Color::Rgb(248, 152, 184))); + assert_eq!(bar[4].style.fg, Some(Color::Rgb(184, 136, 224))); + assert_eq!(bar.last().unwrap().style.fg, Some(Color::Rgb(136, 72, 192))); + + let partial = render_bar(ProgressAppearance::Ansi, 2, 1, 3, 0, false); + assert_eq!(partial[0].content, "⣇"); + assert_eq!(partial[1].content, "⠀"); + } + + #[test] + fn true_color_bar_animates_highlight_and_bottom_bar_has_no_package_name() { + let appearance = + ProgressAppearance::TrueColor { foreground: (240, 240, 240), background: (20, 20, 20) }; + let first = render_bar(appearance, 6, 3, 6, 0, false); + let second = render_bar(appearance, 6, 3, 6, 1, false); + assert_ne!(first[0].style.fg, second[0].style.fg); + + let model = model_with_packages(&["must-not-appear"], 2); + let buffer = render_model(&model, 60, PROGRESS_REGION_HEIGHT, ProgressAppearance::Plain); + assert!(!row_text(&buffer, 11).contains("must-not-appear")); + } + + #[test] + fn hidden_runtime_has_no_thread_and_barrier_is_a_no_op() { + let runtime = ProgressRuntime::start(false, true); + let reporter = runtime.reporter(); + assert!(!reporter.is_active()); + assert!(runtime.thread.is_none()); + reporter.report(ProgressEvent::Finished { + duration: Duration::ZERO, + outcome: ProgressOutcome::Succeeded, + }); + } +} diff --git a/compiler-bin/iris-watch/Cargo.toml b/compiler-bin/iris-watch/Cargo.toml new file mode 100644 index 000000000..d77a8b835 --- /dev/null +++ b/compiler-bin/iris-watch/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "iris-watch" +version = "0.1.0" +edition = "2024" +authors = ["Justin Garcia "] +license = "BSD-3-Clause" +description = "Incremental project watching for Iris." +repository = "https://github.com/purefunctor/purescript-iris" + +[dependencies] +iris-build = { version = "0.1.0", path = "../iris-build" } +iris-progress = { version = "0.1.0", path = "../iris-progress" } +itertools = "0.15.0" +jiff = "0.2.23" +notify = "8.2.0" +thiserror = "2.0.20" +tracing = "0.1.44" diff --git a/compiler-bin/iris-watch/src/lib.rs b/compiler-bin/iris-watch/src/lib.rs new file mode 100644 index 000000000..cf1923b91 --- /dev/null +++ b/compiler-bin/iris-watch/src/lib.rs @@ -0,0 +1,265 @@ +use std::collections::BTreeSet; +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError}; +use std::time::{Duration, Instant}; + +use iris_build::{ + BuildSession, BuildSessionConfig, InputChange, InputChanges, PreparedProject, ProjectError, + RebuildOutcome, SessionError, initialize_project, +}; +use iris_progress::{WatchOutcome, WatchSummary, render_watch_summary}; +use itertools::Itertools; +use notify::event::{CreateKind, ModifyKind, RemoveKind}; +use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; +use thiserror::Error; + +const DEBOUNCE_DURATION: Duration = Duration::from_millis(100); + +pub struct WatchConfig { + pub quiet: bool, + pub color: bool, + pub diagnostics: bool, +} + +#[derive(Debug, Error)] +enum WatchFailure { + #[error(transparent)] + Io(#[from] io::Error), + #[error(transparent)] + Notify(#[from] notify::Error), + #[error(transparent)] + Session(#[from] SessionError), + #[error(transparent)] + Project { + #[from] + source: ProjectError, + }, +} + +#[derive(Debug, Error)] +#[error(transparent)] +pub struct WatchError(WatchFailure); + +pub fn watch(project: PreparedProject, config: WatchConfig) -> Result<(), WatchError> { + watch_project(project, config).map_err(WatchError) +} + +fn watch_project(project: PreparedProject, config: WatchConfig) -> Result<(), WatchFailure> { + let root = project.root_directory().to_path_buf(); + let source_roots = project.source_roots()?; + let (sender, receiver) = mpsc::channel(); + let mut watcher = RecommendedWatcher::new(sender, notify::Config::default())?; + for root in &source_roots { + let root = persistent_watch_root(root); + watcher.watch(&root, RecursiveMode::Recursive)?; + } + + let initial_started = Instant::now(); + let project = initialize_project(project)?; + let mut session = BuildSession::new( + project, + BuildSessionConfig { color: config.color, diagnostics: config.diagnostics }, + )?; + + let initial_inputs = session.take_initial_inputs(); + let changes = session.rescan()?; + report_warnings(&changes); + let mut pending_inputs = + if changes.inputs.is_empty() { initial_inputs } else { changes.inputs }; + let mut pending_rebuild = + !rebuild_and_report(&mut session, &config, &root, &pending_inputs, true, initial_started); + + let mut needs_rescan = false; + loop { + let batch = receive_batch(&receiver)?; + if batch.paths.is_empty() && !batch.rescan { + continue; + } + let changes = if needs_rescan || batch.rescan { + session.rescan() + } else { + session.synchronize_paths(&batch.paths) + }; + let changes = match changes { + Ok(changes) => { + needs_rescan = false; + changes + } + Err(error) => { + needs_rescan = true; + pending_rebuild = true; + report_operational_failure( + &config, + &root, + &pending_inputs, + false, + Duration::ZERO, + error, + ); + continue; + } + }; + report_warnings(&changes); + if !changes.is_empty() { + pending_inputs = changes.inputs; + pending_rebuild = true; + } + if !pending_rebuild { + continue; + } + pending_rebuild = !rebuild_and_report( + &mut session, + &config, + &root, + &pending_inputs, + false, + Instant::now(), + ); + } +} + +fn rebuild_and_report( + session: &mut BuildSession, + config: &WatchConfig, + root: &Path, + inputs: &[InputChange], + initial: bool, + started: Instant, +) -> bool { + match session.rebuild() { + Ok(outcome) => { + let outcome = match outcome { + RebuildOutcome::Succeeded => WatchOutcome::Succeeded, + RebuildOutcome::Diagnostics => WatchOutcome::Diagnostics, + RebuildOutcome::NoInputs => WatchOutcome::Waiting, + }; + report_summary(config, root, inputs, initial, started.elapsed(), outcome); + true + } + Err(error) => { + report_operational_failure(config, root, inputs, initial, started.elapsed(), error); + false + } + } +} + +fn report_operational_failure( + config: &WatchConfig, + root: &Path, + inputs: &[InputChange], + initial: bool, + duration: Duration, + error: SessionError, +) { + tracing::error!(?error, "Watch compilation failed"); + eprintln!("Watch build failed: {error}"); + report_summary(config, root, inputs, initial, duration, WatchOutcome::Failed); +} + +fn report_warnings(changes: &InputChanges) { + for warning in &changes.warnings { + tracing::warn!("{warning}"); + eprintln!("Watch warning: {warning}"); + } +} + +fn report_summary( + config: &WatchConfig, + root: &Path, + inputs: &[InputChange], + initial: bool, + duration: Duration, + outcome: WatchOutcome, +) { + if config.quiet { + return; + } + let changed_inputs = inputs.iter().map(|input| input_label(input, root)); + let mut changed_inputs = changed_inputs.collect_vec(); + changed_inputs.sort(); + let timestamp = jiff::Zoned::now().strftime("%H:%M:%S").to_string(); + println!( + "{}", + render_watch_summary( + WatchSummary { + timestamp: ×tamp, + initial, + changed_inputs: &changed_inputs, + duration, + outcome, + }, + config.color, + ) + ); +} + +fn input_label(input: &InputChange, root: &Path) -> String { + input.module_name.clone().unwrap_or_else(|| { + input.source_path.strip_prefix(root).unwrap_or(&input.source_path).display().to_string() + }) +} + +struct EventBatch { + paths: Vec, + rescan: bool, +} + +fn receive_batch(receiver: &Receiver>) -> Result { + let first = receiver.recv().map_err(|error| { + io::Error::new(io::ErrorKind::BrokenPipe, format!("watch channel closed: {error}")) + })??; + let deadline = Instant::now() + DEBOUNCE_DURATION; + let mut events = vec![first]; + loop { + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { + break; + }; + match receiver.recv_timeout(remaining) { + Ok(event) => events.push(event?), + Err(RecvTimeoutError::Timeout) => break, + Err(RecvTimeoutError::Disconnected) => { + return Err( + io::Error::new(io::ErrorKind::BrokenPipe, "watch channel closed").into() + ); + } + } + } + + let mut paths = BTreeSet::new(); + let mut rescan = false; + for event in events { + if matches!(event.kind, EventKind::Access(_)) { + continue; + } + rescan |= event.need_rescan() || requires_rescan(event.kind); + paths.extend(event.paths); + } + Ok(EventBatch { paths: paths.into_iter().collect_vec(), rescan }) +} + +fn requires_rescan(kind: EventKind) -> bool { + matches!( + kind, + EventKind::Create(CreateKind::Folder) + | EventKind::Modify(ModifyKind::Name(_)) + | EventKind::Remove(RemoveKind::Folder) + | EventKind::Any + | EventKind::Other + ) +} + +fn persistent_watch_root(root: &Path) -> PathBuf { + let mut candidate = root.parent().unwrap_or(root); + while !candidate.exists() { + let Some(parent) = candidate.parent() else { + break; + }; + candidate = parent; + } + if candidate.is_file() { + candidate.parent().unwrap_or(candidate).to_path_buf() + } else { + candidate.to_path_buf() + } +} diff --git a/compiler-bin/src/bin/iris.rs b/compiler-bin/src/bin/iris.rs deleted file mode 100644 index 6ae9886a5..000000000 --- a/compiler-bin/src/bin/iris.rs +++ /dev/null @@ -1,6 +0,0 @@ -fn main() { - // SAFETY: Startup is single-threaded; no worker threads have been started. - unsafe { purescript_iris::cli::initialize_terminal_width() }; - - purescript_iris::run(); -} diff --git a/compiler-bin/src/cli.rs b/compiler-bin/src/cli.rs deleted file mode 100644 index e88b5604f..000000000 --- a/compiler-bin/src/cli.rs +++ /dev/null @@ -1,628 +0,0 @@ -use std::borrow::Cow; -use std::path::PathBuf; -use std::{fs, io}; - -use configuration::{Configuration, ConfigurationSettings}; -use path_absolutize::Absolutize; -use thiserror::Error; -use tracing::level_filters::LevelFilter; -use usage::{Args, Subcommands, ValueEnum}; - -mod diagnostic; - -/// Supply the terminal width to help rendering unless explicitly overridden. -/// -/// # Safety -/// -/// Call only during single-threaded startup, before starting any worker threads. -pub unsafe fn initialize_terminal_width() { - if std::env::var_os("COLUMNS").is_none() - && let Some((_, columns)) = console::Term::stdout().size_checked() - { - // SAFETY: The caller guarantees single-threaded startup. - unsafe { std::env::set_var("COLUMNS", columns.to_string()) }; - } -} - -fn absolute_path(value: PathBuf) -> io::Result { - value.absolutize().map(Cow::into_owned) -} - -#[derive(Debug, usage::Cli)] -#[usage( - bin = "iris", - about = env!("CARGO_PKG_DESCRIPTION"), - version = crate::VERSION, - unknown_flags = "error", - args_override_self = false -)] -pub struct Program { - /// Print log path. - #[usage(long)] - pub log_file: bool, - #[usage(subcommand)] - pub command: Command, -} - -impl Program { - pub fn into_command(self) -> io::Result { - let mut command = self.command; - match &mut command { - Command::Build(options) => options.build.normalize_paths()?, - Command::Watch(options) => options.normalize_paths()?, - Command::Run(options) => options.build.normalize_paths()?, - Command::Test(options) => options.build.normalize_paths()?, - Command::Compile(options) => { - options.build.output = absolute_path(std::mem::take(&mut options.build.output))?; - for package in &mut options.packages { - *package = absolute_path(std::mem::take(package))?; - } - } - Command::Docs(options) => { - options.output = absolute_path(std::mem::take(&mut options.output))?; - options.spago_project = - options.spago_project.take().map(absolute_path).transpose()?; - for package in &mut options.packages { - *package = absolute_path(std::mem::take(package))?; - } - if let Some(DocsCommand::TypeScript(options)) = &mut options.command { - options.output = absolute_path(std::mem::take(&mut options.output))?; - } - } - Command::New(_) | Command::Add(_) | Command::Lsp(_) => {} - } - Ok(command) - } -} - -#[derive(Debug, Subcommands)] -pub enum Command { - /// Create a Spago project in the current directory. - #[usage(help_heading = "Project commands")] - New(NewOptions), - /// Add dependencies to a Spago package. - #[usage(help_heading = "Project commands")] - Add(AddOptions), - /// Build a Spago workspace or package. - #[usage(help_heading = "Project commands")] - Build(ProjectBuildCommandOptions), - /// Build a Spago workspace or package and rebuild when inputs change. - #[usage(help_heading = "Project commands")] - Watch(ProjectBuildOptions), - /// Run the language server. - #[usage(help_heading = "Project commands")] - Lsp(LspOptions), - /// Build and run a Spago package with Node.js. - #[usage(help_heading = "Project commands")] - Run(RunOptions), - /// Build and test one or more Spago packages with Node.js. - #[usage(help_heading = "Project commands")] - Test(TestOptions), - /// Compile PureScript modules to JavaScript (experimental). - #[usage(help_heading = "Legacy commands")] - Compile(CompileOptions), - /// Documentation utilities. - #[usage(help_heading = "Documentation commands")] - Docs(DocsOptions), -} - -#[derive(Debug, Args)] -pub struct LoggingOptions { - /// Log level for the query engine. - #[usage( - long, - value_name = "LevelFilter", - default = "off", - choices("off", "error", "warn", "info", "debug", "trace") - )] - pub query_log: LevelFilter, - - /// Log level for the type checker. - #[usage( - long, - value_name = "LevelFilter", - default = "off", - choices("off", "error", "warn", "info", "debug", "trace") - )] - pub checking_log: LevelFilter, -} - -#[derive(Debug, Args)] -#[usage(args_override_self = false)] -pub struct LspOptions { - #[usage(flatten)] - pub logging: LoggingOptions, - - #[usage(long)] - pub stdio: bool, - - /// Log level for the language server. - #[usage( - long, - value_name = "LevelFilter", - default = "info", - choices("off", "error", "warn", "info", "debug", "trace") - )] - pub lsp_log: LevelFilter, - - /// Language server configuration as a JSON object, read once at startup. - #[usage(long, value_name = "JSON", conflicts = "--config-file")] - pub config: Option, - - /// Language server configuration file, relative to the working directory. - #[usage(long, value_name = "PATH", conflicts = "--config")] - pub config_file: Option, -} - -#[derive(Debug, Error)] -pub enum ConfigurationError { - #[error("failed to read configuration file {}: {error}", path.display())] - ReadFile { path: PathBuf, error: io::Error }, - #[error("invalid configuration in {input}: {error}")] - InvalidJson { input: String, content: String, error: serde_json::Error }, -} - -impl LspOptions { - pub fn configuration(&self) -> Result { - let (input, content) = if let Some(path) = &self.config_file { - let content = fs::read_to_string(path) - .map_err(|error| ConfigurationError::ReadFile { path: path.clone(), error })?; - (path.display().to_string(), Cow::Owned(content)) - } else if let Some(content) = &self.config { - ("--config".to_string(), Cow::Borrowed(content.as_str())) - } else { - return Ok(Configuration::default()); - }; - let settings = serde_json::from_str::>(&content) - .map_err(|error| ConfigurationError::InvalidJson { - input, - content: content.into_owned(), - error, - })? - .unwrap_or_default(); - Ok(settings.apply_to(&Configuration::default())) - } -} - -#[derive(Debug, Args)] -#[usage(args_override_self = false)] -pub struct CompileOptions { - #[usage(flatten)] - pub build: BuildOptions, - - /// Package folder to compile. - #[usage(name = "package", long = "package", value_name = "DIR")] - pub packages: Vec, - - /// PureScript source paths or glob patterns. - #[usage(value_name = "INPUT", required_unless = "--package")] - pub inputs: Vec, - - /// Code generation targets requested by build tools. - #[usage(long, value_name = "TARGETS")] - pub codegen: Option, - - /// Emit a Spago-compatible JSON result. - /// - /// Full structured JSON diagnostics are not yet supported. - #[usage(long)] - pub json_errors: bool, -} - -#[derive(Debug, Args)] -pub struct BuildOptions { - #[usage(flatten)] - pub logging: LoggingOptions, - - /// Output directory for compiled modules. - #[usage(short, long, value_name = "DIR", default = "output")] - pub output: PathBuf, - - /// Suppress build progress output. - #[usage(short, long)] - pub quiet: bool, - - /// When to use colors in human-readable diagnostics. - #[usage(long, value_enum, default = "auto")] - pub color: ColorChoice, -} - -#[derive(Debug, Args)] -#[usage(args_override_self = false)] -pub struct NewOptions { - /// Package name. Defaults to the current directory name. - #[usage(long, value_name = "NAME")] - pub name: Option, -} - -#[derive(Debug, Args)] -#[usage(args_override_self = false)] -pub struct ProjectBuildOptions { - #[usage(flatten)] - pub logging: LoggingOptions, - - /// Workspace package to build. - #[usage(short, long, value_name = "NAME")] - pub package: Option, - - /// Output directory for compiled modules. Defaults to output in the workspace root. - #[usage(short, long, value_name = "DIR")] - pub output: Option, - - /// Suppress build progress output. - #[usage(short, long)] - pub quiet: bool, - - /// When to use colors in human-readable diagnostics. - #[usage(long, value_enum, default = "auto")] - pub color: ColorChoice, -} - -impl ProjectBuildOptions { - fn normalize_paths(&mut self) -> io::Result<()> { - self.output = self.output.take().map(absolute_path).transpose()?; - Ok(()) - } -} - -#[derive(Debug, Args)] -#[usage(args_override_self = false)] -pub struct ProjectBuildCommandOptions { - #[usage(flatten)] - pub build: ProjectBuildOptions, - - /// Write JavaScript output even when compilation reports errors. - #[usage(long)] - pub resilient: bool, -} - -#[derive(Debug, Args)] -#[usage(args_override_self = false)] -pub struct AddOptions { - /// Workspace package whose dependencies should change. - #[usage(short, long, value_name = "NAME")] - pub package: Option, - - /// Add packages as test dependencies. - #[usage(long)] - pub test: bool, - - /// Packages to add. - #[usage(value_name = "DEPENDENCY", required = true)] - pub dependencies: Vec, -} - -#[derive(Debug, Args)] -#[usage(args_override_self = false)] -pub struct RunOptions { - #[usage(flatten)] - pub build: ProjectBuildOptions, - - /// Module containing the program entry point. - #[usage(long, value_name = "MODULE")] - pub main: Option, - - /// Arguments passed to the program. - #[usage(double_dash = "required")] - pub arguments: Vec, -} - -#[derive(Debug, Args)] -#[usage(args_override_self = false)] -pub struct TestOptions { - #[usage(flatten)] - pub build: ProjectBuildOptions, - - /// Module containing the test entry point. - #[usage(long, value_name = "MODULE")] - pub main: Option, - - /// Arguments passed to each test program. - #[usage(double_dash = "required")] - pub arguments: Vec, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] -pub enum ColorChoice { - Auto, - Always, - Never, -} - -#[derive(Debug, Args)] -#[usage(subcommand_negates_reqs = true, args_override_self = false)] -pub struct DocsOptions { - #[usage(flatten)] - pub logging: LoggingOptions, - /// Log level for the documentation tool. - #[usage( - long, - value_name = "LEVEL", - default = "info", - choices("off", "error", "warn", "info", "debug", "trace") - )] - pub docs_log: LevelFilter, - #[usage(subcommand)] - pub command: Option, - /// Output directory for the generated documentation. - #[usage(long, value_name = "DIR", default = "docs")] - pub output: PathBuf, - /// Suppress documentation progress output. - #[usage(short, long)] - pub quiet: bool, - /// Spago project directory containing spago.lock. - #[usage(long, value_name = "DIR", conflicts = "--package")] - pub spago_project: Option, - /// Package folder to document. - #[usage( - name = "package", - long = "package", - value_name = "DIR", - required_unless = "--spago-project" - )] - pub packages: Vec, -} - -#[derive(Debug, Subcommands)] -pub enum DocsCommand { - /// Generate TypeScript declarations for the documentation JSON schema. - #[usage(name = "typescript")] - TypeScript(DocsTypeScriptOptions), -} - -#[derive(Debug, Args)] -#[usage(args_override_self = false)] -pub struct DocsTypeScriptOptions { - /// Output directory for the generated TypeScript schema. - #[usage(long, value_name = "DIR", default = "src-generated")] - pub output: PathBuf, -} - -impl Default for LoggingOptions { - fn default() -> LoggingOptions { - LoggingOptions { query_log: LevelFilter::OFF, checking_log: LevelFilter::OFF } - } -} - -impl Default for LspOptions { - fn default() -> LspOptions { - LspOptions { - logging: LoggingOptions::default(), - stdio: false, - lsp_log: LevelFilter::INFO, - config: None, - config_file: None, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::ffi::OsStr; - use std::path::Path; - use usage::diagnostic::Code; - - fn parse(argv: Vec<&str>) -> Program { - let argv: Vec<_> = argv.into_iter().map(OsStr::new).collect(); - Program::try_parse_from(&argv).unwrap() - } - - fn error_kind(argv: Vec<&str>) -> Code { - let argv: Vec<_> = argv.into_iter().map(OsStr::new).collect(); - let error = Program::try_parse_from(&argv).unwrap_err(); - usage::diagnostic::report(Program::spec(), &argv[1..], &error).code - } - - fn docs(args: &[&str]) -> DocsOptions { - let mut argv = vec!["iris", "docs"]; - argv.extend(args); - let program = parse(argv); - match program.into_command().unwrap() { - Command::Docs(options) => options, - _ => unreachable!("parsed command was not `docs`"), - } - } - - fn compile(args: &[&str]) -> CompileOptions { - let mut argv = vec!["iris", "compile"]; - argv.extend(args); - let program = parse(argv); - match program.into_command().unwrap() { - Command::Compile(options) => options, - _ => unreachable!("parsed command was not `compile`"), - } - } - - fn compile_error_kind(args: &[&str]) -> Code { - let mut argv = vec!["iris", "compile"]; - argv.extend(args); - error_kind(argv) - } - - fn build(args: &[&str]) -> ProjectBuildCommandOptions { - let mut argv = vec!["iris", "build"]; - argv.extend(args); - let program = parse(argv); - match program.into_command().unwrap() { - Command::Build(options) => options, - _ => unreachable!("parsed command was not `build`"), - } - } - - fn watch(args: &[&str]) -> ProjectBuildOptions { - let mut argv = vec!["iris", "watch"]; - argv.extend(args); - let program = parse(argv); - match program.into_command().unwrap() { - Command::Watch(options) => options, - _ => unreachable!("parsed command was not `watch`"), - } - } - - fn docs_error_kind(args: &[&str]) -> Code { - let mut argv = vec!["iris", "docs"]; - argv.extend(args); - error_kind(argv) - } - - fn typescript(args: &[&str]) -> DocsTypeScriptOptions { - match docs(args).command { - Some(DocsCommand::TypeScript(options)) => options, - _ => unreachable!("parsed command was not `typescript`"), - } - } - - fn current_directory() -> PathBuf { - std::env::current_dir().unwrap() - } - - fn current_directory_path(path: impl AsRef) -> PathBuf { - current_directory().join(path) - } - - #[test] - fn compile_accepts_spago_arguments() { - let options = compile(&[ - "--codegen", - "corefn,docs,js,sourcemaps", - "--json-errors", - "--quiet", - "--color", - "always", - "src/**/*.purs", - ".spago/p/prelude-6.0.2/src/**/*.purs", - ]); - - assert_eq!(options.build.output, current_directory_path("output")); - assert_eq!(options.codegen.as_deref(), Some("corefn,docs,js,sourcemaps")); - assert!(options.json_errors); - assert!(options.build.quiet); - assert_eq!(options.build.color, ColorChoice::Always); - assert_eq!( - options.inputs, - vec![ - PathBuf::from("src/**/*.purs"), - PathBuf::from(".spago/p/prelude-6.0.2/src/**/*.purs"), - ] - ); - } - - #[test] - fn watch_accepts_project_build_arguments() { - let options = - watch(&["--package", "application", "--output", "dist", "--quiet", "--color", "never"]); - - assert_eq!(options.package.as_deref(), Some("application")); - assert_eq!(options.output, Some(current_directory_path("dist"))); - assert!(options.quiet); - assert_eq!(options.color, ColorChoice::Never); - } - - #[test] - fn build_accepts_resilient_output() { - let options = build(&["--resilient"]); - - assert!(options.resilient); - assert!(!build(&[]).resilient); - } - - #[test] - fn compile_accepts_package_folders_without_inputs() { - let options = - compile(&["--package", "packages/effect", "--package", "packages/prelude", "--quiet"]); - - assert_eq!( - options.packages, - vec![ - current_directory_path("packages/effect"), - current_directory_path("packages/prelude"), - ] - ); - assert!(options.inputs.is_empty()); - assert!(options.build.quiet); - } - - #[test] - fn compile_requires_inputs_or_a_package() { - insta::assert_debug_snapshot!(compile_error_kind(&[]), @"MissingRequired"); - } - - #[test] - fn single_package_folder() { - let options = docs(&["--package", "packages/effect", "--quiet"]); - - assert_eq!(options.packages, vec![current_directory_path("packages/effect")]); - assert!(options.quiet); - } - - #[test] - fn repeated_packages_keep_order() { - let options = docs(&["--package", "packages/effect", "--package", "packages/prelude"]); - - assert_eq!( - options.packages, - vec![ - current_directory_path("packages/effect"), - current_directory_path("packages/prelude"), - ] - ); - } - - #[test] - fn spago_project_replaces_package_specs() { - let options = docs(&["--spago-project", "."]); - - assert_eq!(options.spago_project, Some(current_directory())); - assert!(options.packages.is_empty()); - } - - #[test] - fn spago_project_conflicts_with_package_specs() { - insta::assert_debug_snapshot!( - docs_error_kind(&["--spago-project", ".", "--package", "packages/effect"]), - @"ConflictingFlags" - ); - } - - #[test] - fn later_flags_are_not_consumed_as_package_paths() { - let options = docs(&["--package", "packages/effect", "--output", "out"]); - - assert_eq!(options.output, current_directory_path("out")); - assert_eq!(options.packages, vec![current_directory_path("packages/effect")]); - } - - #[test] - fn relative_paths_are_normalised() { - let options = docs(&["--spago-project", "./spago/..", "--output", "./generated/../docs"]); - - assert_eq!(options.spago_project, Some(current_directory())); - assert_eq!(options.output, current_directory_path("docs")); - } - - #[test] - fn absolute_paths_are_not_resolved_from_the_current_directory() { - let output = std::env::temp_dir().join("iris-docs-output"); - let options = docs(&["--package", "packages/effect", "--output", output.to_str().unwrap()]); - - assert_eq!(options.output, output); - } - - #[test] - fn missing_package_path_is_rejected() { - insta::assert_debug_snapshot!(docs_error_kind(&["--package"]), @"MissingFlagValue"); - } - - #[test] - fn package_is_required() { - insta::assert_debug_snapshot!(docs_error_kind(&[]), @"MissingRequired"); - } - - #[test] - fn typescript_has_default_output() { - let options = typescript(&["typescript"]); - - assert_eq!(options.output, current_directory_path("src-generated")); - } -} diff --git a/compiler-bin/src/cli/diagnostic.rs b/compiler-bin/src/cli/diagnostic.rs deleted file mode 100644 index c983239e9..000000000 --- a/compiler-bin/src/cli/diagnostic.rs +++ /dev/null @@ -1,205 +0,0 @@ -use std::ffi::OsStr; - -use diagnostics::{Diagnostic, Span}; -use itertools::Itertools; -use line_index::LineIndex; -use serde::de::IgnoredAny; -use usage::diagnostic::ArgvSpan; - -use super::{ColorChoice, ConfigurationError, Program}; -use crate::compile::use_color; - -impl Program { - pub fn parse_with_diagnostics() -> Program { - let arguments = std::env::args_os().collect_vec(); - let arguments = arguments.iter().map(|argument| argument.as_os_str()).collect_vec(); - let mut warnings = vec![]; - let error = match Program::try_parse_from_with_warnings(&arguments, &mut warnings) { - Ok(program) => { - eprint!("{}", usage::render_warnings(&warnings)); - return program; - } - Err(error) => error, - }; - - match error { - usage::Error::Help { cmd, long } => { - let page = usage::help::render_styled( - Program::spec(), - cmd, - long, - usage::help::Style::auto(), - ); - print!("{}", page.unwrap_or_default()); - std::process::exit(0); - } - usage::Error::HelpAll { cmd } => { - let page = usage::help::render_all_styled( - Program::spec(), - cmd, - usage::help::Style::auto(), - ); - print!("{}", page.unwrap_or_default()); - std::process::exit(0); - } - usage::Error::Version { .. } => { - println!("iris {}", crate::VERSION); - std::process::exit(0); - } - usage::Error::MissingArgsHelp { cmd } => { - let page = usage::help::render_styled( - Program::spec(), - cmd, - false, - usage::help::Style::auto_stderr(), - ); - eprint!("{}", page.unwrap_or_default()); - } - error => { - let report = usage::diagnostic::report(Program::spec(), &arguments[1..], &error); - let (content, span) = command_line(&arguments[1..], report.location); - let message = report.rendered.strip_prefix("error: ").unwrap_or(&report.rendered); - eprint!( - "{}", - render( - report.code.as_str(), - message.trim_end(), - &content, - "command line", - span - ) - ); - } - } - std::process::exit(2); - } -} - -impl ConfigurationError { - pub(crate) fn render(&self) -> String { - match self { - ConfigurationError::ReadFile { path, error } => { - let content = path.display().to_string(); - render( - "ConfigurationFileRead", - &format!("Cannot read the configuration file: {error}"), - &content, - "--config-file", - Span::new(0, content.len() as u32), - ) - } - ConfigurationError::InvalidJson { input, content, error } => { - let message = error.to_string(); - let location = format!(" at line {} column {}", error.line(), error.column()); - let message = message.strip_suffix(&location).unwrap_or(&message); - render("InvalidConfiguration", message, content, input, json_span(content, error)) - } - } - } -} - -fn render(code: &'static str, message: &str, content: &str, input: &str, span: Span) -> String { - let diagnostic = Diagnostic::error(code, message, span, "cli"); - diagnostics::format_rich_with_path( - &[diagnostic], - content, - &LineIndex::new(content), - input, - use_color(ColorChoice::Auto), - ) -} - -fn command_line(arguments: &[&OsStr], location: Option) -> (String, Span) { - let mut content = "iris".to_string(); - let mut span = None; - for (index, argument) in arguments.iter().enumerate() { - content.push(' '); - let text = argument.to_string_lossy(); - let quoted = text - .chars() - .any(|character| character.is_whitespace() || matches!(character, '"' | '\\' | '\'')); - if quoted { - content.push('"'); - } - if let Some(location) = location.filter(|location| location.index == index) { - let bytes = argument.as_encoded_bytes(); - let start = - String::from_utf8_lossy(&bytes[..location.start]).escape_debug().to_string().len(); - let end = - String::from_utf8_lossy(&bytes[..location.end]).escape_debug().to_string().len(); - span = Some(Span::new((content.len() + start) as u32, (content.len() + end) as u32)); - } - content.extend(text.escape_debug()); - if quoted { - content.push('"'); - } - } - let span = span.unwrap_or_else(|| Span::new(0, content.len() as u32)); - (content, span) -} - -fn json_span(content: &str, error: &serde_json::Error) -> Span { - if error.is_eof() { - // Keep incomplete input visible rather than displaying an empty trailing line. - let end = content.trim_end_matches([' ', '\t', '\r', '\n']).len() as u32; - return Span::new(end, end); - } - let line_start: usize = - content.split_inclusive('\n').take(error.line().saturating_sub(1)).map(str::len).sum(); - let end = (line_start + error.column()).min(content.len()); - - // Data errors point just past the consumed token. Let Serde find token boundaries as - // well, so escaped strings and numbers use the same grammar as configuration parsing. - if error.is_data() { - let mut offset = 0; - while offset < end { - let character = content[offset..].chars().next().unwrap(); - if character.is_ascii_whitespace() - || matches!(character, '{' | '}' | '[' | ']' | ':' | ',') - { - offset += character.len_utf8(); - continue; - } - let mut tokens = - serde_json::Deserializer::from_str(&content[offset..]).into_iter::(); - if !matches!(tokens.next(), Some(Ok(_))) { - break; - } - let token_end = offset + tokens.byte_offset(); - if end <= token_end { - return Span::new(offset as u32, token_end as u32); - } - offset = token_end; - } - } - - let start = content.floor_char_boundary(end.saturating_sub(1)); - let end = start + content[start..].chars().next().map_or(0, char::len_utf8); - Span::new(start as u32, end as u32) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn command_line_spans_follow_escaped_byte_offsets() { - let arguments = [OsStr::new("--option"), OsStr::new("λ\n\"bad\"")]; - let location = ArgvSpan { index: 1, start: 3, end: 8 }; - let (content, span) = command_line(&arguments, Some(location)); - assert_eq!(content, r#"iris --option "λ\n\"bad\"""#); - assert_eq!(&content[span.start as usize..span.end as usize], r#"\"bad\""#); - } - - #[cfg(unix)] - #[test] - fn command_line_spans_follow_lossy_arguments() { - use std::os::unix::ffi::OsStrExt; - - let arguments = [OsStr::from_bytes(b"--option=\xffbad")]; - let location = ArgvSpan { index: 0, start: 10, end: 13 }; - let (content, span) = command_line(&arguments, Some(location)); - assert_eq!(content, "iris --option=�bad"); - assert_eq!(&content[span.start as usize..span.end as usize], "bad"); - } -} diff --git a/compiler-bin/src/compilation.rs b/compiler-bin/src/compilation.rs deleted file mode 100644 index de0098c65..000000000 --- a/compiler-bin/src/compilation.rs +++ /dev/null @@ -1,195 +0,0 @@ -use std::sync::Arc; - -use building::{ - DiskObservation, FileLifecycle, ForeignEvent, LifecycleChange, LifecycleEvent, QueryEngine, - QueryError, SourceEvent, SourceUnitKey, -}; -use files::{FileId, ForeignSourceKind}; -use prim_constants::MODULE_MAP; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum SourceRole { - Prim, - Input, -} - -pub struct CompilationState { - engine: QueryEngine, - files: FileLifecycle<(), SourceRole>, -} - -impl CompilationState { - pub fn new() -> CompilationState { - let engine = QueryEngine::default(); - let mut files = FileLifecycle::default(); - - for (name, content) in MODULE_MAP { - let source = format!("prim://localhost/{name}.purs"); - let foreign = format!("prim://localhost/{name}.js"); - let event = LifecycleEvent::Source { - unit: SourceUnitKey::new(source, foreign), - event: SourceEvent::DiskObserved { - disk: DiskObservation::Found(Arc::from(*content)), - metadata: SourceRole::Prim, - }, - }; - - let change = files.apply(&engine, event); - let id = change - .changed_sources() - .next() - .expect("invariant violated: Prim source lifecycle did not insert a source"); - engine.set_module_file(name, id); - } - - CompilationState { engine, files } - } - - pub fn observe_source( - &mut self, - unit: SourceUnitKey, - disk: DiskObservation, - ) -> LifecycleChange { - let event = LifecycleEvent::Source { - unit, - event: SourceEvent::DiskObserved { disk, metadata: SourceRole::Input }, - }; - self.files.apply(&self.engine, event) - } - - pub fn observe_foreign( - &mut self, - unit: SourceUnitKey, - kind: ForeignSourceKind, - disk: DiskObservation, - ) -> LifecycleChange { - let event = - LifecycleEvent::Foreign { unit, kind, event: ForeignEvent::DiskObserved { disk } }; - self.files.apply(&self.engine, event) - } - - pub fn input_source_ids(&self) -> Vec { - self.files - .source_ids() - .filter(|&file_id| self.files.source_metadata(file_id) == Some(&SourceRole::Input)) - .collect() - } - - pub fn snapshot(&self) -> QueryEngine { - self.engine.snapshot() - } - - pub fn source_path(&self, file_id: FileId) -> Option> { - self.files.source_path(file_id) - } - - pub fn source_content(&self, locator: &str) -> Result>, QueryError> { - let Some(file_id) = self.files.source_id(locator) else { - return Ok(None); - }; - self.engine.content(file_id).map(Some) - } - - pub fn foreign_content(&self, locator: &str) -> Option> { - let foreign_id = self.files.foreign_id(locator)?; - let content = self - .engine - .foreign_content(foreign_id) - .expect("invariant violated: lifecycle foreign file has no engine content"); - Some(content) - } - - pub fn source_foreign_content(&self, source_id: FileId) -> Option> { - let foreign_id = self.engine.foreign_file(source_id)?; - let content = self - .engine - .foreign_content(foreign_id) - .expect("invariant violated: associated foreign file has no engine content"); - Some(content) - } - - pub fn module_name(&self, locator: &str) -> Result, QueryError> { - let Some(file_id) = self.files.source_id(locator) else { - return Ok(None); - }; - let engine = self.engine.snapshot(); - let content = engine.content(file_id)?; - let (parsed, _) = engine.parsed(file_id)?; - Ok(parsed.module_name(&content).map(|name| name.to_string())) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn unit(name: &str) -> SourceUnitKey { - SourceUnitKey::new(format!("file:///src/{name}.purs"), format!("file:///src/{name}.js")) - } - - #[test] - fn prim_modules_are_not_input_sources() { - let compilation = CompilationState::new(); - - assert!(compilation.input_source_ids().is_empty()); - assert!(compilation.snapshot().module_file("Prim").is_some()); - } - - #[test] - fn source_observations_preserve_identity_until_deletion() { - let mut compilation = CompilationState::new(); - let unit = unit("Main"); - let content = DiskObservation::Found(Arc::from("module Main where\n")); - compilation.observe_source(SourceUnitKey::clone(&unit), content); - let original = compilation.input_source_ids()[0]; - - let content = DiskObservation::Found(Arc::from("module Main where\n\nvalue = 1\n")); - compilation.observe_source(SourceUnitKey::clone(&unit), content); - assert_eq!(compilation.input_source_ids(), vec![original]); - - compilation.observe_source(SourceUnitKey::clone(&unit), DiskObservation::NotFound); - assert!(compilation.input_source_ids().is_empty()); - - let content = DiskObservation::Found(Arc::from("module Main where\n")); - compilation.observe_source(unit, content); - assert_ne!(compilation.input_source_ids(), vec![original]); - } - - #[test] - fn foreign_observations_are_associated_with_the_input_source() { - let mut compilation = CompilationState::new(); - let unit = unit("Main"); - let content = DiskObservation::Found(Arc::from("module Main where\n")); - compilation.observe_source(SourceUnitKey::clone(&unit), content); - let source_id = compilation.input_source_ids()[0]; - - let content = DiskObservation::Found(Arc::from("export const value = 1;\n")); - let change = compilation.observe_foreign(unit, ForeignSourceKind::JavaScript, content); - - assert_eq!(change.changed_sources().collect::>(), vec![source_id]); - assert!(compilation.snapshot().foreign_file(source_id).is_some()); - assert_eq!( - compilation.source_foreign_content(source_id).as_deref(), - Some("export const value = 1;\n") - ); - } - - #[test] - fn source_queries_preserve_missing_engine_content_errors() { - let mut compilation = CompilationState::new(); - let unit = unit("Main"); - let content = DiskObservation::Found(Arc::from("module Main where\n")); - compilation.observe_source(SourceUnitKey::clone(&unit), content); - let source_id = compilation.input_source_ids()[0]; - compilation.engine.remove_file(source_id); - - assert_eq!( - compilation.source_content(unit.source()), - Err(QueryError::MissingContent { file_id: source_id }) - ); - assert_eq!( - compilation.module_name(unit.source()), - Err(QueryError::MissingContent { file_id: source_id }) - ); - } -} diff --git a/compiler-bin/src/compile.rs b/compiler-bin/src/compile.rs deleted file mode 100644 index c068b7423..000000000 --- a/compiler-bin/src/compile.rs +++ /dev/null @@ -1,391 +0,0 @@ -use std::collections::{BTreeSet, HashSet}; -use std::io::IsTerminal; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use std::time::Instant; -use std::{fs, io, process}; - -use building::{DiskObservation, QueryError, SourceUnitKey}; -use diagnostics::Severity; -use files::{FileId, ForeignSourceKind}; -use indicatif::MultiProgress; -use rayon::prelude::*; -use thiserror::Error; -use url::Url; - -use crate::cli::ColorChoice; -use crate::compilation::CompilationState; -use crate::{package, progress, walk}; - -pub struct CompileConfig { - pub output: PathBuf, - pub inputs: Vec, - pub packages: Vec, - pub json_errors: bool, - pub quiet: bool, - pub color: ColorChoice, -} - -pub(crate) struct BuildConfig<'a> { - pub output: &'a Path, - pub current_directory: &'a Path, - pub color: bool, - pub progress: bool, - pub resilience: Resilience, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Resilience { - Strict, - Resilient, -} - -#[derive(Debug, PartialEq, Eq)] -pub(crate) enum BuildOutcome { - Succeeded(BTreeSet), - Diagnostics, -} - -#[derive(Debug, Error)] -pub(crate) enum CompileError { - #[error("compilation failed")] - Diagnostics, - #[error("failed to convert path to a file URL: {0}")] - InvalidPath(PathBuf), - #[error(transparent)] - Io(#[from] io::Error), - #[error(transparent)] - Package(#[from] package::PackageError), - #[error(transparent)] - Query(#[from] QueryError), - #[error(transparent)] - Walk(#[from] walk::Error), -} - -pub fn start(config: CompileConfig) { - let json_errors = config.json_errors; - if let Err(error) = compile(config) { - if !matches!(error, CompileError::Diagnostics) { - eprintln!("Compilation exited: {error}"); - } - tracing::error!(?error, "Compilation exited"); - if json_errors { - println!( - r#"{{"warnings":[],"errors":[{{"message":{message:?}}}]}}"#, - message = error.to_string() - ); - } - process::exit(1); - } - - if json_errors { - println!(r#"{{"warnings":[],"errors":[]}}"#); - } -} - -fn compile(config: CompileConfig) -> Result<(), CompileError> { - let started = Instant::now(); - let preparation_progress = progress::bar(1, "Preparing", !config.quiet); - let current_directory = std::env::current_dir()?; - let walked = walk::walk(¤t_directory, &config.inputs)?; - - let mut source_paths = walked.files.into_iter().collect::>(); - for package in &config.packages { - source_paths.extend(package::source_files(¤t_directory, package)?); - } - - let build_config = BuildConfig { - output: &config.output, - current_directory: ¤t_directory, - color: use_color(config.color), - progress: !config.quiet, - resilience: Resilience::Strict, - }; - compile_source_paths(&build_config, source_paths, started, preparation_progress) -} - -pub(crate) fn compile_inputs( - root: &Path, - output: &Path, - inputs: &[PathBuf], - quiet: bool, - color: ColorChoice, - resilience: Resilience, -) -> Result<(), CompileError> { - let started = Instant::now(); - let preparation_progress = progress::bar(1, "Preparing", !quiet); - let walked = walk::walk(root, inputs)?; - let source_paths = walked.files.into_iter().collect::>(); - - let build_config = BuildConfig { - output, - current_directory: root, - color: use_color(color), - progress: !quiet, - resilience, - }; - compile_source_paths(&build_config, source_paths, started, preparation_progress) -} - -fn compile_source_paths( - config: &BuildConfig<'_>, - source_paths: BTreeSet, - started: Instant, - preparation_progress: indicatif::ProgressBar, -) -> Result<(), CompileError> { - let mut compilation = CompilationState::new(); - - for path in source_paths { - load_source(&mut compilation, &path)?; - } - let source_ids = compilation.input_source_ids(); - preparation_progress.inc(1); - progress::finish(&preparation_progress); - - if source_ids.is_empty() { - return Err(io::Error::new(io::ErrorKind::NotFound, "no input files found").into()); - } - - if matches!(build(&compilation, config)?, BuildOutcome::Diagnostics) { - return Err(CompileError::Diagnostics); - } - - if config.progress { - progress::report_completion(started.elapsed()); - } - - Ok(()) -} - -pub(crate) fn build( - compilation: &CompilationState, - config: &BuildConfig<'_>, -) -> Result { - let source_ids = compilation.input_source_ids(); - let has_errors = report_diagnostics(compilation, &source_ids, config)?; - if has_errors && config.resilience == Resilience::Strict { - return Ok(BuildOutcome::Diagnostics); - } - - let modules = collect_modules(compilation, &source_ids)?; - let outputs = write_modules(compilation, &modules, config.output, config.progress)?; - if has_errors { Ok(BuildOutcome::Diagnostics) } else { Ok(BuildOutcome::Succeeded(outputs)) } -} - -pub(crate) fn load_source( - compilation: &mut CompilationState, - path: &Path, -) -> Result<(), CompileError> { - let source_url = - Url::from_file_path(path).map_err(|()| CompileError::InvalidPath(path.to_path_buf()))?; - let foreign_path = path.with_extension("js"); - let foreign_url = Url::from_file_path(&foreign_path) - .map_err(|()| CompileError::InvalidPath(foreign_path.clone()))?; - - let unit = SourceUnitKey::new(source_url.as_str(), foreign_url.as_str()); - - let content = fs::read_to_string(path)?; - compilation.observe_source(SourceUnitKey::clone(&unit), DiskObservation::Found(content.into())); - - for kind in ForeignSourceKind::ALL { - let foreign_path = path.with_extension(kind.extension()); - let disk = match fs::read_to_string(&foreign_path) { - Ok(content) => DiskObservation::Found(content.into()), - Err(error) if error.kind() == io::ErrorKind::NotFound => DiskObservation::NotFound, - Err(error) => return Err(error.into()), - }; - compilation.observe_foreign(SourceUnitKey::clone(&unit), kind, disk); - } - Ok(()) -} - -pub(crate) fn use_color(choice: ColorChoice) -> bool { - match choice { - ColorChoice::Auto => { - let no_color = std::env::var_os("NO_COLOR").is_some_and(|value| !value.is_empty()); - io::stderr().is_terminal() && !no_color - } - ColorChoice::Always => true, - ColorChoice::Never => false, - } -} - -fn display_source_path(source_path: &str, current_directory: &Path) -> String { - if let Some(file_path) = Url::parse(source_path).ok().and_then(|url| url.to_file_path().ok()) { - file_path.strip_prefix(current_directory).unwrap_or(&file_path).display().to_string() - } else { - source_path.to_owned() - } -} - -fn report_diagnostics( - compilation: &CompilationState, - source_ids: &[FileId], - config: &BuildConfig<'_>, -) -> Result { - let progress = MultiProgress::new(); - progress.set_move_cursor(true); - let analysing_progress = - progress::phase(&progress, source_ids.len(), "Analyse", config.progress); - let checking_progress = - progress::phase(&progress, source_ids.len(), "Elaborate", config.progress); - let generating_progress = - progress::phase(&progress, source_ids.len(), "Generate", config.progress); - - let module_names = source_ids.par_iter().map(|&file_id| { - let engine = compilation.snapshot(); - let content = engine.content(file_id)?; - let (parsed, _) = engine.parsed(file_id)?; - let module_name = parsed.module_name(&content); - if let Some(module_name) = &module_name { - progress::set_message(&analysing_progress, module_name); - } - engine.stabilized(file_id)?; - engine.indexed(file_id)?; - engine.resolved(file_id)?; - engine.lowered(file_id)?; - analysing_progress.inc(1); - Ok::<_, CompileError>(module_name) - }); - let module_names = module_names.collect::, _>>()?; - progress::finish(&analysing_progress); - - let elaborated = source_ids.par_iter().zip(&module_names).map(|(&file_id, module_name)| { - let engine = compilation.snapshot(); - if let Some(module_name) = module_name { - progress::set_message(&checking_progress, module_name); - } - engine.checked(file_id)?; - engine.foreign_validation(file_id)?; - checking_progress.inc(1); - Ok::<_, CompileError>(()) - }); - elaborated.collect::, _>>()?; - progress::finish(&checking_progress); - - let generated = source_ids.par_iter().zip(&module_names).map(|(&file_id, module_name)| { - let engine = compilation.snapshot(); - if let Some(module_name) = module_name { - progress::set_message(&generating_progress, module_name); - } - let _ = engine.javascript(file_id)?; - generating_progress.inc(1); - Ok::<_, CompileError>(()) - }); - generated.collect::, _>>()?; - progress::finish(&generating_progress); - - let engine = compilation.snapshot(); - let diagnostics = diagnostics::collect_diagnostics(&engine, source_ids)?; - let has_errors = diagnostics - .iter() - .flat_map(diagnostics::DiagnosticCollection::diagnostics) - .any(|diagnostic| diagnostic.severity == Severity::Error); - let has_diagnostics = diagnostics.iter().any(|collected| !collected.diagnostics().is_empty()); - - if has_diagnostics { - if config.progress && io::stderr().is_terminal() { - eprint!("\n\n"); - } else if !config.progress { - eprintln!(); - } - } - for collected in diagnostics { - if collected.diagnostics().is_empty() { - continue; - } - let source_path = - compilation.source_path(collected.file_id).expect("input source has no lifecycle path"); - let display_path = display_source_path(&source_path, config.current_directory); - let line_index = line_index::LineIndex::new(&collected.content); - let rendered = diagnostics::format_rich_with_path( - collected.diagnostics(), - &collected.content, - &line_index, - &display_path, - config.color, - ); - eprint!("{rendered}"); - } - Ok(has_errors) -} - -fn collect_modules( - compilation: &CompilationState, - source_ids: &[FileId], -) -> Result>, CompileError> { - let mut pending = source_ids.to_vec(); - let mut visited = HashSet::new(); - - let mut modules = vec![]; - while !pending.is_empty() { - let frontier = pending.drain(..).filter(|file_id| visited.insert(*file_id)); - let frontier = frontier.collect::>(); - - let generated = frontier.par_iter().map(|&file_id| { - let engine = compilation.snapshot(); - let module = engine.javascript(file_id)?.ok(); - Ok::<_, CompileError>(module) - }); - let generated = generated.collect::, _>>()?; - - for module in generated.into_iter().flatten() { - pending.extend(module.dependencies().iter().copied()); - modules.push(module); - } - } - - Ok(modules) -} - -fn write_modules( - compilation: &CompilationState, - modules: &[Arc], - output: &Path, - show_progress: bool, -) -> Result, CompileError> { - let mut outputs = BTreeSet::new(); - if modules.iter().any(|module| module.requires_runtime()) { - let runtime = output.join(javascript::runtime_filename()); - fs::create_dir_all(output)?; - write_if_changed(&runtime, javascript::runtime_source().as_bytes())?; - outputs.insert(runtime); - } - - let progress = progress::bar(modules.len(), "Write", show_progress); - let module_outputs = modules.par_iter().map(|module| -> Result, CompileError> { - progress::set_message(&progress, module.name()); - let output_path = output.join(module.filename()); - let output_parent = - output_path.parent().expect("invariant violated: module filename has no parent"); - - fs::create_dir_all(output_parent)?; - write_if_changed(&output_path, module.source().as_bytes())?; - let mut outputs = vec![output_path]; - - if let Some(kind) = module.foreign_kind() { - let output_path = output.join(javascript::foreign_module_filename(module.name(), kind)); - let foreign = compilation - .source_foreign_content(module.file_id()) - .expect("invariant violated: generated module requires missing foreign content"); - write_if_changed(&output_path, foreign.as_bytes())?; - outputs.push(output_path); - } - - progress.inc(1); - Ok(outputs) - }); - let module_outputs = module_outputs.collect::, _>>()?; - outputs.extend(module_outputs.into_iter().flatten()); - progress::finish(&progress); - Ok(outputs) -} - -fn write_if_changed(path: &Path, content: &[u8]) -> io::Result<()> { - match fs::read(path) { - Ok(previous) if previous == content => Ok(()), - Ok(_) => fs::write(path, content), - Err(error) if error.kind() == io::ErrorKind::NotFound => fs::write(path, content), - Err(error) => Err(error), - } -} diff --git a/compiler-bin/src/docs.rs b/compiler-bin/src/docs.rs deleted file mode 100644 index 8a904b120..000000000 --- a/compiler-bin/src/docs.rs +++ /dev/null @@ -1,740 +0,0 @@ -pub mod error; -mod location; - -use std::collections::BTreeMap; -use std::path::{Component, Path, PathBuf}; -use std::time::Instant; -use std::{env, fs, process}; - -use building::{QueryEngine, prim}; -use documentation::schema::Location; -use files::{FileId, Files}; -use indicatif::MultiProgress; -use itertools::Itertools; -use rayon::iter::{IndexedParallelIterator, IntoParallelRefIterator, ParallelIterator}; -use serde::Deserialize; - -use crate::docs::error::DocsError; -use crate::docs::location::{manifest_location, package_reference_location}; -use crate::{package, progress}; - -pub struct DocsConfig { - pub output: PathBuf, - pub spago_project: Option, - pub packages: Vec, - pub quiet: bool, -} - -pub struct TypeScriptConfig { - pub output: PathBuf, -} - -pub fn start(config: DocsConfig) { - if let Err(error) = generate_documentation(config) { - eprintln!("Documentation exited: {error}"); - tracing::error!(?error, "Documentation exited"); - process::exit(1); - } -} - -pub fn typescript(config: TypeScriptConfig) { - if let Err(error) = write_typescript(config) { - eprintln!("TypeScript schema generation exited: {error}"); - tracing::error!(?error, "TypeScript schema generation exited"); - process::exit(1); - } -} - -fn write_typescript(config: TypeScriptConfig) -> Result<(), DocsError> { - documentation::export_typescript(config.output)?; - Ok(()) -} - -#[derive(Default)] -struct Compiler { - files: Files, - engine: QueryEngine, -} - -struct Package { - name: String, - version: String, - license: Option, - description: Option, - dependencies: BTreeMap, - location: Option, - modules: Vec, -} - -struct RenderedPackage { - manifest: documentation::schema::Package, - modules: Vec, -} - -#[derive(Debug, Default)] -struct PackageMetadata { - name: Option, - version: Option, - license: Option, - description: Option, - include_files: Vec, - exclude_files: Vec, - dependencies: BTreeMap, - location: Option, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct PursManifest { - name: String, - version: String, - license: Option, - description: Option, - #[serde(default)] - include_files: Vec, - #[serde(default)] - exclude_files: Vec, - #[serde(default)] - dependencies: BTreeMap, - #[serde(default)] - location: Option, - #[serde(default, rename = "ref")] - reference: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(untagged)] -enum PursLocation { - GitHub { - #[serde(rename = "githubOwner")] - owner: String, - #[serde(rename = "githubRepo")] - repository: String, - subdir: Option, - }, - Git { - #[serde(rename = "gitUrl")] - url: String, - subdir: Option, - }, -} - -fn generate_documentation(config: DocsConfig) -> Result<(), DocsError> { - let started = Instant::now(); - let show_progress = !config.quiet; - let preparation_progress = progress::bar(1, "Preparing", show_progress); - let mut compiler = Compiler::default(); - prim::configure(&mut compiler.engine, &mut compiler.files); - - let packages = load_packages(&config, &mut compiler)?; - let modules = package_modules(&packages); - preparation_progress.inc(1); - progress::finish(&preparation_progress); - - prepare_documentation_queries(&compiler.engine, &modules, show_progress)?; - let rendered = render_documentation(&compiler.engine, &packages, show_progress)?; - write_documentation(&config.output, &rendered, show_progress)?; - - if show_progress { - progress::report_completion(started.elapsed()); - } - - Ok(()) -} - -fn package_modules(packages: &[Package]) -> Vec { - packages.iter().flat_map(|package| package.modules.iter().copied()).collect_vec() -} - -fn prepare_documentation_queries( - engine: &QueryEngine, - modules: &[FileId], - show_progress: bool, -) -> Result<(), DocsError> { - let progress = MultiProgress::new(); - progress.set_move_cursor(true); - let analysing_progress = progress::phase(&progress, modules.len(), "Analyse", show_progress); - let elaborating_progress = - progress::phase(&progress, modules.len(), "Elaborate", show_progress); - - let analysed = modules.par_iter().map(|&file_id| { - let engine = engine.snapshot(); - let module_name = analyse_module(&engine, file_id)?; - if let Some(module_name) = &module_name { - progress::set_message(&analysing_progress, module_name); - } - analysing_progress.inc(1); - Ok::<_, DocsError>(module_name) - }); - let module_names = analysed.collect::, _>>()?; - progress::finish(&analysing_progress); - - let elaborated = modules.par_iter().zip(&module_names).map(|(&file_id, module_name)| { - let engine = engine.snapshot(); - if let Some(module_name) = module_name { - progress::set_message(&elaborating_progress, module_name); - } - engine.checked(file_id)?; - elaborating_progress.inc(1); - Ok::<_, DocsError>(()) - }); - elaborated.collect::, _>>()?; - progress::finish(&elaborating_progress); - - Ok(()) -} - -fn analyse_module(engine: &QueryEngine, file_id: FileId) -> Result, DocsError> { - let content = engine.content(file_id)?; - let (parsed, _) = engine.parsed(file_id)?; - let module_name = parsed.module_name(&content).map(|name| name.to_string()); - - engine.indexed(file_id)?; - engine.resolved(file_id)?; - engine.lowered(file_id)?; - engine.grouped(file_id)?; - engine.bracketed(file_id)?; - engine.sectioned(file_id)?; - - Ok(module_name) -} - -fn load_packages(config: &DocsConfig, compiler: &mut Compiler) -> Result, DocsError> { - if let Some(spago_project) = &config.spago_project { - return load_packages_from_spago_project(spago_project, compiler); - } - - let root = env::current_dir()?; - - let mut packages = vec![]; - for path in &config.packages { - let package = load_package_from_folder(compiler, &root, path, None, None, None)?; - packages.push(package); - } - - populate_module_file(compiler)?; - - Ok(packages) -} - -fn load_packages_from_spago_project( - spago_project: &Path, - compiler: &mut Compiler, -) -> Result, DocsError> { - let mut packages = vec![]; - let packages_by_source = spago::source_files_by_package(spago_project)?; - - for (name, sources) in packages_by_source { - let name = name.to_string(); - let version = package_version(&sources.reference); - let location = package_reference_location(&sources.reference); - let package = if let Some(package_root) = sources - .roots - .iter() - .rev() - .find(|root| spago_project.join(root).join("purs.json").is_file()) - { - load_package_from_folder( - compiler, - spago_project, - package_root, - Some(name.clone()), - Some(version.clone()), - location.clone(), - )? - } else { - validate_package_name(&name)?; - - let modules = load_modules(compiler, sources.sources)?; - Package { - name, - version, - license: None, - description: None, - dependencies: BTreeMap::new(), - location, - modules, - } - }; - - packages.push(package); - } - - populate_module_file(compiler)?; - - Ok(packages) -} - -fn load_package_from_folder( - compiler: &mut Compiler, - root: &Path, - path: &Path, - name: Option, - version: Option, - location: Option, -) -> Result { - let package_root = root.join(path); - let metadata = load_package_metadata(&package_root)?; - let files = package::source_files_with_globs( - root, - path, - &metadata.include_files, - &metadata.exclude_files, - )?; - - let name = metadata.name.or(name).unwrap_or_else(|| fallback_package_name(path)); - validate_package_name(&name)?; - - let version = metadata.version.or(version).unwrap_or_else(|| "0.0.0".to_owned()); - let location = metadata.location.or(location); - let modules = load_modules(compiler, files)?; - - Ok(Package { - name, - version, - license: metadata.license, - description: metadata.description, - dependencies: metadata.dependencies, - location, - modules, - }) -} - -fn load_package_metadata(package_root: &Path) -> Result { - let manifest = package_root.join("purs.json"); - if !manifest.exists() { - return Ok(PackageMetadata::default()); - } - - let manifest = fs::read_to_string(manifest)?; - let manifest: PursManifest = serde_json::from_str(&manifest)?; - let location = manifest_location(manifest.location, manifest.reference); - - Ok(PackageMetadata { - name: Some(manifest.name), - version: Some(manifest.version), - license: manifest.license, - description: manifest.description, - include_files: manifest.include_files, - exclude_files: manifest.exclude_files, - dependencies: manifest.dependencies, - location, - }) -} - -fn fallback_package_name(path: &Path) -> String { - path.file_name() - .and_then(|name| name.to_str()) - .filter(|name| !name.is_empty()) - .unwrap_or("package") - .to_string() -} - -fn validate_package_name(name: &str) -> Result<(), DocsError> { - if !is_single_path_segment(name) { - return Err(DocsError::InvalidPackageName(name.to_owned())); - } - - Ok(()) -} - -fn is_single_path_segment(path: &str) -> bool { - if path.contains('/') || path.contains('\\') { - return false; - } - - let mut components = Path::new(path).components(); - let first_component = components.next(); - let extra_component = components.next(); - - matches!(first_component, Some(Component::Normal(_))) && extra_component.is_none() -} - -fn package_version(reference: &spago::PackageReference) -> String { - match reference { - spago::PackageReference::Workspace | spago::PackageReference::Local => "0.0.0".to_owned(), - spago::PackageReference::Git { version, .. } => version.to_string(), - spago::PackageReference::Registry { version } => version.to_string(), - } -} - -fn render_documentation( - engine: &QueryEngine, - packages: &[Package], - show_progress: bool, -) -> Result, DocsError> { - let package_by_file = packages.iter().flat_map(|package| { - package.modules.iter().map(|&file_id| (file_id, package.name.as_str())) - }); - let package_by_file = package_by_file.collect_vec(); - let module_counts = packages.iter().map(|package| package.modules.len()); - let module_count = module_counts.sum(); - let progress = progress::bar(module_count, "Document", show_progress); - - let mut rendered = vec![]; - for package in packages { - let package_input = documentation::PackageInput { - name: &package.name, - version: &package.version, - license: package.license.as_deref(), - description: package.description.as_deref(), - dependencies: &package.dependencies, - location: package.location.as_ref(), - modules: &package.modules, - }; - let manifest = documentation::render_package_manifest(engine, &package_input)?; - - progress::set_message(&progress, &package.name); - let modules = package.modules.par_iter().map(|&file_id| { - let engine = engine.snapshot(); - let module = documentation::render_module(&engine, file_id, &package_by_file)?; - if let Some(module) = &module { - progress::set_message(&progress, &module.name); - } - progress.inc(1); - Ok::<_, DocsError>(module) - }); - let modules = modules.collect::, _>>()?; - let modules = modules.into_iter().flatten().collect_vec(); - - rendered.push(RenderedPackage { manifest, modules }); - } - progress::finish(&progress); - - Ok(rendered) -} - -fn write_documentation( - output: &Path, - packages: &[RenderedPackage], - show_progress: bool, -) -> Result<(), DocsError> { - if output.exists() { - fs::remove_dir_all(output)?; - } - - let file_counts = packages.iter().map(|package| package.modules.len() + 1); - let file_count = file_counts.sum(); - let progress = progress::bar(file_count, "Output", show_progress); - - for package in packages { - let package_folder = output.join(&package.manifest.name); - let modules_folder = package_folder.join("modules"); - fs::create_dir_all(&modules_folder)?; - - progress::set_message(&progress, &package.manifest.name); - let manifest_file = package_folder.join("manifest.json"); - let manifest = serde_json::to_string(&package.manifest)?; - fs::write(manifest_file, manifest)?; - progress.inc(1); - - for module in &package.modules { - progress::set_message(&progress, &module.name); - let module_file = modules_folder.join(format!("{}.json", module.name)); - let module = serde_json::to_string_pretty(module)?; - - fs::write(module_file, module)?; - progress.inc(1); - } - } - progress::finish(&progress); - - Ok(()) -} - -fn load_modules(compiler: &mut Compiler, files: Vec) -> Result, DocsError> { - let mut modules = vec![]; - - for file in &files { - if file.extension().is_none_or(|extension| extension != "purs") { - continue; - } - - let url = url::Url::from_file_path(file).map_err(|_| { - let file = PathBuf::clone(file); - DocsError::PathParseFail(file) - })?; - - let uri = url.as_str(); - let text = fs::read_to_string(file)?; - - let id = compiler.files.insert(uri, &*text); - compiler.engine.set_content(id, &*text); - - modules.push(id); - } - - Ok(modules) -} - -fn populate_module_file(compiler: &mut Compiler) -> Result<(), DocsError> { - let results = compiler.files.par_iter_id().map(|id| { - let content = compiler.engine.content(id)?; - let (parsed, _) = compiler.engine.parsed(id)?; - Ok((id, content, parsed)) - }); - - let results = results.collect::, DocsError>>()?; - let mut module_files = BTreeMap::new(); - for (id, content, parsed) in results { - if let Some(name) = parsed.module_name(&content) { - let name = name.to_string(); - if module_files.insert(String::clone(&name), id).is_some() { - return Err(DocsError::DuplicateModuleName(name)); - } - } - } - - for (name, id) in module_files { - compiler.engine.set_module_file(&name, id); - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::sync::atomic::{AtomicUsize, Ordering}; - use std::time::{SystemTime, UNIX_EPOCH}; - - static TEMPORARY_DIRECTORY_INDEX: AtomicUsize = AtomicUsize::new(0); - - fn temporary_directory() -> PathBuf { - let nanos = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); - let index = TEMPORARY_DIRECTORY_INDEX.fetch_add(1, Ordering::Relaxed); - let directory = std::env::temp_dir().join(format!("iris-docs-{nanos}-{index}")); - fs::create_dir_all(&directory).unwrap(); - directory - } - - #[test] - fn purs_manifest_supplies_package_metadata() { - let root = temporary_directory(); - fs::write( - root.join("purs.json"), - r#"{ - "name": "effect", - "version": "4.0.0", - "license": "BSD-3-Clause", - "description": "Effect package", - "location": { - "githubOwner": "purescript", - "githubRepo": "purescript-effect" - }, - "ref": "v4.0.0", - "includeFiles": ["examples/**/*.purs"], - "excludeFiles": ["test/Excluded.purs"], - "dependencies": { "prelude": ">=6.0.0 <7.0.0" } - }"#, - ) - .unwrap(); - - let metadata = load_package_metadata(&root).unwrap(); - - insta::assert_debug_snapshot!(metadata, @r#" - PackageMetadata { - name: Some( - "effect", - ), - version: Some( - "4.0.0", - ), - license: Some( - "BSD-3-Clause", - ), - description: Some( - "Effect package", - ), - include_files: [ - "examples/**/*.purs", - ], - exclude_files: [ - "test/Excluded.purs", - ], - dependencies: { - "prelude": ">=6.0.0 <7.0.0", - }, - location: Some( - GitHub { - url: "https://github.com/purescript/purescript-effect", - owner: "purescript", - repository: "purescript-effect", - reference: Some( - "v4.0.0", - ), - subdir: None, - }, - ), - } - "#); - - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn git_manifest_location_detects_github_urls() { - let root = temporary_directory(); - fs::write( - root.join("purs.json"), - r#"{ - "name": "effect", - "version": "4.0.0", - "license": "BSD-3-Clause", - "location": { - "gitUrl": "https://github.com/purescript/purescript-effect.git", - "subdir": "packages/effect" - }, - "ref": "v4.0.0", - "dependencies": {} - }"#, - ) - .unwrap(); - - let metadata = load_package_metadata(&root).unwrap(); - - insta::assert_debug_snapshot!(metadata.location, @r#" - Some( - GitHub { - url: "https://github.com/purescript/purescript-effect", - owner: "purescript", - repository: "purescript-effect", - reference: Some( - "v4.0.0", - ), - subdir: Some( - "packages/effect", - ), - }, - ) - "#); - - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn git_manifest_location_preserves_generic_git_urls() { - let root = temporary_directory(); - fs::write( - root.join("purs.json"), - r#"{ - "name": "image", - "version": "1.0.0", - "license": "BSD-3-Clause", - "location": { - "gitUrl": "https://example.com/purefunctor/purescript-package.git", - "subdir": "libs/package" - }, - "ref": "v1.0.0", - "dependencies": {} - }"#, - ) - .unwrap(); - - let metadata = load_package_metadata(&root).unwrap(); - - insta::assert_debug_snapshot!(metadata.location, @r#" - Some( - Git { - url: "https://example.com/purefunctor/purescript-package.git", - reference: Some( - "v1.0.0", - ), - subdir: Some( - "libs/package", - ), - }, - ) - "#); - - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn package_names_must_be_single_path_segments() { - assert!(validate_package_name("effect").is_ok()); - assert!(matches!( - validate_package_name("../effect"), - Err(DocsError::InvalidPackageName(_)) - )); - assert!(matches!( - validate_package_name("scope/effect"), - Err(DocsError::InvalidPackageName(_)) - )); - assert!(matches!(validate_package_name("."), Err(DocsError::InvalidPackageName(_)))); - } - - #[test] - fn missing_package_folders_are_rejected() { - let root = temporary_directory(); - let mut compiler = Compiler::default(); - - assert!(matches!( - load_package_from_folder(&mut compiler, &root, Path::new("missing"), None, None, None), - Err(DocsError::PackageError(package::PackageError::MissingFolder(_))) - )); - - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn documentation_generation_clears_stale_output_files() { - let root = temporary_directory(); - let package = root.join("effect"); - let source = package.join("src/Main.purs"); - let output = root.join("generated"); - - fs::create_dir_all(source.parent().unwrap()).unwrap(); - fs::create_dir_all(&output).unwrap(); - fs::write(package.join("purs.json"), r#"{ "name": "effect", "version": "1.0.0" }"#) - .unwrap(); - fs::write(source, "module Main where\n").unwrap(); - fs::write(output.join("stale.json"), "{}").unwrap(); - - generate_documentation(DocsConfig { - output: output.clone(), - spago_project: None, - packages: vec![package], - quiet: true, - }) - .unwrap(); - - assert!(!output.join("stale.json").exists()); - assert!(output.join("effect/manifest.json").is_file()); - - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn duplicate_module_names_are_rejected_before_populating_the_engine() { - let root = temporary_directory(); - let first = root.join("src/First.purs"); - let second = root.join("src/Second.purs"); - fs::create_dir_all(first.parent().unwrap()).unwrap(); - fs::write(&first, "module Main where\n").unwrap(); - fs::write(&second, "module Main where\n").unwrap(); - - let mut compiler = Compiler::default(); - load_modules(&mut compiler, vec![first, second]).unwrap(); - - assert!(matches!( - populate_module_file(&mut compiler), - Err(DocsError::DuplicateModuleName(_)) - )); - - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn analysis_propagates_query_failures() { - let mut compiler = Compiler::default(); - let file_id = compiler.files.insert("file:///Main.purs", "module Main where\n"); - - assert!(matches!( - analyse_module(&compiler.engine, file_id), - Err(DocsError::QueryError(building::QueryError::MissingContent { - file_id: missing_file_id - })) if missing_file_id == file_id - )); - } -} diff --git a/compiler-bin/src/docs/error.rs b/compiler-bin/src/docs/error.rs deleted file mode 100644 index 53f5b940b..000000000 --- a/compiler-bin/src/docs/error.rs +++ /dev/null @@ -1,32 +0,0 @@ -use std::io; -use std::path::PathBuf; - -use building::QueryError; -use documentation::Error as DocumentationError; -use thiserror::Error; - -use crate::package; - -#[derive(Error, Debug)] -pub enum DocsError { - #[error("QueryError: {0}")] - QueryError(#[from] QueryError), - #[error("DocumentationError: {0}")] - DocumentationError(#[from] DocumentationError), - #[error("Duplicate module name: {0}")] - DuplicateModuleName(String), - #[error("Invalid package name: {0}")] - InvalidPackageName(String), - #[error("Failed to parse file {0}")] - PathParseFail(PathBuf), - #[error("IoError: {0}")] - IoError(#[from] io::Error), - #[error("GlobSetError: {0}")] - GlobSetError(#[from] globset::Error), - #[error(transparent)] - PackageError(#[from] package::PackageError), - #[error("JsonError: {0}")] - JsonError(#[from] serde_json::Error), - #[error("SpagoLockError: {0}")] - SpagoLockError(#[from] spago::LockfileGlobSetError), -} diff --git a/compiler-bin/src/docs/location.rs b/compiler-bin/src/docs/location.rs deleted file mode 100644 index 0118655e8..000000000 --- a/compiler-bin/src/docs/location.rs +++ /dev/null @@ -1,76 +0,0 @@ -use std::path::Path; - -use documentation::schema::Location; - -use crate::docs::PursLocation; - -pub fn manifest_location( - location: Option, - reference: Option, -) -> Option { - match location? { - PursLocation::GitHub { owner, repository, subdir } => { - Some(github_location(owner, repository, reference, subdir)) - } - PursLocation::Git { url, subdir } => Some(location_from_git_url(&url, reference, subdir)), - } -} - -pub fn package_reference_location(reference: &spago::PackageReference) -> Option { - let spago::PackageReference::Git { url: Some(url), rev, subdir, .. } = reference else { - return None; - }; - - let reference = Some(rev.to_string()); - let subdir = subdir.as_deref().map(path_to_string); - - Some(location_from_git_url(url.as_str(), reference, subdir)) -} - -fn location_from_git_url(url: &str, reference: Option, subdir: Option) -> Location { - let Some((owner, repository)) = github_repository_from_url(url) else { - return Location::Git { url: url.to_string(), reference, subdir }; - }; - - github_location(owner, repository, reference, subdir) -} - -fn github_location( - owner: String, - repository: String, - reference: Option, - subdir: Option, -) -> Location { - Location::GitHub { - url: github_repository_url(&owner, &repository), - owner, - repository, - reference, - subdir, - } -} - -fn github_repository_from_url(url: &str) -> Option<(String, String)> { - let url = url::Url::parse(url).ok()?; - if url.host_str()? != "github.com" { - return None; - } - - let mut segments = url.path_segments()?; - let owner = segments.next()?.to_string(); - let repository = segments.next()?.trim_end_matches(".git").to_string(); - - if owner.is_empty() || repository.is_empty() { - return None; - } - - Some((owner, repository)) -} - -fn github_repository_url(owner: &str, repository: &str) -> String { - format!("https://github.com/{owner}/{repository}") -} - -fn path_to_string(path: &Path) -> String { - path.to_string_lossy().replace('\\', "/") -} diff --git a/compiler-bin/src/lib.rs b/compiler-bin/src/lib.rs deleted file mode 100644 index 3b94d01e7..000000000 --- a/compiler-bin/src/lib.rs +++ /dev/null @@ -1,138 +0,0 @@ -use tracing::level_filters::LevelFilter; - -pub mod cli; -mod compilation; -pub mod compile; -pub mod docs; -pub mod logging; -pub mod lsp; -mod package; -mod progress; -mod project; -pub mod walk; -mod watch; -mod workspace; - -pub(crate) const PACKAGE_NAME: &str = env!("CARGO_PKG_NAME"); -pub(crate) const VERSION: &str = env!("IRIS_VERSION"); - -pub fn run() { - let program = cli::Program::parse_with_diagnostics(); - - if program.log_file { - eprintln!("Log file: {:?}", logging::temporary_log_file()); - } - - let command = program.into_command().unwrap_or_else(|error| { - eprintln!("error: failed to resolve command paths: {error}"); - std::process::exit(2); - }); - - match command { - cli::Command::Lsp(options) => { - let configuration = options.configuration().unwrap_or_else(|error| { - eprint!("{}", error.render()); - std::process::exit(2); - }); - logging::start(logging::LoggingFilters { - query_log: options.logging.query_log, - checking_log: options.logging.checking_log, - lsp_log: options.lsp_log, - docs_log: LevelFilter::OFF, - }); - lsp::start(configuration); - } - cli::Command::New(options) => project::start(project::new(options.name)), - cli::Command::Build(options) => { - start_project_logging(&options.build.logging); - let resilience = if options.resilient { - compile::Resilience::Resilient - } else { - compile::Resilience::Strict - }; - project::start(project::build(project_build_config(options.build), resilience)); - } - cli::Command::Add(options) => { - project::start(project::add(project::AddProjectConfig { - package: options.package, - dependencies: options.dependencies, - test_dependencies: options.test, - })); - } - cli::Command::Run(options) => { - start_project_logging(&options.build.logging); - project::start(project::run(project::RunProjectConfig { - build: project_build_config(options.build), - main: options.main, - arguments: options.arguments, - })); - } - cli::Command::Test(options) => { - start_project_logging(&options.build.logging); - project::start(project::test(project::TestProjectConfig { - build: project_build_config(options.build), - main: options.main, - arguments: options.arguments, - })); - } - cli::Command::Compile(options) => { - logging::start(logging::LoggingFilters { - query_log: options.build.logging.query_log, - checking_log: options.build.logging.checking_log, - lsp_log: LevelFilter::OFF, - docs_log: LevelFilter::OFF, - }); - compile::start(compile::CompileConfig { - output: options.build.output, - inputs: options.inputs, - packages: options.packages, - json_errors: options.json_errors, - quiet: options.build.quiet, - color: options.build.color, - }); - } - cli::Command::Watch(options) => { - start_project_logging(&options.logging); - project::start(project::watch(project_build_config(options))); - } - cli::Command::Docs(options) => { - logging::start(logging::LoggingFilters { - query_log: options.logging.query_log, - checking_log: options.logging.checking_log, - lsp_log: LevelFilter::OFF, - docs_log: options.docs_log, - }); - match options.command { - Some(cli::DocsCommand::TypeScript(options)) => { - docs::typescript(docs::TypeScriptConfig { output: options.output }); - } - None => { - docs::start(docs::DocsConfig { - output: options.output, - spago_project: options.spago_project, - packages: options.packages, - quiet: options.quiet, - }); - } - } - } - } -} - -fn start_project_logging(options: &cli::LoggingOptions) { - logging::start(logging::LoggingFilters { - query_log: options.query_log, - checking_log: options.checking_log, - lsp_log: LevelFilter::OFF, - docs_log: LevelFilter::OFF, - }); -} - -fn project_build_config(options: cli::ProjectBuildOptions) -> project::BuildProjectConfig { - project::BuildProjectConfig { - package: options.package, - output: options.output, - quiet: options.quiet, - color: options.color, - } -} diff --git a/compiler-bin/src/logging.rs b/compiler-bin/src/logging.rs deleted file mode 100644 index 26c6acfe4..000000000 --- a/compiler-bin/src/logging.rs +++ /dev/null @@ -1,72 +0,0 @@ -use std::path::PathBuf; -use std::time::Instant; -use std::{env, fs}; - -use tracing::level_filters::LevelFilter; -use tracing_subscriber::layer::SubscriberExt; -use tracing_subscriber::{Layer, Registry, filter, fmt}; - -#[derive(Debug, Clone, Copy)] -pub struct LoggingFilters { - pub query_log: LevelFilter, - pub checking_log: LevelFilter, - pub lsp_log: LevelFilter, - pub docs_log: LevelFilter, -} - -struct SpanTimingLayer; - -impl tracing_subscriber::Layer for SpanTimingLayer -where - S: tracing::Subscriber + for<'lookup> tracing_subscriber::registry::LookupSpan<'lookup>, -{ - fn on_enter(&self, id: &tracing::span::Id, ctx: tracing_subscriber::layer::Context<'_, S>) { - if let Some(span) = ctx.span(id) - && span.extensions().get::().is_none() - { - span.extensions_mut().insert(Instant::now()); - } - } - - fn on_close(&self, id: tracing::span::Id, ctx: tracing_subscriber::layer::Context<'_, S>) { - if let Some(span) = ctx.span(&id) - && let Some(start) = span.extensions().get::() - && ctx.enabled(span.metadata()) - { - let duration = start.elapsed(); - let name = span.name(); - tracing::info!(target: "meta", span = name, span.duration = ?duration); - } - } -} - -pub fn temporary_log_file() -> PathBuf { - let temporary_directory = env::temp_dir(); - temporary_directory.join("purescript-iris.log") -} - -pub fn start(filters: LoggingFilters) { - let path = temporary_log_file(); - let file = fs::OpenOptions::new() - .create(true) - .append(true) - .open(path) - .expect("Failed to open log file"); - - let fmt_filter = filter::Targets::new() - .with_target("building::engine", filters.query_log) - .with_target("purescript_iris::lsp", filters.lsp_log) - .with_target("purescript_iris::docs", filters.docs_log) - .with_target("checking", filters.checking_log) - .with_default(LevelFilter::INFO); - let fmt = fmt::layer().with_writer(file).with_filter(fmt_filter); - - let timing_filter = filter::Targets::new() - .with_target("purescript_iris::lsp", filters.lsp_log) - .with_target("purescript_iris::docs", filters.docs_log) - .with_default(LevelFilter::OFF); - let timing = SpanTimingLayer.with_filter(timing_filter); - - let subscriber = Registry::default().with(fmt).with(timing); - tracing::subscriber::set_global_default(subscriber).unwrap(); -} diff --git a/compiler-bin/src/package.rs b/compiler-bin/src/package.rs deleted file mode 100644 index b3c837801..000000000 --- a/compiler-bin/src/package.rs +++ /dev/null @@ -1,155 +0,0 @@ -use std::path::{Component, Path, PathBuf}; -use std::{fs, io}; - -use serde::Deserialize; -use thiserror::Error; - -use crate::walk; - -#[derive(Debug, Error)] -pub enum PackageError { - #[error("invalid package glob: {0}")] - InvalidGlob(String), - #[error("missing package folder: {0}")] - MissingFolder(PathBuf), - #[error(transparent)] - Io(#[from] io::Error), - #[error(transparent)] - Json(#[from] serde_json::Error), - #[error(transparent)] - Walk(#[from] walk::Error), -} - -#[derive(Default, Deserialize)] -#[serde(rename_all = "camelCase")] -struct PackageSources { - #[serde(default)] - include_files: Vec, - #[serde(default)] - exclude_files: Vec, -} - -pub fn source_files(root: &Path, package: &Path) -> Result, PackageError> { - let package_root = root.join(package); - if !package_root.is_dir() { - return Err(PackageError::MissingFolder(package_root)); - } - - let manifest = package_root.join("purs.json"); - let sources = if manifest.exists() { - let manifest = fs::read_to_string(manifest)?; - serde_json::from_str(&manifest)? - } else { - PackageSources::default() - }; - - source_files_with_globs(root, package, &sources.include_files, &sources.exclude_files) -} - -pub fn source_files_with_globs( - root: &Path, - package: &Path, - include_files: &[String], - exclude_files: &[String], -) -> Result, PackageError> { - let package_root = root.join(package); - if !package_root.is_dir() { - return Err(PackageError::MissingFolder(package_root)); - } - - let mut includes = vec![package.join("src/**/*.purs"), package.join("test/**/*.purs")]; - for path in include_files { - validate_package_glob(path)?; - includes.push(package.join(path)); - } - - let mut excludes = vec![]; - for path in exclude_files { - validate_package_glob(path)?; - excludes.push(package.join(path)); - } - - let walked = walk::walk_filtered(root, includes, excludes)?; - Ok(walked.files) -} - -fn validate_package_glob(path: &str) -> Result<(), PackageError> { - let valid = !path.is_empty() && Path::new(path).components().all(is_package_relative_component); - if !valid { - return Err(PackageError::InvalidGlob(path.to_owned())); - } - - Ok(()) -} - -fn is_package_relative_component(component: Component<'_>) -> bool { - match component { - Component::Normal(_) | Component::CurDir => true, - Component::Prefix(_) | Component::RootDir | Component::ParentDir => false, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::time::{SystemTime, UNIX_EPOCH}; - - fn temporary_directory() -> PathBuf { - let nanos = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); - let directory = std::env::temp_dir().join(format!("iris-package-{nanos}")); - fs::create_dir_all(&directory).unwrap(); - directory - } - - fn touch(path: &Path) { - fs::create_dir_all(path.parent().unwrap()).unwrap(); - fs::write(path, "").unwrap(); - } - - #[test] - fn manifest_globs_extend_and_filter_package_sources() { - let root = temporary_directory(); - let package = root.join("effect"); - touch(&package.join("src/Main.purs")); - touch(&package.join("test/Test.Main.purs")); - touch(&package.join("test/Excluded.purs")); - touch(&package.join("examples/Example.purs")); - fs::write( - package.join("purs.json"), - r#"{ - "includeFiles": ["examples/**/*.purs"], - "excludeFiles": ["test/Excluded.purs"] - }"#, - ) - .unwrap(); - - let files = source_files(&root, Path::new("effect")).unwrap(); - let files = files - .iter() - .map(|path| path.strip_prefix(&root).unwrap().to_string_lossy().to_string()); - let files = files.collect::>(); - - assert_eq!( - files, - ["effect/examples/Example.purs", "effect/src/Main.purs", "effect/test/Test.Main.purs"] - ); - - fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn package_globs_reject_paths_outside_the_package_root() { - let root = temporary_directory(); - fs::create_dir(root.join("effect")).unwrap(); - - let result = source_files_with_globs( - &root, - Path::new("effect"), - &["../Outside.purs".to_owned()], - &[], - ); - - assert!(matches!(result, Err(PackageError::InvalidGlob(_)))); - fs::remove_dir_all(root).unwrap(); - } -} diff --git a/compiler-bin/src/progress.rs b/compiler-bin/src/progress.rs deleted file mode 100644 index 1dd3500fe..000000000 --- a/compiler-bin/src/progress.rs +++ /dev/null @@ -1,114 +0,0 @@ -use std::fmt; -use std::io::{self, IsTerminal}; -use std::sync::OnceLock; -use std::time::{Duration, Instant}; - -use console::{Color, Style}; -use indicatif::{MultiProgress, ProgressBar, ProgressState, ProgressStyle}; -use terminal_colorsaurus::{QueryOptions, ThemeMode}; - -const CARGO_PROGRESS_REGION_WIDTH: usize = 50; -const CARGO_PROGRESS_FIXED_OVERHEAD: usize = 17; -const SHIMMER_FRAME_INTERVAL: Duration = Duration::from_millis(80); -static TERMINAL_THEME_MODE: OnceLock> = OnceLock::new(); - -pub(crate) fn bar(total: usize, phase: &'static str, show: bool) -> ProgressBar { - if !show { - return ProgressBar::hidden(); - } - let progress = ProgressBar::new(total as u64); - configure(&progress, phase); - progress -} - -pub(crate) fn phase( - progress: &MultiProgress, - total: usize, - phase: &'static str, - show: bool, -) -> ProgressBar { - if !show { - return ProgressBar::hidden(); - } - let phase_progress = progress.add(ProgressBar::new(total as u64)); - configure(&phase_progress, phase); - phase_progress -} - -pub(crate) fn set_message(progress: &ProgressBar, message: &str) { - progress.set_message(message.to_string()); -} - -pub(crate) fn finish(progress: &ProgressBar) { - progress.set_message(""); - progress.finish(); -} - -pub(crate) fn report_completion(elapsed: Duration) { - if !io::stderr().is_terminal() { - return; - } - - let label = format!("{:>12}", "Finished"); - let style = Style::new().green().bold(); - let jobs = rayon::current_num_threads(); - let job_label = if jobs == 1 { "job" } else { "jobs" }; - eprintln!("{} in {elapsed:.2?} via {jobs} {job_label}", style.apply_to(label)); -} - -fn configure(progress: &ProgressBar, phase: &'static str) { - let started = Instant::now(); - let characters = phase.chars().collect::>(); - let theme_mode = *TERMINAL_THEME_MODE - .get_or_init(|| terminal_colorsaurus::theme_mode(QueryOptions::default()).ok()); - let cycle = characters.len() + 4; - let total = progress.length().unwrap_or_default(); - let count_width = total.to_string().len().max(4); - let statistics_width = count_width * 2 + 2; - let bar_width = CARGO_PROGRESS_REGION_WIDTH - .saturating_sub(CARGO_PROGRESS_FIXED_OVERHEAD + statistics_width) - .max(1); - let phase_text = move |state: &ProgressState, writer: &mut dyn fmt::Write| { - if state.is_finished() { - let style = phase_style(theme_mode, 255); - write!(writer, "{}", style.apply_to(phase)) - .expect("writing to a formatter cannot fail"); - return; - } - - let frame = - (started.elapsed().as_millis() / SHIMMER_FRAME_INTERVAL.as_millis()) as usize % cycle; - let highlight = frame as isize - 2; - for (index, character) in characters.iter().enumerate() { - let distance = (index as isize - highlight).unsigned_abs(); - let intensity = match distance { - 0 => 255, - 1 => 210, - 2 => 155, - 3 => 115, - _ => 90, - }; - let style = phase_style(theme_mode, intensity); - write!(writer, "{}", style.apply_to(character)) - .expect("writing to a formatter cannot fail"); - } - }; - let template = - format!("{{phase:>12}} [{{bar:{bar_width}.cyan/blue}}] {{pos:>4}}/{{len:4}} {{msg}}"); - let style = ProgressStyle::with_template(&template) - .expect("progress bar template is valid") - .with_key("phase", phase_text) - .progress_chars("=> "); - progress.set_style(style); - progress.enable_steady_tick(SHIMMER_FRAME_INTERVAL); -} - -fn phase_style(theme_mode: Option, intensity: u8) -> Style { - let style = Style::new().for_stderr(); - let intensity = match theme_mode { - Some(ThemeMode::Dark) => intensity, - Some(ThemeMode::Light) => 255 - intensity, - None => return style, - }; - style.fg(Color::TrueColor(intensity, intensity, intensity)) -} diff --git a/compiler-bin/src/project.rs b/compiler-bin/src/project.rs deleted file mode 100644 index fa18d617b..000000000 --- a/compiler-bin/src/project.rs +++ /dev/null @@ -1,332 +0,0 @@ -use std::path::{Path, PathBuf}; -use std::process::{self, Command, ExitStatus}; -use std::{env, fs, io}; - -use thiserror::Error; -use url::Url; - -use crate::cli::ColorChoice; -use crate::compile::{self, CompileError, Resilience}; -use crate::watch::{self, WatchError}; -use crate::workspace::{Workspace, WorkspaceError}; -use spago::{SpagoCommand, SpagoError}; - -const MAIN_SOURCE: &str = include_str!("../bundled/project/Main.purs"); -const TEST_SOURCE: &str = include_str!("../bundled/project/Test.Main.purs"); -const NODE_RUNNER: &str = include_str!("../bundled/project/runner.mjs"); -const GITIGNORE: &str = include_str!("../bundled/project/gitignore"); - -#[derive(Debug, Error)] -pub enum ProjectError { - #[error(transparent)] - Compile(#[from] CompileError), - #[error(transparent)] - Spago(#[from] SpagoError), - #[error(transparent)] - Watch(#[from] WatchError), - #[error(transparent)] - Workspace(#[from] WorkspaceError), - #[error("invalid package name '{0}'; use lowercase letters, digits, and hyphens")] - InvalidPackageName(String), - #[error("cannot create a project because these paths already exist: {0}")] - ExistingPaths(String), - #[error("cannot create a project inside the existing Spago workspace at {0}")] - ExistingWorkspace(PathBuf), - #[error("failed to determine the current directory: {0}")] - CurrentDirectory(io::Error), - #[error("failed to write {path}: {source}")] - Write { - path: PathBuf, - #[source] - source: io::Error, - }, - #[error("failed to execute Node.js: {0}")] - Node(io::Error), - #[error("Node.js exited without a status code")] - MissingStatus, - #[error("Node.js exited with status {0}")] - NodeFailed(ExitStatus), - #[error("module output does not exist: {0}")] - MissingModule(PathBuf), - #[error("failed to canonicalize module output {path}: {source}")] - CanonicalizeModule { - path: PathBuf, - #[source] - source: io::Error, - }, - #[error("failed to convert module output to a file URL: {0}")] - ModuleUrl(PathBuf), - #[error("no selected packages contain tests")] - NoTests, -} - -pub struct BuildProjectConfig { - pub package: Option, - pub output: Option, - pub quiet: bool, - pub color: ColorChoice, -} - -pub struct AddProjectConfig { - pub package: Option, - pub dependencies: Vec, - pub test_dependencies: bool, -} - -pub struct RunProjectConfig { - pub build: BuildProjectConfig, - pub main: Option, - pub arguments: Vec, -} - -pub struct TestProjectConfig { - pub build: BuildProjectConfig, - pub main: Option, - pub arguments: Vec, -} - -pub fn start(result: Result<(), ProjectError>) { - if let Err(error) = result { - eprintln!("{error}"); - let exit_code = match error { - ProjectError::NodeFailed(status) => status.code().unwrap_or(1), - _ => 1, - }; - process::exit(exit_code); - } -} - -pub fn new(name: Option) -> Result<(), ProjectError> { - let current_directory = env::current_dir().map_err(ProjectError::CurrentDirectory)?; - if let Some(root) = Workspace::find_ancestor(¤t_directory)? { - return Err(ProjectError::ExistingWorkspace(root)); - } - let name = match name { - Some(name) => name, - None => current_directory - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("main") - .to_owned(), - }; - validate_package_name(&name)?; - - let targets = [ - current_directory.join("spago.yaml"), - current_directory.join("src/Main.purs"), - current_directory.join("test/Test/Main.purs"), - current_directory.join(".gitignore"), - ]; - let required_directories = [ - current_directory.join("src"), - current_directory.join("test"), - current_directory.join("test/Test"), - ]; - let existing_targets = targets.iter().filter(|path| path.exists()); - let invalid_directories = - required_directories.iter().filter(|path| path.exists() && !path.is_dir()); - let existing = existing_targets.chain(invalid_directories); - let existing = existing.map(|path| path.display().to_string()); - let existing = existing.collect::>(); - if !existing.is_empty() { - return Err(ProjectError::ExistingPaths(existing.join(", "))); - } - - let manifest = format!( - r#"package: - name: {name} - dependencies: - - console - - effect - - prelude - test: - main: Test.Main - dependencies: - - assert -workspace: {{}} -"# - ); - write_file(&targets[0], &manifest)?; - write_file(&targets[1], MAIN_SOURCE)?; - write_file(&targets[2], TEST_SOURCE)?; - write_file(&targets[3], GITIGNORE)?; - Ok(()) -} - -pub fn build(config: BuildProjectConfig, resilience: Resilience) -> Result<(), ProjectError> { - let current_directory = env::current_dir().map_err(ProjectError::CurrentDirectory)?; - let workspace = Workspace::discover(¤t_directory, config.package.as_deref())?; - compile_workspace(&workspace, ¤t_directory, &config, resilience) -} - -pub fn watch(config: BuildProjectConfig) -> Result<(), ProjectError> { - let current_directory = env::current_dir().map_err(ProjectError::CurrentDirectory)?; - let workspace = Workspace::discover(¤t_directory, config.package.as_deref())?; - let (inputs, output) = prepare_workspace_build(&workspace, ¤t_directory, &config)?; - watch::watch(watch::WatchConfig { - current_directory: workspace.root, - output, - inputs, - quiet: config.quiet, - color: config.color, - })?; - Ok(()) -} - -pub fn add(config: AddProjectConfig) -> Result<(), ProjectError> { - let current_directory = env::current_dir().map_err(ProjectError::CurrentDirectory)?; - let workspace = Workspace::discover(¤t_directory, config.package.as_deref())?; - let selected = workspace.require_selected()?.manifest.name.as_str(); - let spago = SpagoCommand::new(¤t_directory)?; - spago.add(selected, &config.dependencies, config.test_dependencies).map_err(ProjectError::from) -} - -pub fn run(config: RunProjectConfig) -> Result<(), ProjectError> { - let current_directory = env::current_dir().map_err(ProjectError::CurrentDirectory)?; - let workspace = Workspace::discover(¤t_directory, config.build.package.as_deref())?; - let package = workspace.require_selected()?; - compile_workspace(&workspace, ¤t_directory, &config.build, Resilience::Strict)?; - - let execution = package.manifest.run.as_ref().cloned().unwrap_or_default(); - let main = config.main.or(execution.main).unwrap_or_else(|| "Main".to_owned()); - let arguments = - if config.arguments.is_empty() { execution.exec_args } else { config.arguments }; - execute_module(&workspace.root, output(&workspace, &config.build), &main, &arguments) -} - -pub fn test(config: TestProjectConfig) -> Result<(), ProjectError> { - let current_directory = env::current_dir().map_err(ProjectError::CurrentDirectory)?; - let workspace = Workspace::discover(¤t_directory, config.build.package.as_deref())?; - if workspace.selected.is_some() { - let package = workspace.require_selected()?; - if !package.has_tests { - return Err(ProjectError::NoTests); - } - } else if !workspace.packages.values().any(|package| package.has_tests) { - return Err(ProjectError::NoTests); - } - compile_workspace(&workspace, ¤t_directory, &config.build, Resilience::Strict)?; - let output = output(&workspace, &config.build); - - let packages = workspace.packages.values().filter(|package| { - workspace.selected.as_ref().is_some_and(|selected| selected == &package.manifest.name) - || (workspace.selected.is_none() && package.has_tests) - }); - for package in packages { - let execution = package.manifest.test.as_ref().cloned().unwrap_or_default(); - let main = config.main.clone().or(execution.main).unwrap_or_else(|| "Test.Main".to_owned()); - let arguments = if config.arguments.is_empty() { - execution.exec_args - } else { - config.arguments.clone() - }; - execute_module(&workspace.root, output.clone(), &main, &arguments)?; - } - Ok(()) -} - -fn compile_workspace( - workspace: &Workspace, - current_directory: &Path, - config: &BuildProjectConfig, - resilience: Resilience, -) -> Result<(), ProjectError> { - let (sources, output) = prepare_workspace_build(workspace, current_directory, config)?; - compile::compile_inputs( - &workspace.root, - &output, - &sources, - config.quiet, - config.color, - resilience, - )?; - Ok(()) -} - -fn prepare_workspace_build( - workspace: &Workspace, - current_directory: &Path, - config: &BuildProjectConfig, -) -> Result<(Vec, PathBuf), ProjectError> { - let spago = SpagoCommand::new(current_directory)?; - spago.fetch(workspace.selected.as_deref())?; - let sources = spago.source_globs(workspace.selected.as_deref())?; - let output = output(workspace, config); - Ok((sources, output)) -} - -fn output(workspace: &Workspace, config: &BuildProjectConfig) -> PathBuf { - config.output.clone().unwrap_or_else(|| workspace.root.join("output")) -} - -fn execute_module( - workspace_root: &Path, - output: PathBuf, - module: &str, - arguments: &[String], -) -> Result<(), ProjectError> { - let module = output.join(module).join("index.js"); - if !module.is_file() { - return Err(ProjectError::MissingModule(module)); - } - let module = module - .canonicalize() - .map_err(|source| ProjectError::CanonicalizeModule { path: module.clone(), source })?; - let module_url = Url::from_file_path(&module) - .map_err(|()| ProjectError::ModuleUrl(module.clone()))? - .to_string(); - let status = Command::new("node") - .arg("--input-type=module") - .arg("--eval") - .arg(NODE_RUNNER) - .arg(module_url) - .arg("--") - .args(arguments) - .current_dir(workspace_root) - .status() - .map_err(ProjectError::Node)?; - ensure_node_success(status) -} - -fn ensure_node_success(status: ExitStatus) -> Result<(), ProjectError> { - if status.success() { - return Ok(()); - } - if status.code().is_none() { - return Err(ProjectError::MissingStatus); - } - Err(ProjectError::NodeFailed(status)) -} - -fn validate_package_name(name: &str) -> Result<(), ProjectError> { - let mut characters = name.chars(); - let starts_lowercase = - characters.next().is_some_and(|character| character.is_ascii_lowercase()); - let remaining_valid = characters.all(|character| { - character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-' - }); - if !starts_lowercase || !remaining_valid { - return Err(ProjectError::InvalidPackageName(name.to_owned())); - } - Ok(()) -} - -fn write_file(path: &Path, content: &str) -> Result<(), ProjectError> { - let parent = path.parent().expect("project file path has no parent"); - fs::create_dir_all(parent) - .map_err(|source| ProjectError::Write { path: parent.to_path_buf(), source })?; - fs::write(path, content) - .map_err(|source| ProjectError::Write { path: path.to_path_buf(), source }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn validates_package_names() { - assert!(validate_package_name("my-project2").is_ok()); - assert!(validate_package_name("MyProject").is_err()); - assert!(validate_package_name("2project").is_err()); - } -} diff --git a/compiler-bin/src/watch.rs b/compiler-bin/src/watch.rs deleted file mode 100644 index 6c2f8e891..000000000 --- a/compiler-bin/src/watch.rs +++ /dev/null @@ -1,569 +0,0 @@ -use std::collections::BTreeSet; -use std::path::{Path, PathBuf}; -use std::sync::mpsc::{self, RecvTimeoutError}; -use std::time::Duration; -use std::{fs, io}; - -use building::{DiskObservation, LifecycleChange, QueryError, ReloadFailure, SourceUnitKey}; -use console::Style; -use files::ForeignSourceKind; -use itertools::Itertools; -use notify::event::{CreateKind, ModifyKind, RemoveKind}; -use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; -use thiserror::Error; -use url::Url; - -use crate::cli::ColorChoice; -use crate::compilation::CompilationState; -use crate::compile::{self, BuildConfig, BuildOutcome, CompileError, Resilience}; -use crate::walk; - -const DEBOUNCE_DURATION: Duration = Duration::from_millis(100); -const MODULE_DISPLAY_LIMIT: usize = 3; - -pub struct WatchConfig { - pub current_directory: PathBuf, - pub output: PathBuf, - pub inputs: Vec, - pub quiet: bool, - pub color: ColorChoice, -} - -#[derive(Debug, Error)] -pub enum WatchError { - #[error("failed to convert path to a file URL: {0}")] - InvalidPath(PathBuf), - #[error(transparent)] - Io(#[from] io::Error), - #[error(transparent)] - Notify(#[from] notify::Error), - #[error(transparent)] - Query(#[from] QueryError), - #[error(transparent)] - Walk(#[from] walk::Error), -} - -pub fn watch(config: WatchConfig) -> Result<(), WatchError> { - let (mut workspace, initial_change) = WatchWorkspace::new( - config.current_directory.clone(), - config.inputs.clone(), - config.output.clone(), - )?; - let (sender, receiver) = mpsc::channel(); - let mut watcher = RecommendedWatcher::new(sender, notify::Config::default())?; - for root in &workspace.watch_roots { - watcher.watch(root, RecursiveMode::Recursive)?; - } - report_lifecycle_warnings(&initial_change.lifecycle); - if !config.quiet { - report_changed_modules(&initial_change.modules, compile::use_color(config.color)); - } - rebuild_or_report(&mut workspace, &config); - - loop { - let first = receiver.recv().map_err(|error| { - io::Error::new(io::ErrorKind::BrokenPipe, format!("watch channel closed: {error}")) - })?; - let mut events = vec![first?]; - loop { - match receiver.recv_timeout(DEBOUNCE_DURATION) { - Ok(event) => events.push(event?), - Err(RecvTimeoutError::Timeout) => break, - Err(RecvTimeoutError::Disconnected) => { - return Err( - io::Error::new(io::ErrorKind::BrokenPipe, "watch channel closed").into() - ); - } - } - } - - let change = workspace.synchronize_events(events)?; - report_lifecycle_warnings(&change.lifecycle); - if !change.modules.is_empty() { - if !config.quiet { - report_changed_modules(&change.modules, compile::use_color(config.color)); - } - rebuild_or_report(&mut workspace, &config); - } - } -} - -fn rebuild_or_report(workspace: &mut WatchWorkspace, config: &WatchConfig) { - if let Err(error) = rebuild(workspace, config) { - eprintln!("Compilation failed: {error}"); - tracing::error!(?error, "Watch compilation failed"); - } -} - -fn rebuild(workspace: &mut WatchWorkspace, config: &WatchConfig) -> Result<(), CompileError> { - if workspace.compilation.input_source_ids().is_empty() { - if let Err(error) = workspace.reconcile_outputs(BTreeSet::new()) { - eprintln!("Failed to remove stale output: {error}"); - } - if !config.quiet { - println!("No input files found; waiting for changes."); - } - return Ok(()); - } - - let build_config = BuildConfig { - output: &config.output, - current_directory: &workspace.current_directory, - color: compile::use_color(config.color), - progress: false, - resilience: Resilience::Strict, - }; - match compile::build(&workspace.compilation, &build_config)? { - BuildOutcome::Succeeded(outputs) => { - if let Err(error) = workspace.reconcile_outputs(outputs) { - eprintln!("Compilation succeeded, but stale output could not be removed: {error}"); - } - } - BuildOutcome::Diagnostics => {} - } - Ok(()) -} - -fn report_lifecycle_warnings(change: &LifecycleChange) { - for warning in change.warnings() { - tracing::warn!("{warning}"); - eprintln!("Watch warning: {warning}"); - } -} - -fn report_changed_modules(module_names: &BTreeSet, color: bool) { - if module_names.is_empty() { - return; - } - - let timestamp = jiff::Zoned::now().strftime("%H:%M:%S"); - let timestamp = - Style::new().cyan().dim().force_styling(color).apply_to(format!("[{timestamp}]")); - let count = module_names.len(); - let noun = if count == 1 { "file" } else { "files" }; - let mut displayed = module_names.iter().take(MODULE_DISPLAY_LIMIT).map(String::as_str); - let displayed = displayed.join(", "); - let omitted = count.saturating_sub(MODULE_DISPLAY_LIMIT); - - if omitted == 0 { - println!("{timestamp} {count} {noun} changed: {displayed}"); - } else { - println!("{timestamp} {count} {noun} changed: {displayed}, … +{omitted} more"); - } -} - -#[derive(Default)] -struct WorkspaceChange { - lifecycle: LifecycleChange, - modules: BTreeSet, -} - -impl WorkspaceChange { - fn combine(&mut self, other: WorkspaceChange) { - self.lifecycle.combine(other.lifecycle); - self.modules.extend(other.modules); - } -} - -struct WatchWorkspace { - current_directory: PathBuf, - inputs: Vec, - output: PathBuf, - watch_roots: BTreeSet, - source_globs: globset::GlobSet, - source_paths: BTreeSet, - generated_outputs: BTreeSet, - compilation: CompilationState, -} - -impl WatchWorkspace { - fn new( - current_directory: PathBuf, - inputs: Vec, - output: PathBuf, - ) -> Result<(WatchWorkspace, WorkspaceChange), WatchError> { - let walked = walk::walk(¤t_directory, &inputs)?; - let watch_roots = walked.roots.iter().map(|root| persistent_watch_root(root)).collect(); - let source_paths = walked - .files - .into_iter() - .filter(|path| !path.starts_with(&output)) - .collect::>(); - let mut compilation = CompilationState::new(); - let mut initial_change = WorkspaceChange::default(); - for path in &source_paths { - initial_change.combine(observe_source_unit(&mut compilation, path)?); - } - - let workspace = WatchWorkspace { - current_directory, - inputs, - output, - watch_roots, - source_globs: walked.globs, - source_paths, - generated_outputs: BTreeSet::new(), - compilation, - }; - Ok((workspace, initial_change)) - } - - fn synchronize_events( - &mut self, - events: impl IntoIterator, - ) -> Result { - let mut rescan = false; - let mut paths = BTreeSet::new(); - for event in events { - if !event.paths.is_empty() - && event.paths.iter().all(|path| path.starts_with(&self.output)) - { - continue; - } - if event.need_rescan() { - rescan = true; - } - if matches!(event.kind, EventKind::Access(_)) { - continue; - } - if matches!( - event.kind, - EventKind::Create(CreateKind::Folder) - | EventKind::Modify(ModifyKind::Name(_)) - | EventKind::Remove(RemoveKind::Folder) - | EventKind::Any - | EventKind::Other - ) { - rescan = true; - } - paths.extend(event.paths.into_iter().filter(|path| !path.starts_with(&self.output))); - } - - if rescan { self.rescan() } else { self.synchronize_paths(paths) } - } - - fn synchronize_paths( - &mut self, - paths: impl IntoIterator, - ) -> Result { - let mut source_paths = BTreeSet::new(); - let mut foreign_paths = BTreeSet::new(); - for path in paths { - if path.starts_with(&self.output) { - continue; - } - match path.extension().and_then(|extension| extension.to_str()) { - Some("purs") => { - if self.source_paths.contains(&path) - || !self.source_globs.matches(&path).is_empty() - { - source_paths.insert(path); - } - } - Some("js" | "jsx") => { - let source_path = path.with_extension("purs"); - if self.source_paths.contains(&source_path) { - foreign_paths.insert(source_path); - } - } - _ => {} - } - } - - let mut change = WorkspaceChange::default(); - for path in source_paths { - change.combine(observe_source_unit(&mut self.compilation, &path)?); - if path.exists() { - self.source_paths.insert(path); - } else { - self.source_paths.remove(&path); - } - } - for source_path in foreign_paths { - change.combine(observe_foreign(&mut self.compilation, &source_path)?); - } - Ok(change) - } - - fn rescan(&mut self) -> Result { - let walked = walk::walk(&self.current_directory, &self.inputs)?; - let current_paths = walked - .files - .into_iter() - .filter(|path| !path.starts_with(&self.output)) - .collect::>(); - let affected_paths = self.source_paths.union(¤t_paths).cloned(); - let affected_paths = affected_paths.collect::>(); - - let mut change = WorkspaceChange::default(); - for path in affected_paths { - change.combine(observe_source_unit(&mut self.compilation, &path)?); - } - self.source_globs = walked.globs; - self.source_paths = current_paths; - Ok(change) - } - - fn reconcile_outputs(&mut self, current: BTreeSet) -> io::Result<()> { - for stale in self.generated_outputs.difference(¤t) { - match fs::remove_file(stale) { - Ok(()) => {} - Err(error) if error.kind() == io::ErrorKind::NotFound => {} - Err(error) => return Err(error), - } - } - self.generated_outputs = current; - Ok(()) - } -} - -fn observe_source_unit( - compilation: &mut CompilationState, - source_path: &Path, -) -> Result { - let unit = source_unit(source_path)?; - let previous_source = compilation.source_content(unit.source())?; - let previous_foreign = - ForeignSourceKind::ALL.map(|kind| compilation.foreign_content(unit.foreign_for(kind))); - let previous_name = compilation.module_name(unit.source())?; - - let source = observe_disk(source_path); - let mut lifecycle = compilation.observe_source(SourceUnitKey::clone(&unit), source); - for kind in ForeignSourceKind::ALL { - let foreign = observe_disk(&source_path.with_extension(kind.extension())); - lifecycle.combine(compilation.observe_foreign(SourceUnitKey::clone(&unit), kind, foreign)); - } - - let current_source = compilation.source_content(unit.source())?; - let current_foreign = - ForeignSourceKind::ALL.map(|kind| compilation.foreign_content(unit.foreign_for(kind))); - let mut modules = BTreeSet::new(); - if previous_source != current_source || previous_foreign != current_foreign { - let name = compilation.module_name(unit.source())?.or(previous_name); - modules.insert(name.unwrap_or_else(|| fallback_module_name(source_path))); - } - Ok(WorkspaceChange { lifecycle, modules }) -} - -fn observe_foreign( - compilation: &mut CompilationState, - source_path: &Path, -) -> Result { - let unit = source_unit(source_path)?; - let previous_foreign = - ForeignSourceKind::ALL.map(|kind| compilation.foreign_content(unit.foreign_for(kind))); - let mut lifecycle = LifecycleChange::default(); - for kind in ForeignSourceKind::ALL { - let foreign = observe_disk(&source_path.with_extension(kind.extension())); - lifecycle.combine(compilation.observe_foreign(SourceUnitKey::clone(&unit), kind, foreign)); - } - let current_foreign = - ForeignSourceKind::ALL.map(|kind| compilation.foreign_content(unit.foreign_for(kind))); - let mut modules = BTreeSet::new(); - if previous_foreign != current_foreign { - let name = compilation.module_name(unit.source())?; - modules.insert(name.unwrap_or_else(|| fallback_module_name(source_path))); - } - Ok(WorkspaceChange { lifecycle, modules }) -} - -fn source_unit(source_path: &Path) -> Result { - let source_url = Url::from_file_path(source_path) - .map_err(|()| WatchError::InvalidPath(source_path.to_path_buf()))?; - let foreign_path = source_path.with_extension("js"); - let foreign_url = Url::from_file_path(&foreign_path) - .map_err(|()| WatchError::InvalidPath(foreign_path.clone()))?; - Ok(SourceUnitKey::new(source_url.as_str(), foreign_url.as_str())) -} - -fn observe_disk(path: &Path) -> DiskObservation { - match fs::read_to_string(path) { - Ok(content) => DiskObservation::Found(content.into()), - Err(error) if error.kind() == io::ErrorKind::NotFound => DiskObservation::NotFound, - Err(error) => DiskObservation::Failed(ReloadFailure::new(error.kind(), error.to_string())), - } -} - -fn fallback_module_name(source_path: &Path) -> String { - source_path - .file_stem() - .map(|name| name.to_string_lossy().into_owned()) - .unwrap_or_else(|| source_path.display().to_string()) -} - -fn persistent_watch_root(root: &Path) -> PathBuf { - // The parent remains watched so deleting the input root does not prevent observing its recreation. - let mut candidate = root.parent().unwrap_or(root); - while !candidate.exists() { - let Some(parent) = candidate.parent() else { - break; - }; - candidate = parent; - } - if candidate.is_file() { - candidate.parent().unwrap_or(candidate).to_path_buf() - } else { - candidate.to_path_buf() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - fn workspace() -> (TempDir, WatchWorkspace) { - let temporary = TempDir::new().unwrap(); - let root = temporary.path(); - fs::create_dir_all(root.join("src")).unwrap(); - fs::write(root.join("src/Main.purs"), "module Main where\n\nvalue = 1\n").unwrap(); - let (workspace, _) = WatchWorkspace::new( - root.to_path_buf(), - vec![PathBuf::from("src/**/*.purs")], - root.join("output"), - ) - .unwrap(); - (temporary, workspace) - } - - #[test] - fn synchronizes_source_additions_modifications_and_deletions() { - let (temporary, mut workspace) = workspace(); - let main = temporary.path().join("src/Main.purs"); - let original = workspace.compilation.input_source_ids()[0]; - - fs::write(&main, "module Main where\n\nvalue = 2\n").unwrap(); - let change = workspace.synchronize_paths([main.clone()]).unwrap(); - assert_eq!(change.modules, BTreeSet::from([String::from("Main")])); - assert_eq!(workspace.compilation.input_source_ids(), vec![original]); - assert_eq!( - workspace.compilation.snapshot().content(original).unwrap().as_ref(), - "module Main where\n\nvalue = 2\n" - ); - - let added = temporary.path().join("src/Added.purs"); - fs::write(&added, "module Added where\n").unwrap(); - let change = workspace.synchronize_paths([added.clone()]).unwrap(); - assert_eq!(change.modules, BTreeSet::from([String::from("Added")])); - assert_eq!(workspace.compilation.input_source_ids().len(), 2); - - fs::remove_file(&main).unwrap(); - let change = workspace.synchronize_paths([main]).unwrap(); - assert_eq!(change.modules, BTreeSet::from([String::from("Main")])); - assert_eq!(workspace.compilation.input_source_ids().len(), 1); - } - - #[test] - fn synchronizes_foreign_changes_for_tracked_sources() { - let (temporary, mut workspace) = workspace(); - let source_id = workspace.compilation.input_source_ids()[0]; - let foreign = temporary.path().join("src/Main.jsx"); - fs::write(&foreign, "export const value = 1;\n").unwrap(); - - let change = workspace.synchronize_paths([foreign]).unwrap(); - - assert_eq!(change.lifecycle.changed_sources().collect::>(), vec![source_id]); - assert_eq!(change.modules, BTreeSet::from([String::from("Main")])); - assert_eq!( - workspace.compilation.snapshot().foreign_file(source_id).map(|file| file.kind()), - Some(ForeignSourceKind::Jsx) - ); - - let javascript = temporary.path().join("src/Main.js"); - fs::write(&javascript, "export const value = 2;\n").unwrap(); - workspace.synchronize_paths([javascript]).unwrap(); - assert_eq!(workspace.compilation.snapshot().foreign_files(source_id).count(), 2); - } - - #[test] - fn ignores_events_when_disk_content_is_unchanged() { - let (temporary, mut workspace) = workspace(); - let main = temporary.path().join("src/Main.purs"); - - let change = workspace.synchronize_paths([main]).unwrap(); - - assert!(change.modules.is_empty()); - } - - #[test] - fn full_rescan_reloads_existing_sources_and_removes_missing_sources() { - let (temporary, mut workspace) = workspace(); - let main = temporary.path().join("src/Main.purs"); - let original = workspace.compilation.input_source_ids()[0]; - fs::write(&main, "module Main where\n\nvalue = 2\n").unwrap(); - - workspace.rescan().unwrap(); - assert_eq!( - workspace.compilation.snapshot().content(original).unwrap().as_ref(), - "module Main where\n\nvalue = 2\n" - ); - - fs::remove_file(main).unwrap(); - workspace.rescan().unwrap(); - assert!(workspace.compilation.input_source_ids().is_empty()); - } - - #[test] - fn excludes_output_from_source_membership() { - let temporary = TempDir::new().unwrap(); - let root = temporary.path(); - fs::create_dir_all(root.join("output")).unwrap(); - fs::write(root.join("output/Generated.purs"), "module Generated where\n").unwrap(); - - let (workspace, _) = WatchWorkspace::new( - root.to_path_buf(), - vec![PathBuf::from("**/*.purs")], - root.join("output"), - ) - .unwrap(); - - assert!(workspace.compilation.input_source_ids().is_empty()); - } - - #[test] - fn removes_outputs_missing_from_the_latest_successful_build() { - let (temporary, mut workspace) = workspace(); - let stale = temporary.path().join("output/Stale/index.js"); - let retained = temporary.path().join("output/Main/index.js"); - fs::create_dir_all(stale.parent().unwrap()).unwrap(); - fs::create_dir_all(retained.parent().unwrap()).unwrap(); - fs::write(&stale, "stale").unwrap(); - fs::write(&retained, "retained").unwrap(); - workspace.generated_outputs = BTreeSet::from([stale.clone(), retained.clone()]); - - workspace.reconcile_outputs(BTreeSet::from([retained.clone()])).unwrap(); - - assert!(!stale.exists()); - assert!(retained.exists()); - assert_eq!(workspace.generated_outputs, BTreeSet::from([retained])); - } - - #[test] - fn rebuilds_after_a_recoverable_output_error() { - let (temporary, mut workspace) = workspace(); - let output = temporary.path().join("output"); - let config = WatchConfig { - current_directory: temporary.path().to_path_buf(), - output: output.clone(), - inputs: vec![PathBuf::from("src/**/*.purs")], - quiet: true, - color: ColorChoice::Never, - }; - fs::write(&output, "not a directory").unwrap(); - - rebuild_or_report(&mut workspace, &config); - - fs::remove_file(&output).unwrap(); - rebuild_or_report(&mut workspace, &config); - assert!(output.join("Main/index.js").is_file()); - } - - #[test] - fn watches_the_parent_of_an_existing_input_root() { - let temporary = TempDir::new().unwrap(); - let input_root = temporary.path().join("src"); - fs::create_dir(&input_root).unwrap(); - - assert_eq!(persistent_watch_root(&input_root), temporary.path()); - } -} diff --git a/compiler-lsp/spago/src/command.rs b/compiler-lsp/spago/src/command.rs index 1c3058560..bc51d0078 100644 --- a/compiler-lsp/spago/src/command.rs +++ b/compiler-lsp/spago/src/command.rs @@ -41,11 +41,13 @@ impl SpagoCommand { }) } - pub fn fetch(&self, selected: Option<&str>) -> Result<(), SpagoError> { + pub fn fetch(&self, selected: Option<&str>, show_output: bool) -> Result<(), SpagoError> { let mut arguments = vec!["fetch".to_owned()]; add_selection(&mut arguments, selected); let output = self.execute(&arguments)?; - forward_output(&output)?; + if show_output { + forward_output(&output)?; + } ensure_success("fetch", &output) } @@ -65,11 +67,17 @@ impl SpagoCommand { ensure_success("fetch", &output) } - pub fn source_globs(&self, selected: Option<&str>) -> Result, SpagoError> { + pub fn source_globs( + &self, + selected: Option<&str>, + show_output: bool, + ) -> Result, SpagoError> { let mut arguments = vec!["sources".to_owned(), "--json".to_owned()]; add_selection(&mut arguments, selected); let output = self.execute(&arguments)?; - io::stderr().write_all(&output.stderr).map_err(SpagoError::Execute)?; + if show_output { + io::stderr().write_all(&output.stderr).map_err(SpagoError::Execute)?; + } ensure_success("sources --json", &output)?; let sources: Vec = serde_json::from_slice(&output.stdout).map_err(SpagoError::InvalidSources)?; @@ -163,9 +171,9 @@ exit 9 ); let command = test_command(&temporary, &executable); - command.fetch(Some("application")).unwrap(); + command.fetch(Some("application"), true).unwrap(); command.add("application", &["console".to_owned(), "effect".to_owned()], true).unwrap(); - let sources = command.source_globs(Some("application")).unwrap(); + let sources = command.source_globs(Some("application"), true).unwrap(); assert_eq!( sources, @@ -192,12 +200,12 @@ esac ); let command = test_command(&temporary, &executable); - assert!(matches!(command.fetch(None), Err(SpagoError::Failed { .. }))); + assert!(matches!(command.fetch(None, true), Err(SpagoError::Failed { .. }))); assert!(matches!( - command.source_globs(Some("invalid")), + command.source_globs(Some("invalid"), true), Err(SpagoError::InvalidSources(_)) )); - assert!(matches!(command.source_globs(Some("empty")), Err(SpagoError::EmptySources))); + assert!(matches!(command.source_globs(Some("empty"), true), Err(SpagoError::EmptySources))); } #[cfg(unix)] diff --git a/compiler-lsp/spago/src/lockfile.rs b/compiler-lsp/spago/src/lockfile.rs index 39c4c9f6d..ecccceb9c 100644 --- a/compiler-lsp/spago/src/lockfile.rs +++ b/compiler-lsp/spago/src/lockfile.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::path::{Component, Path, PathBuf}; use globset::Glob; @@ -25,6 +25,23 @@ pub struct Workspace { #[derive(Debug, Deserialize)] pub struct WorkspacePackage { pub path: PathBuf, + #[serde(default)] + pub core: PackageDependencies, + #[serde(default)] + pub test: PackageDependencies, +} + +#[derive(Debug, Default, Deserialize)] +pub struct PackageDependencies { + #[serde(default)] + dependencies: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum Dependency { + Named(SmolStr), + Versioned(BTreeMap), } #[derive(Debug, Deserialize)] @@ -45,6 +62,7 @@ pub struct PackageSources { pub reference: PackageReference, pub roots: Vec, pub sources: Vec, + pub dependencies: BTreeSet, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -64,12 +82,18 @@ pub enum PackageEntry { rev: SmolStr, #[serde(default)] subdir: Option, + #[serde(default)] + dependencies: Vec, }, Local { path: SmolStr, + #[serde(default)] + dependencies: Vec, }, Registry { version: SmolStr, + #[serde(default)] + dependencies: Vec, }, } @@ -84,9 +108,22 @@ impl Lockfile { for (name, package) in &self.workspace.packages { let roots = vec![PathBuf::clone(&package.path)]; let sources = vec![package.path.join("src"), package.path.join("test")]; + let dependencies = package + .core + .dependencies + .iter() + .chain(&package.test.dependencies) + .flat_map(Dependency::names) + .cloned() + .collect(); packages.insert( SmolStr::clone(name), - PackageSources { reference: PackageReference::Workspace, roots, sources }, + PackageSources { + reference: PackageReference::Workspace, + roots, + sources, + dependencies, + }, ); } @@ -96,7 +133,7 @@ impl Lockfile { let mut roots = Vec::new(); let mut sources = Vec::new(); let reference = match package { - PackageEntry::Git { url, rev, subdir } => { + PackageEntry::Git { url, rev, subdir, .. } => { let root = base.join(name).join(rev); roots.push(PathBuf::clone(&root)); sources.push(root.join("src")); @@ -125,14 +162,14 @@ impl Lockfile { subdir: subdir.cloned(), } } - PackageEntry::Local { path } => { + PackageEntry::Local { path, .. } => { let root = Path::new(path).to_path_buf(); roots.push(PathBuf::clone(&root)); sources.push(root.join("src")); sources.push(root.join("test")); PackageReference::Local } - PackageEntry::Registry { version } => { + PackageEntry::Registry { version, .. } => { let name_version = format!("{name}-{version}"); let root = base.join(&name_version); roots.push(PathBuf::clone(&root)); @@ -142,7 +179,18 @@ impl Lockfile { } }; - packages.insert(SmolStr::clone(name), PackageSources { reference, roots, sources }); + let dependencies = match package { + PackageEntry::Git { dependencies, .. } + | PackageEntry::Local { dependencies, .. } + | PackageEntry::Registry { dependencies, .. } => { + dependencies.iter().cloned().collect() + } + }; + + packages.insert( + SmolStr::clone(name), + PackageSources { reference, roots, sources, dependencies }, + ); } packages @@ -169,6 +217,15 @@ impl Lockfile { } } +impl Dependency { + fn names(&self) -> Vec<&SmolStr> { + match self { + Dependency::Named(name) => vec![name], + Dependency::Versioned(packages) => packages.keys().collect_vec(), + } + } +} + fn with_root(root: impl AsRef) -> impl Fn(PathBuf) -> Option { move |source| root.as_ref().join(source).canonicalize().ok() } diff --git a/compiler-lsp/spago/tests/lockfile.rs b/compiler-lsp/spago/tests/lockfile.rs index 087281fb9..47c13ee9a 100644 --- a/compiler-lsp/spago/tests/lockfile.rs +++ b/compiler-lsp/spago/tests/lockfile.rs @@ -2,6 +2,7 @@ use std::fmt::Write; use std::path::{Path, PathBuf}; use itertools::Itertools; +use smol_str::SmolStr; use spago::lockfile::Lockfile; const SPAGO_LOCK: &str = include_str!("./fixture/spago.lock"); @@ -104,6 +105,42 @@ fn test_parse_lockfile_without_extra_packages() { "); } +#[test] +fn test_lockfile_sources_include_direct_dependencies() { + let lockfile = serde_json::from_str::( + r#"{ + "workspace": { + "packages": { + "application": { + "path": ".", + "core": { "dependencies": ["effect", { "prelude": ">=6.0.0" }] }, + "test": { "dependencies": ["assert"] } + } + } + }, + "packages": { + "effect": { + "type": "registry", + "version": "4.0.0", + "dependencies": ["prelude"] + } + } +}"#, + ) + .unwrap(); + + let packages = lockfile.sources_by_package(); + + assert_eq!( + packages["application"].dependencies.iter().map(SmolStr::as_str).collect_vec(), + vec!["assert", "effect", "prelude"] + ); + assert_eq!( + packages["effect"].dependencies.iter().map(SmolStr::as_str).collect_vec(), + vec!["prelude"] + ); +} + #[test] fn test_lockfile_sources_by_package_include_package_roots() { let lockfile = serde_json::from_str::( @@ -151,6 +188,7 @@ fn test_lockfile_sources_by_package_include_package_roots() { ".spago/p/git-package/abcd/packages/git-package/src", ".spago/p/git-package/abcd/packages/git-package/test", ], + dependencies: {}, }, "local-package": PackageSources { reference: Local, @@ -161,6 +199,7 @@ fn test_lockfile_sources_by_package_include_package_roots() { "../local-package/src", "../local-package/test", ], + dependencies: {}, }, "registry-package": PackageSources { reference: Registry { @@ -173,6 +212,7 @@ fn test_lockfile_sources_by_package_include_package_roots() { ".spago/p/registry-package-1.2.3/src", ".spago/p/registry-package-1.2.3/test", ], + dependencies: {}, }, "workspace-package": PackageSources { reference: Workspace, @@ -183,6 +223,7 @@ fn test_lockfile_sources_by_package_include_package_roots() { "packages/workspace-package/src", "packages/workspace-package/test", ], + dependencies: {}, }, } "#); diff --git a/justfile b/justfile index 96a7de5dd..da1e54791 100644 --- a/justfile +++ b/justfile @@ -54,13 +54,13 @@ fix: cargo clippy --workspace --fix && cargo fmt [doc("Update THIRDPARTY.toml")] -[working-directory: 'compiler-bin'] +[working-directory: 'compiler-bin/iris-cli'] licenses: - cargo bundle-licenses --prefer MIT -o ../THIRDPARTY.toml + cargo bundle-licenses --prefer MIT -o ../../THIRDPARTY.toml [doc("Update the release version and third-party licenses")] prepare-release version: - cargo set-version --package purescript-iris "{{version}}" + cargo set-version --package iris-cli "{{version}}" just licenses [doc("Format imports with module granularity")] diff --git a/tests-e2e/Cargo.toml b/tests-e2e/Cargo.toml index 65e543119..41571d265 100644 --- a/tests-e2e/Cargo.toml +++ b/tests-e2e/Cargo.toml @@ -6,7 +6,7 @@ publish = false autotests = false [dependencies] -purescript-iris = { path = "../compiler-bin" } +iris-cli = { path = "../compiler-bin/iris-cli" } [dev-dependencies] async-lsp = { version = "0.2.4", features = ["tokio"] } diff --git a/tests-e2e/src/bin/iris-e2e.rs b/tests-e2e/src/bin/iris-e2e.rs index f083ea938..4662be38d 100644 --- a/tests-e2e/src/bin/iris-e2e.rs +++ b/tests-e2e/src/bin/iris-e2e.rs @@ -1,3 +1,3 @@ fn main() { - purescript_iris::run(); + std::process::exit(iris_cli::run()); } diff --git a/tests-e2e/tests/cli.rs b/tests-e2e/tests/cli.rs index fce6fddda..196213670 100644 --- a/tests-e2e/tests/cli.rs +++ b/tests-e2e/tests/cli.rs @@ -27,40 +27,19 @@ fn prints_help_for_every_command_path() { ("help_build", &["build", "--help"]), ("help_watch", &["watch", "--help"]), ("help_lsp", &["lsp", "--help"]), - ("help_lsp_short", &["lsp", "-h"]), ("help_run", &["run", "--help"]), ("help_test", &["test", "--help"]), - ("help_compile", &["compile", "--help"]), - ("help_docs", &["docs", "--help"]), - ("help_docs_typescript", &["docs", "typescript", "--help"]), ]; for (name, arguments) in paths { let output = workspace.command(arguments); - assert!(output.status.success(), "{name} failed"); - assert!(!output.stdout.is_empty(), "{name} did not write stdout"); - assert!(output.stderr.is_empty(), "{name} wrote stderr"); + assert!(output.status.success(), "{name} help failed"); + assert!(!output.stdout.is_empty(), "{name} help did not write stdout"); + assert!(output.stderr.is_empty(), "{name} help wrote stderr"); snapshot_output(name, &output); } } -#[test] -fn root_help_groups_commands_by_role() { - let workspace = TestWorkspace::empty(); - let output = workspace.command(&["--help"]); - let stdout = String::from_utf8_lossy(&output.stdout); - - let project = stdout.find("Project commands:").unwrap(); - let legacy = stdout.find("Legacy commands:").unwrap(); - let documentation = stdout.find("Documentation commands:").unwrap(); - assert!(project < legacy && legacy < documentation); - for command in ["new", "add", "build", "watch", "lsp", "run", "test"] { - assert!(stdout[project..legacy].contains(command)); - } - assert!(stdout[legacy..documentation].contains("compile")); - assert!(stdout[documentation..].contains("docs")); -} - #[test] fn prints_version_to_stdout() { let workspace = TestWorkspace::empty(); @@ -72,94 +51,12 @@ fn prints_version_to_stdout() { } #[test] -fn invalid_option_values_point_to_the_supplied_argument() { - let workspace = TestWorkspace::empty(); - for (name, arguments) in [ - ("invalid_choice", vec!["build", "--color", "alway"]), - ("invalid_choice_equals", vec!["lsp", "--lsp-log=verböse"]), - ("invalid_choice_escaped", vec!["lsp", "--lsp-log", "warn\n\"λ\""]), - ("unicode_unknown_flag", vec!["--λ"]), - ] { - let output = workspace.command(&arguments); - assert_eq!(output.status.code(), Some(2), "{name}"); - assert!(output.stdout.is_empty(), "{name} wrote stdout"); - snapshot_output(name, &output); - } -} - -#[test] -fn rejects_unknown_flags_and_duplicate_scalar_options() { - let workspace = TestWorkspace::empty(); - let cases: &[(&str, &[&str])] = &[ - ("unknown_root_flag", &["--unknown"]), - ("duplicate_lsp_scalar", &["lsp", "--config", "{}", "--config", "null"]), - ("unknown_build_flag", &["build", "--unknown"]), - ("duplicate_build_scalar", &["build", "--package", "one", "--package", "two"]), - ("unknown_compile_flag", &["compile", "--unknown", "Main.purs"]), - ( - "duplicate_compile_scalar", - &["compile", "--output", "one", "--output", "two", "Main.purs"], - ), - ("unknown_docs_flag", &["docs", "--unknown", "--package", "."]), - ( - "duplicate_docs_scalar", - &["docs", "--output", "one", "--output", "two", "--package", "."], - ), - ("unknown_typescript_flag", &["docs", "typescript", "--unknown"]), - ( - "duplicate_typescript_scalar", - &["docs", "typescript", "--output", "one", "--output", "two"], - ), - ]; - - for (name, arguments) in cases { - let output = workspace.command(arguments); - assert!(!output.status.success(), "{name} unexpectedly succeeded"); - assert!(output.stdout.is_empty(), "{name} wrote stdout"); - assert!(!output.stderr.is_empty(), "{name} did not write stderr"); - snapshot_output(name, &output); - } -} - -#[test] -fn rejects_missing_required_arguments() { - let workspace = TestWorkspace::empty(); - let cases: &[(&str, &[&str])] = &[ - ("root_requires_subcommand", &[]), - ("log_file_requires_subcommand", &["--log-file"]), - ("add_requires_dependencies", &["add"]), - ("compile_requires_input_or_package", &["compile"]), - ("compile_package_requires_value", &["compile", "--package"]), - ("docs_requires_package_or_project", &["docs"]), - ("docs_package_requires_value", &["docs", "--package"]), - ("docs_project_requires_value", &["docs", "--spago-project"]), - ]; - - for (name, arguments) in cases { - let output = workspace.command(arguments); - assert!(!output.status.success(), "{name} unexpectedly succeeded"); - snapshot_output(name, &output); - } -} - -#[test] -fn lsp_options_require_the_explicit_subcommand() { +fn rejects_unpromoted_commands() { let workspace = TestWorkspace::empty(); - let cases: &[(&str, &[&str])] = &[ - ("root_stdio", &["--stdio"]), - ("root_stdio_before_lsp", &["--stdio", "lsp"]), - ("root_lsp_log", &["--lsp-log", "off", "lsp"]), - ("root_query_log", &["--query-log", "off", "lsp"]), - ("root_checking_log", &["--checking-log", "off", "lsp"]), - ("root_config", &["--config", "{}", "lsp"]), - ("root_config_file", &["--config-file", "missing.json", "lsp"]), - ]; - - for (name, arguments) in cases { - let output = workspace.command(arguments); - assert_eq!(output.status.code(), Some(2), "{name}"); - assert!(output.stdout.is_empty(), "{name} wrote stdout"); - snapshot_output(name, &output); + for command in ["compile", "docs"] { + let output = workspace.command(&[command]); + assert_eq!(output.status.code(), Some(2), "{command} was accepted"); + assert!(output.stdout.is_empty(), "{command} wrote stdout"); } } @@ -177,117 +74,30 @@ fn run_and_test_require_separator_before_trailing_arguments() { } #[test] -fn lsp_configuration_options_reject_invalid_arguments() { +fn add_requires_dependencies() { let workspace = TestWorkspace::empty(); - let cases: &[(&str, &[&str])] = &[ - ("config_requires_value", &["lsp", "--config"]), - ("config_file_requires_value", &["lsp", "--config-file"]), - ("config_conflicts_with_file", &["lsp", "--config", "{}", "--config-file", "missing.json"]), - ( - "config_file_conflicts_with_literal", - &["lsp", "--config-file", "missing.json", "--config", "{}"], - ), - ("duplicate_config_file", &["lsp", "--config-file", "one", "--config-file", "two"]), - ("removed_source_command", &["lsp", "--source-command", "custom"]), - ("removed_diagnostics_on_open", &["lsp", "--diagnostics-on-open", "false"]), - ("removed_diagnostics_on_save", &["lsp", "--diagnostics-on-save", "false"]), - ("removed_diagnostics_on_change", &["lsp", "--diagnostics-on-change"]), - ]; - - for (name, arguments) in cases { - let output = workspace.command(arguments); - assert_eq!(output.status.code(), Some(2), "{name}"); - assert!(output.stdout.is_empty(), "{name} wrote stdout"); - snapshot_output(name, &output); - } + let output = workspace.command(&["add"]); + assert_eq!(output.status.code(), Some(2), "add without dependencies succeeded"); + assert!(output.stdout.is_empty(), "add error wrote stdout"); + snapshot_output("add_requires_dependencies", &output); } #[test] -fn lsp_rejects_invalid_json_configuration_before_starting() { +fn rejects_invalid_lsp_configuration_before_starting() { let workspace = TestWorkspace::empty(); - let cases = [ - ("empty_json", ""), - ("malformed_json", "{"), - ("multiline_eof", "{\n \"diagnostics\": {\n \"onSave\":\n"), - ("trailing_json", "{} false"), - ("wrong_top_level", "false"), - ("unknown_setting", r#"{"unknown":true}"#), - ("unknown_diagnostic", r#"{"diagnostics":{"onOpened":false}}"#), - ("wrong_diagnostic_type", r#"{"diagnostics":{"onSave":"false"}}"#), - ("unknown_source_kind", r#"{"sources":{"kind":"unknown"}}"#), - ("missing_source_program", r#"{"sources":{"kind":"command"}}"#), - ("empty_source_program", r#"{"sources":{"kind":"command","program":""}}"#), - ("blank_source_program", r#"{"sources":{"kind":"command","program":" \t\n\u3000"}}"#), - ( - "invalid_source_arguments", - r#"{"sources":{"kind":"command","program":"custom","arguments":[1]}}"#, - ), - ( - "multiline_program_before_arguments", - "{\n \"sources\": {\n \"kind\": \"command\",\n \"program\": \" \",\n \"arguments\": [\"sources\"]\n }\n}", - ), - ( - "multiline_program_before_kind", - "{\n \"sources\": {\n \"program\": \" \",\n \"kind\": \"command\"\n }\n}", - ), - ( - "multiline_invalid_arguments", - "{\n \"sources\": {\n \"arguments\": [\"λ\", 12, \"sources\"],\n \"program\": \"custom\",\n \"kind\": \"command\"\n }\n}", - ), - ( - "multiline_unicode_unknown_key", - "{\r\n\t\"sources\": {\"kind\": \"command\", \"program\": \"λ\"},\r\n\t\"diagnostics\": {\"λ\": true}\r\n}", - ), - ("unicode_syntax_error", "{\n\t\"diagnostics\": {\"onSave\": λ}\n}"), - ("wrong_diagnostic_object", r#"{"diagnostics":{"onSave":{}}}"#), - ("escaped_diagnostic_key", r#"{"diagnostics":{"on\u004fpened":true}}"#), - ("duplicate_diagnostic", r#"{"diagnostics":{"onSave":null,"onSave":true}}"#), - ( - "duplicate_program", - r#"{"sources":{"kind":"command","program":"first","program":"second"}}"#, - ), - ( - "null_source_arguments", - r#"{"sources":{"kind":"command","program":"custom","arguments":null}}"#, - ), + let cases: &[(&str, &[&str])] = &[ + ("config_invalid", &["lsp", "--config", "{"]), + ("config_conflict", &["lsp", "--config", "{}", "--config-file", "missing.json"]), + ("config_file_missing", &["lsp", "--config-file", "missing.json"]), ]; - - for (name, content) in cases { - workspace.write("config/settings.json", content); - for (transport, arguments) in [ - ("inline", vec!["lsp", "--config", content]), - ("file", vec!["lsp", "--config-file", "config/settings.json"]), - ] { - let output = workspace.command(&arguments); - assert_eq!(output.status.code(), Some(2), "{name}: {transport}"); - assert!(output.stdout.is_empty(), "{name}: {transport} wrote stdout"); - snapshot_output(&format!("config_{name}_{transport}"), &output); - } - } -} - -#[test] -fn lsp_reports_configuration_file_read_errors() { - let workspace = TestWorkspace::empty(); - std::fs::write(workspace.path().join("invalid-utf8.json"), [0xff]).unwrap(); - let cases = [("missing.json", "missing"), ("invalid-utf8.json", "invalid_utf8")]; - for (path, name) in cases { - let output = workspace.command(&["lsp", "--config-file", path]); - assert_eq!(output.status.code(), Some(2)); - assert!(output.stdout.is_empty()); + for (name, arguments) in cases { + let output = workspace.command(arguments); + assert_eq!(output.status.code(), Some(2), "{name} was accepted"); + assert!(output.stdout.is_empty(), "{name} wrote stdout"); insta::with_settings!({filters => vec![ (r"No such file or directory \(os error 2\)|The system cannot find the file specified\. \(os error 2\)", "[FILE NOT FOUND]"), ]}, { - snapshot_output(&format!("config_file_{name}"), &output); + snapshot_output(name, &output); }); } } - -#[test] -fn typescript_output_resolves_relative_to_the_working_directory() { - let workspace = TestWorkspace::empty(); - let output = - workspace.command_in("project", &["docs", "typescript", "--output", "../generated"]); - assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); - assert!(workspace.path().join("generated/docs-schema.ts").is_file()); -} diff --git a/tests-e2e/tests/lsp.rs b/tests-e2e/tests/lsp.rs index a2412a76d..2192a1759 100644 --- a/tests-e2e/tests/lsp.rs +++ b/tests-e2e/tests/lsp.rs @@ -73,7 +73,7 @@ impl LanguageServer { configuration: Option, ) -> LanguageServer { let mut arguments = arguments.to_vec(); - arguments.extend(["--stdio", "--lsp-log", "off"]); + arguments.extend(["--lsp-log", "off"]); let (notifications, messages) = mpsc::channel(); let client = Arc::new(ClientState { configuration: Mutex::new(configuration), @@ -163,7 +163,7 @@ impl LanguageServer { }), ) .await - .expect("timed out waiting for initialize response") + .expect("invariant violated: timed out waiting for initialize response") .unwrap() }); assert_ne!(result.capabilities, Default::default()); @@ -315,7 +315,7 @@ impl LanguageServer { self.runtime.block_on(async { timeout(Duration::from_secs(10), self.server.shutdown(())) .await - .expect("timed out waiting for shutdown response") + .expect("invariant violated: timed out waiting for shutdown response") .unwrap(); }); self.server.exit(()).unwrap(); @@ -323,7 +323,7 @@ impl LanguageServer { let mainloop = self .runtime .block_on(async { timeout(Duration::from_secs(10), mainloop).await }) - .expect("timed out stopping language client") + .expect("invariant violated: timed out stopping language client") .unwrap(); match mainloop { Ok(()) | Err(Error::Eof) => {} @@ -332,7 +332,7 @@ impl LanguageServer { let status = self .runtime .block_on(async { timeout(Duration::from_secs(10), self.child.wait()).await }) - .expect("timed out stopping language server") + .expect("invariant violated: timed out stopping language server") .unwrap(); let unexpected_requests = self.client.unexpected_requests.lock().unwrap(); assert!( @@ -407,6 +407,7 @@ fn empty_configuration_preserves_spago_and_default_diagnostics() { let cases: &[&[&str]] = &[ &["lsp"], + &["lsp", "--stdio"], &["lsp", "--config", "null"], &["lsp", "--config-file", "config/empty.json"], &[ diff --git a/tests-e2e/tests/package_manager/add.rs b/tests-e2e/tests/package_manager/add.rs index 43c80b2e4..afff95a18 100644 --- a/tests-e2e/tests/package_manager/add.rs +++ b/tests-e2e/tests/package_manager/add.rs @@ -25,6 +25,8 @@ fn adds_a_workspace_dependency_with_real_spago() { let output = workspace.command(&["add", "--package", "application", "library"]); assert_success(&output); + assert!(workspace.path().join("spago.lock").is_file()); + workspace.assert_spago_calls("", &[&["fetch", "-p", "application", "library"]]); insta::assert_snapshot!(workspace.read("packages/application/spago.yaml"), @r#" package: @@ -32,6 +34,4 @@ fn adds_a_workspace_dependency_with_real_spago() { dependencies: - library: "*" "#); - assert!(workspace.path().join("spago.lock").is_file()); - workspace.assert_spago_calls("", &[&["fetch", "-p", "application", "library"]]); } diff --git a/tests-e2e/tests/package_manager/build.rs b/tests-e2e/tests/package_manager/build.rs index 544f89b30..e68491523 100644 --- a/tests-e2e/tests/package_manager/build.rs +++ b/tests-e2e/tests/package_manager/build.rs @@ -1,207 +1,116 @@ +use std::process::Output; + use super::support::{TestWorkspace, assert_success}; -fn diagnostic_settings(workspace: &TestWorkspace) -> insta::Settings { - let mut settings = insta::Settings::clone_current(); - settings.set_strip_ansi_escape_codes(true); - let workspace_path = std::fs::canonicalize(workspace.path()).unwrap(); - let workspace_path = workspace_path.to_string_lossy(); - let workspace_path = regex::escape(workspace_path.trim_start_matches(r"\\?\")); - settings.add_filter(&format!(r"{workspace_path}[/\\]"), ""); - settings.add_filter(r"src\\Main\.purs", "src/Main.purs"); - settings.add_filter( - concat!( - r"(?m)^(?:Reading Spago workspace configuration\.\.\.", - r"|Refreshing the Registry Index\.\.\.", - r"|Cloning https://github\.com/purescript/registry-index\.git", - r"|Cloning https://github\.com/purescript/registry\.git", - r"|✓ Selecting package to build: application", - r#"|Adding dependency ranges to the config in "spago.yaml""#, - r"|Downloading dependencies\.\.\.", - r"|No lockfile found, generating it\.\.\.", - r"|Lockfile written to spago.lock\. Please commit this file\.)\r?\n(?:\r?\n)*", - ), - "", - ); - settings +fn snapshot_output(name: &str, output: &Output) { + let status = output.status.code().map_or_else(|| "signal".to_owned(), |code| code.to_string()); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + insta::with_settings!({omit_expression => true}, { + insta::assert_snapshot!( + name, + format!("status: {status}\n--- stdout\n{stdout}--- stderr\n{stderr}") + ); + }); } #[test] -fn builds_a_single_package_with_real_spago() { +fn builds_the_selected_workspace_package() { let workspace = TestWorkspace::empty(); + workspace.write("spago.yaml", "workspace: {}\n"); workspace.write( - "spago.yaml", - r#"workspace: {} -package: - name: application - dependencies: [] -"#, + "packages/application/spago.yaml", + "package:\n name: application\n dependencies: []\n", ); - workspace.write( - "src/Main.purs", - r#"module Main where + workspace.write("packages/application/src/Main.purs", "module Main where\n\nvalue = 42\n"); + workspace + .write("packages/library/spago.yaml", "package:\n name: library\n dependencies: []\n"); + workspace.write("packages/library/src/Library.purs", "module Library where\n"); -value = 42 -"#, - ); + let output = workspace.command_in("packages/application/src", &["build", "--quiet"]); - let output = workspace.command(&["build", "--quiet"]); assert_success(&output); - assert!(workspace.path().join("spago.lock").is_file()); assert!(workspace.path().join("output/Main/index.js").is_file()); + assert!(!workspace.path().join("output/Library/index.js").exists()); workspace.assert_spago_calls( - "", + "packages/application/src", &[&["fetch", "-p", "application"], &["sources", "--json", "-p", "application"]], ); } #[test] -fn builds_resilient_output_despite_diagnostics() { +fn builds_the_whole_workspace_from_a_root_package_subdirectory() { let workspace = TestWorkspace::empty(); workspace.write( "spago.yaml", - r#"workspace: {} -package: - name: application - dependencies: [] -"#, + "workspace: {}\npackage:\n name: application\n dependencies: [library]\n", ); workspace.write( - "src/Main.purs", - r#"module Main where - -usable = 42 - -broken = missing -"#, + "src/Application.purs", + "module Application where\n\nimport Library (library)\n\napplication = library\n", ); + workspace + .write("packages/library/spago.yaml", "package:\n name: library\n dependencies: []\n"); + workspace.write("packages/library/src/Library.purs", "module Library where\n\nlibrary = 42\n"); - let output = workspace.command(&["build", "--quiet", "--resilient"]); - assert!( - !output.status.success(), - "command unexpectedly succeeded\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - let stderr = String::from_utf8_lossy(&output.stderr); - let _settings = diagnostic_settings(&workspace).bind_to_scope(); - insta::assert_snapshot!("resilient_source_diagnostics", stderr); + let output = workspace.command_in("src", &["build", "--quiet"]); - let generated = workspace.read("output/Main/index.js"); - assert!(generated.contains("Generated code reached a source error")); - assert!(generated.contains("export const broken")); - assert!(generated.contains("export const usable = 42 | 0;")); - workspace.assert_spago_calls( - "", - &[&["fetch", "-p", "application"], &["sources", "--json", "-p", "application"]], - ); + assert_success(&output); + assert!(workspace.path().join("output/Application/index.js").is_file()); + assert!(workspace.path().join("output/Library/index.js").is_file()); + workspace.assert_spago_calls("src", &[&["fetch"], &["sources", "--json"]]); } #[test] -fn gates_initializer_cycle_output_on_resilience() { +fn excludes_gitignored_packages_from_selection() { let workspace = TestWorkspace::empty(); - workspace.write( - "spago.yaml", - r#"workspace: {} -package: - name: application - dependencies: [] -"#, - ); - workspace.write( - "src/Main.purs", - r#"module Main where + workspace + .write("spago.yaml", "workspace: {}\npackage:\n name: application\n dependencies: []\n"); + workspace.write(".gitignore", "ignored/\n"); + workspace.write("ignored/spago.yaml", "package:\n name: ignored\n dependencies: []\n"); -first :: Int -first = second + let output = workspace.command(&["build", "--quiet", "--package", "ignored"]); -second :: Int -second = first -"#, - ); - - let strict = workspace.command(&["build", "--quiet"]); - assert!(!strict.status.success()); - let stderr = String::from_utf8_lossy(&strict.stderr); - let _settings = diagnostic_settings(&workspace).bind_to_scope(); - insta::assert_snapshot!("strict_initializer_cycle_diagnostics", stderr); - assert!(!workspace.path().join("output/Main/index.js").exists()); - - let resilient = workspace.command(&["build", "--quiet", "--resilient"]); - assert!(!resilient.status.success()); - let stderr = String::from_utf8_lossy(&resilient.stderr); - insta::assert_snapshot!("resilient_initializer_cycle_diagnostics", stderr); - let generated = workspace.read("output/Main/index.js"); - assert!(generated.contains("Top-level value initializer cycle")); - assert!(!generated.contains("@__PURE__")); - workspace.assert_spago_calls( - "", - &[ - &["fetch", "-p", "application"], - &["sources", "--json", "-p", "application"], - &["fetch", "-p", "application"], - &["sources", "--json", "-p", "application"], - ], + assert_eq!(output.status.code(), Some(1)); + assert!(output.stdout.is_empty()); + assert_eq!( + String::from_utf8_lossy(&output.stderr), + "workspace package 'ignored' was not found; available packages: application\n" ); + workspace.assert_spago_calls("", &[]); } #[test] -fn reports_backend_failures_as_diagnostics() { +fn excludes_nested_workspaces_from_selection() { let workspace = TestWorkspace::empty(); + workspace + .write("spago.yaml", "workspace: {}\npackage:\n name: application\n dependencies: []\n"); workspace.write( - "spago.yaml", - r#"workspace: {} -package: - name: application - dependencies: [] -"#, + "nested/spago.yaml", + "workspace: {}\npackage:\n name: nested\n dependencies: []\n", ); - workspace.write( - "src/Main.purs", - r#"module Main where - -import Iris.StyleX (Props, Style, props) + workspace + .write("nested/packages/child/spago.yaml", "package:\n name: child\n dependencies: []\n"); -partialProps :: Style -> Props -partialProps = props -"#, - ); + let output = workspace.command(&["build", "--quiet", "--package", "child"]); - let output = workspace.command(&["build", "--quiet"]); - assert!(!output.status.success()); - let stderr = String::from_utf8_lossy(&output.stderr); - let _settings = diagnostic_settings(&workspace).bind_to_scope(); - insta::assert_snapshot!("backend_failure_diagnostics", stderr); - assert!(!workspace.path().join("output/Main/index.js").exists()); - workspace.assert_spago_calls( - "", - &[&["fetch", "-p", "application"], &["sources", "--json", "-p", "application"]], + assert_eq!(output.status.code(), Some(1)); + assert!(output.stdout.is_empty()); + assert_eq!( + String::from_utf8_lossy(&output.stderr), + "workspace package 'child' was not found; available packages: application\n" ); + workspace.assert_spago_calls("", &[]); } #[test] -fn builds_the_whole_workspace_from_a_root_package_subdirectory() { +fn suppresses_diagnostics_without_changing_failure_status() { let workspace = TestWorkspace::empty(); - workspace.write( - "spago.yaml", - r#"workspace: {} -package: - name: application - dependencies: [] -"#, - ); - workspace.write("src/Application.purs", "module Application where\n"); - workspace.write( - "packages/library/spago.yaml", - r#"package: - name: library - dependencies: [] -"#, - ); - workspace.write("packages/library/src/Library.purs", "module Library where\n"); + workspace + .write("spago.yaml", "workspace: {}\npackage:\n name: application\n dependencies: []\n"); + workspace.write("src/Main.purs", "module Main where\n\nbroken = missing\n"); - let output = workspace.command_in("src", &["build", "--quiet"]); - assert_success(&output); - assert!(workspace.path().join("output/Application/index.js").is_file()); - assert!(workspace.path().join("output/Library/index.js").is_file()); - workspace.assert_spago_calls("src", &[&["fetch"], &["sources", "--json"]]); + let output = workspace.command(&["build", "--quiet", "--no-diagnostics"]); + + snapshot_output("suppressed_error_diagnostics", &output); } diff --git a/tests-e2e/tests/package_manager/run.rs b/tests-e2e/tests/package_manager/run.rs index 1c5e11ecf..3839af817 100644 --- a/tests-e2e/tests/package_manager/run.rs +++ b/tests-e2e/tests/package_manager/run.rs @@ -37,3 +37,32 @@ foreign import main :: Effect Unit &[&["fetch", "-p", "application"], &["sources", "--json", "-p", "application"]], ); } + +#[test] +fn preserves_the_node_exit_code() { + let workspace = TestWorkspace::empty(); + workspace.write( + "spago.yaml", + r#"workspace: {} +package: + name: application + dependencies: [] +"#, + ); + workspace.write( + "src/Main.purs", + r#"module Main where + +data Unit = Unit +foreign import data Effect :: Type -> Type +foreign import main :: Effect Unit +"#, + ); + workspace.write("src/Main.js", "export const main = () => { process.exitCode = 7; };\n"); + + let output = workspace.command(&["run", "--quiet"]); + + assert_eq!(output.status.code(), Some(7)); + assert!(output.stdout.is_empty()); + assert!(String::from_utf8_lossy(&output.stderr).starts_with("Node.js exited with status ")); +} diff --git a/tests-e2e/tests/package_manager/snapshots/package_manager__build__backend_failure_diagnostics.snap b/tests-e2e/tests/package_manager/snapshots/package_manager__build__backend_failure_diagnostics.snap deleted file mode 100644 index 6bb46435b..000000000 --- a/tests-e2e/tests/package_manager/snapshots/package_manager__build__backend_failure_diagnostics.snap +++ /dev/null @@ -1,18 +0,0 @@ ---- -source: tests-e2e/tests/package_manager/build.rs -expression: stderr ---- -Error! · [FunctionalCodegen] · src/Main.purs:5:1 - • - │ - • Cannot generate JavaScript for module 'Main'. - │ - │ 'Iris.StyleX.props' must be called directly with all of its arguments. - │ - │ It cannot be passed around as a function or partially applied. - │ - │ partialProps :: Style -> Props - │ ╰───────────────────────────── - • - -compilation failed diff --git a/tests-e2e/tests/package_manager/snapshots/package_manager__build__resilient_initializer_cycle_diagnostics.snap b/tests-e2e/tests/package_manager/snapshots/package_manager__build__resilient_initializer_cycle_diagnostics.snap deleted file mode 100644 index 95323ade0..000000000 --- a/tests-e2e/tests/package_manager/snapshots/package_manager__build__resilient_initializer_cycle_diagnostics.snap +++ /dev/null @@ -1,19 +0,0 @@ ---- -source: tests-e2e/tests/package_manager/build.rs -expression: stderr ---- -Error! · [JavaScriptInitializerCycle] · src/Main.purs:6:1 - • - │ - • This declaration is cyclic with other declarations - │ - │ second :: Int - │ ╰──────────── - │ - • This declaration is a member of the same cycle - │ - │ first :: Int - │ ╰─────────── - • - -compilation failed diff --git a/tests-e2e/tests/package_manager/snapshots/package_manager__build__resilient_source_diagnostics.snap b/tests-e2e/tests/package_manager/snapshots/package_manager__build__resilient_source_diagnostics.snap deleted file mode 100644 index 562f63cb4..000000000 --- a/tests-e2e/tests/package_manager/snapshots/package_manager__build__resilient_source_diagnostics.snap +++ /dev/null @@ -1,23 +0,0 @@ ---- -source: tests-e2e/tests/package_manager/build.rs -expression: stderr ---- -Error! · [NotInScope] · src/Main.purs:5:10 - • - │ - • 'missing' is not in scope - │ - │ broken = missing - │ ╰────── - • - -Error! · [CannotUnify] · src/Main.purs:5:10 - • - │ - • Cannot unify 'Type' with '?[invalid kind]' - │ - │ broken = missing - │ ╰────── - • - -compilation failed diff --git a/tests-e2e/tests/package_manager/snapshots/package_manager__build__strict_initializer_cycle_diagnostics.snap b/tests-e2e/tests/package_manager/snapshots/package_manager__build__strict_initializer_cycle_diagnostics.snap deleted file mode 100644 index 95323ade0..000000000 --- a/tests-e2e/tests/package_manager/snapshots/package_manager__build__strict_initializer_cycle_diagnostics.snap +++ /dev/null @@ -1,19 +0,0 @@ ---- -source: tests-e2e/tests/package_manager/build.rs -expression: stderr ---- -Error! · [JavaScriptInitializerCycle] · src/Main.purs:6:1 - • - │ - • This declaration is cyclic with other declarations - │ - │ second :: Int - │ ╰──────────── - │ - • This declaration is a member of the same cycle - │ - │ first :: Int - │ ╰─────────── - • - -compilation failed diff --git a/tests-e2e/tests/package_manager/snapshots/package_manager__build__suppressed_error_diagnostics.snap b/tests-e2e/tests/package_manager/snapshots/package_manager__build__suppressed_error_diagnostics.snap new file mode 100644 index 000000000..0d47df52e --- /dev/null +++ b/tests-e2e/tests/package_manager/snapshots/package_manager__build__suppressed_error_diagnostics.snap @@ -0,0 +1,6 @@ +--- +source: tests-e2e/tests/package_manager/build.rs +--- +status: 1 +--- stdout +--- stderr diff --git a/tests-e2e/tests/package_manager/snapshots/package_manager__watch__watch_diagnostic_recovery.snap b/tests-e2e/tests/package_manager/snapshots/package_manager__watch__watch_diagnostic_recovery.snap new file mode 100644 index 000000000..bfdbb5eeb --- /dev/null +++ b/tests-e2e/tests/package_manager/snapshots/package_manager__watch__watch_diagnostic_recovery.snap @@ -0,0 +1,20 @@ +--- +source: tests-e2e/tests/package_manager/watch.rs +--- +Error! · [NotInScope] · src/Main.purs:3:9 + • + │ + • 'missing' is not in scope + │ + │ value = missing + │ ╰────── + • + +Error! · [CannotUnify] · src/Main.purs:3:9 + • + │ + • Cannot unify 'Type' with '?[invalid kind]' + │ + │ value = missing + │ ╰────── + • diff --git a/tests-e2e/tests/package_manager/snapshots/package_manager__watch__watch_single_package.snap b/tests-e2e/tests/package_manager/snapshots/package_manager__watch__watch_single_package.snap new file mode 100644 index 000000000..7721f8c7c --- /dev/null +++ b/tests-e2e/tests/package_manager/snapshots/package_manager__watch__watch_single_package.snap @@ -0,0 +1,5 @@ +--- +source: tests-e2e/tests/package_manager/watch.rs +--- +[TIME] Build succeeded in [DURATION] + Loaded 1 input: Main diff --git a/tests-e2e/tests/package_manager/snapshots/package_manager__watch__watch_workspace.snap b/tests-e2e/tests/package_manager/snapshots/package_manager__watch__watch_workspace.snap new file mode 100644 index 000000000..f4446471d --- /dev/null +++ b/tests-e2e/tests/package_manager/snapshots/package_manager__watch__watch_workspace.snap @@ -0,0 +1,5 @@ +--- +source: tests-e2e/tests/package_manager/watch.rs +--- +[TIME] Build succeeded in [DURATION] + Loaded 2 inputs: Application, Library diff --git a/tests-e2e/tests/package_manager/test.rs b/tests-e2e/tests/package_manager/test.rs index 32892c56a..e2978368f 100644 --- a/tests-e2e/tests/package_manager/test.rs +++ b/tests-e2e/tests/package_manager/test.rs @@ -12,6 +12,7 @@ package: test: main: Application.Test dependencies: [] + execArgs: [from-manifest] "#, ); workspace.write( @@ -25,12 +26,12 @@ foreign import main :: Effect Unit ); workspace.write( "test/Application/Test.js", - "export const main = () => console.log(\"tests ran\");\n", + "export const main = () => console.log(`tests ran: ${process.argv.slice(2).join(\",\")}`);\n", ); let output = workspace.command(&["test", "--quiet"]); assert_success(&output); - assert_eq!(String::from_utf8_lossy(&output.stdout), "tests ran\n"); + assert_eq!(String::from_utf8_lossy(&output.stdout), "tests ran: from-manifest\n"); workspace.assert_spago_calls( "", &[&["fetch", "-p", "application"], &["sources", "--json", "-p", "application"]], diff --git a/tests-e2e/tests/package_manager/watch.rs b/tests-e2e/tests/package_manager/watch.rs index 07991e54c..83ab4485b 100644 --- a/tests-e2e/tests/package_manager/watch.rs +++ b/tests-e2e/tests/package_manager/watch.rs @@ -1,6 +1,8 @@ -use std::path::PathBuf; -use std::process::{Child, Output}; -use std::thread; +use std::io::{BufRead, BufReader}; +use std::path::Path; +use std::process::Child; +use std::sync::{Arc, Mutex}; +use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant}; use itertools::Itertools; @@ -26,12 +28,15 @@ value = 42 "#, ); - let child = workspace.spawn(&["watch"]); + let mut watch = WatchProcess::new(workspace.spawn(&["watch"])); let output = workspace.path().join("output/Main/index.js"); - let result = watch_until_outputs(child, &[output]); - insta::assert_snapshot!(normalized_watch_log(&result), @r#" - [TIME] 1 file changed: Main - "#); + watch.wait_for("initial compilation", |stdout, _| { + stdout.contains("Loaded 1 input: Main") && output.is_file() + }); + watch.stop(); + insta::with_settings!({omit_expression => true}, { + insta::assert_snapshot!("watch_single_package", normalized_watch_log(&watch.stdout())); + }); workspace.assert_spago_calls( "", @@ -60,48 +65,222 @@ package: ); workspace.write("packages/library/src/Library.purs", "module Library where\n"); - let child = workspace.spawn_in("src", &["watch"]); + let mut watch = WatchProcess::new(workspace.spawn_in("src", &["watch"])); let outputs = [ workspace.path().join("output/Application/index.js"), workspace.path().join("output/Library/index.js"), ]; - let result = watch_until_outputs(child, &outputs); - insta::assert_snapshot!(normalized_watch_log(&result), @r#" - [TIME] 2 files changed: Application, Library - "#); + watch.wait_for("initial workspace compilation", |stdout, _| { + stdout.contains("Loaded 2 inputs: Application, Library") + && outputs.iter().all(|output| output.is_file()) + }); + watch.stop(); + insta::with_settings!({omit_expression => true}, { + insta::assert_snapshot!("watch_workspace", normalized_watch_log(&watch.stdout())); + }); workspace.assert_spago_calls("src", &[&["fetch"], &["sources", "--json"]]); } -fn watch_until_outputs(mut child: Child, outputs: &[PathBuf]) -> Output { - let deadline = Instant::now() + Duration::from_secs(30); - while !outputs.iter().all(|output| output.is_file()) { - if let Some(status) = child.try_wait().unwrap() { - let output = child.wait_with_output().unwrap(); - panic!( - "watch exited before compiling with status {status}\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - } - if Instant::now() >= deadline { - child.kill().unwrap(); - let output = child.wait_with_output().unwrap(); - panic!( - "watch did not compile within 30 seconds\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - } - thread::sleep(Duration::from_millis(50)); - } - child.kill().unwrap(); - child.wait_with_output().unwrap() +#[test] +fn rebuilds_after_edits_and_recovers_from_diagnostics() { + let workspace = TestWorkspace::empty(); + workspace + .write("spago.yaml", "workspace: {}\npackage:\n name: application\n dependencies: []\n"); + workspace.write("src/Main.purs", "module Main where\n\nvalue = 42\n"); + + let mut watch = WatchProcess::new(workspace.spawn(&["watch"])); + let output = workspace.path().join("output/Main/index.js"); + watch.wait_for("initial compilation", |stdout, _| { + stdout.contains("Loaded 1 input: Main") && output.is_file() + }); + + workspace.write("src/Main.purs", "module Main where\n\nvalue = missing\n"); + watch.wait_for("diagnostic rebuild", |stdout, stderr| { + stdout.contains("Rebuild completed with diagnostics") && stderr.contains("[NotInScope]") + }); + + workspace.write("src/Main.purs", "module Main where\n\nvalue = 43\n"); + watch.wait_for("recovered rebuild", |stdout, _| { + stdout.contains("Rebuild succeeded") && generated_module_contains(&output, "43") + }); + watch.stop(); + let mut settings = insta::Settings::clone_current(); + settings.set_strip_ansi_escape_codes(true); + settings.add_filter(r"(?s)\A.*?(Error! ·)", "$1"); + let _settings = settings.bind_to_scope(); + insta::with_settings!({omit_expression => true}, { + insta::assert_snapshot!("watch_diagnostic_recovery", watch.stderr()); + }); } -fn normalized_watch_log(output: &Output) -> String { - let stdout = String::from_utf8_lossy(&output.stdout); +#[test] +fn suppresses_diagnostics_without_hiding_watch_summaries() { + let workspace = TestWorkspace::empty(); + workspace + .write("spago.yaml", "workspace: {}\npackage:\n name: application\n dependencies: []\n"); + workspace.write("src/Main.purs", "module Main where\n\nvalue = missing\n"); + + let child = workspace.spawn(&["watch", "--no-diagnostics"]); + let mut watch = WatchProcess::new(child); + watch.wait_for("suppressed diagnostic build", |stdout, _| { + stdout.contains("Build completed with diagnostics") + }); + assert!(!watch.stderr().contains("[NotInScope]")); + + workspace.write("src/Main.purs", "module Main where\n\nvalue = 42\n"); + let output = workspace.path().join("output/Main/index.js"); + watch.wait_for("recovery with diagnostics suppressed", |stdout, _| { + stdout.contains("Rebuild succeeded") && output.is_file() + }); +} + +#[test] +fn adds_and_removes_sources_and_stale_outputs() { + let workspace = TestWorkspace::empty(); + workspace + .write("spago.yaml", "workspace: {}\npackage:\n name: application\n dependencies: []\n"); + workspace.write("src/Main.purs", "module Main where\n"); + + let mut watch = WatchProcess::new(workspace.spawn(&["watch"])); + let main_output = workspace.path().join("output/Main/index.js"); + watch.wait_for("initial compilation", |stdout, _| { + stdout.contains("Loaded 1 input: Main") && main_output.is_file() + }); + + workspace.write("src/Extra.purs", "module Extra where\n\nvalue = 42\n"); + let extra_output = workspace.path().join("output/Extra/index.js"); + watch.wait_for("source addition", |stdout, _| { + stdout.contains("Changed 1 input: Extra") && extra_output.is_file() + }); + + std::fs::remove_file(workspace.path().join("src/Extra.purs")).unwrap(); + watch.wait_for("source removal", |stdout, _| { + stdout.contains("Changed 1 input: Extra") && !extra_output.exists() + }); +} + +#[test] +fn rebuilds_for_ffi_changes_and_reconciles_foreign_outputs() { + let workspace = TestWorkspace::empty(); + workspace + .write("spago.yaml", "workspace: {}\npackage:\n name: application\n dependencies: []\n"); + workspace.write("src/Main.purs", "module Main where\n\nforeign import value :: Int\n"); + workspace.write("src/Main.js", "export const value = 42;\n"); + + let mut watch = WatchProcess::new(workspace.spawn(&["watch"])); + let javascript_output = workspace.path().join("output/Main/foreign.js"); + watch.wait_for("initial FFI compilation", |stdout, _| { + stdout.contains("Loaded 1 input: Main") + && generated_module_contains(&javascript_output, "42") + }); + + workspace.write("src/Main.js", "export const value = 43;\n"); + watch.wait_for("JavaScript FFI rebuild", |stdout, _| { + stdout.contains("Changed 1 input: Main") + && generated_module_contains(&javascript_output, "43") + }); + + std::fs::remove_file(workspace.path().join("src/Main.js")).unwrap(); + workspace.write("src/Main.jsx", "export const value = 44;\n"); + let jsx_output = workspace.path().join("output/Main/foreign.jsx"); + watch.wait_for("JSX FFI rebuild", |stdout, _| { + stdout.contains("Changed 1 input: Main") + && generated_module_contains(&jsx_output, "44") + && !javascript_output.exists() + }); +} + +#[test] +fn waits_for_a_deleted_input_directory_and_builds_when_it_returns() { + let workspace = TestWorkspace::empty(); + workspace + .write("spago.yaml", "workspace: {}\npackage:\n name: application\n dependencies: []\n"); + workspace.write("src/Main.purs", "module Main where\n\nvalue = 42\n"); + + let mut watch = WatchProcess::new(workspace.spawn(&["watch"])); + let output = workspace.path().join("output/Main/index.js"); + watch.wait_for("initial compilation", |stdout, _| { + stdout.contains("Loaded 1 input: Main") && output.is_file() + }); + + std::fs::remove_dir_all(workspace.path().join("src")).unwrap(); + watch.wait_for("empty input state", |stdout, _| { + stdout.contains("No input files; waiting for changes") && !output.exists() + }); + + workspace.write("src/Main.purs", "module Main where\n\nvalue = 43\n"); + watch.wait_for("recreated input compilation", |stdout, _| { + stdout.contains("Rebuild succeeded") && generated_module_contains(&output, "43") + }); +} + +#[test] +fn resolves_relative_output_from_the_invocation_directory_and_excludes_it() { + let workspace = TestWorkspace::empty(); + workspace + .write("spago.yaml", "workspace: {}\npackage:\n name: application\n dependencies: []\n"); + workspace.write("src/Main.purs", "module Main where\n"); + workspace.write("src/generated/Ignored.purs", "this is not PureScript\n"); + + let child = + workspace.spawn_in("src", &["watch", "--package", "application", "--output", "generated"]); + let mut watch = WatchProcess::new(child); + let output = workspace.path().join("src/generated/Main/index.js"); + watch.wait_for("custom output compilation", |stdout, _| { + stdout.contains("Build succeeded") && output.is_file() + }); +} + +#[test] +fn reports_output_failures_separately_and_recovers_on_a_later_change() { + let workspace = TestWorkspace::empty(); + workspace + .write("spago.yaml", "workspace: {}\npackage:\n name: application\n dependencies: []\n"); + workspace.write("src/Main.purs", "module Main where\n\nforeign import value :: Int\n"); + workspace.write("src/Main.js", "export const value = 42;\n"); + std::fs::create_dir_all(workspace.path().join("output/Main/foreign.js")).unwrap(); + + let child = workspace.spawn(&["watch", "--quiet"]); + let mut watch = WatchProcess::new(child); + let partial_output = workspace.path().join("output/Main/index.js"); + watch.wait_for("partial output failure", |_, stderr| { + stderr.contains("Watch build failed:") && partial_output.is_file() + }); + assert!(!watch.stderr().contains("completed with diagnostics")); + + thread::sleep(Duration::from_millis(500)); + let settled_failures = watch.stderr().matches("Watch build failed:").count(); + thread::sleep(Duration::from_millis(500)); + assert_eq!(watch.stderr().matches("Watch build failed:").count(), settled_failures); + + std::fs::remove_file(workspace.path().join("src/Main.purs")).unwrap(); + watch.wait_for("partial output cleanup", |_, _| !partial_output.exists()); +} + +fn generated_module_contains(path: &Path, expected: &str) -> bool { + std::fs::read_to_string(path).is_ok_and(|source| source.contains(expected)) +} + +fn normalized_watch_log(stdout: &str) -> String { let mut lines = stdout.lines().map(|line| { + let prefix = line.as_bytes().get(..8); + let is_plain_timestamp = prefix.is_some_and(|timestamp| { + timestamp[2] == b':' + && timestamp[5] == b':' + && timestamp + .iter() + .enumerate() + .all(|(index, byte)| matches!(index, 2 | 5) || byte.is_ascii_digit()) + }); + if is_plain_timestamp && line.as_bytes().get(8..10) == Some(b" ") { + let message = &line[10..]; + let message = message.rsplit_once(" in ").map_or_else( + || message.to_owned(), + |(message, _)| format!("{message} in [DURATION]"), + ); + return format!("[TIME] {message}"); + } let Some((timestamp, message)) = line.split_once("] ") else { return line.to_owned(); }; @@ -117,3 +296,110 @@ fn normalized_watch_log(output: &Output) -> String { }); lines.join("\n") } + +struct WatchProcess { + child: Child, + stdout: Arc>, + stderr: Arc>, + stdout_reader: Option>, + stderr_reader: Option>, + stdout_cursor: usize, + stderr_cursor: usize, +} + +impl WatchProcess { + fn new(mut child: Child) -> WatchProcess { + let stdout = Arc::new(Mutex::new(String::new())); + let stderr = Arc::new(Mutex::new(String::new())); + let stdout_reader = spawn_reader( + child.stdout.take().expect("invariant violated: watch process stdout is not piped"), + Arc::clone(&stdout), + ); + let stderr_reader = spawn_reader( + child.stderr.take().expect("invariant violated: watch process stderr is not piped"), + Arc::clone(&stderr), + ); + WatchProcess { + child, + stdout, + stderr, + stdout_reader: Some(stdout_reader), + stderr_reader: Some(stderr_reader), + stdout_cursor: 0, + stderr_cursor: 0, + } + } + + fn wait_for(&mut self, description: &str, predicate: impl Fn(&str, &str) -> bool) { + let deadline = Instant::now() + Duration::from_secs(30); + loop { + let stdout = self.stdout(); + let stderr = self.stderr(); + let new_stdout = &stdout[self.stdout_cursor..]; + let new_stderr = &stderr[self.stderr_cursor..]; + if predicate(new_stdout, new_stderr) { + self.stdout_cursor = stdout.len(); + self.stderr_cursor = stderr.len(); + return; + } + if let Some(status) = self.child.try_wait().unwrap() { + panic!( + "watch exited before {description} with status {status}\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + } + if Instant::now() >= deadline { + self.stop(); + panic!( + "watch did not reach {description} within 30 seconds\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + } + thread::sleep(Duration::from_millis(20)); + } + } + + fn stdout(&self) -> String { + let stdout = + self.stdout.lock().expect("invariant violated: watch stdout capture is poisoned"); + String::clone(&stdout) + } + + fn stderr(&self) -> String { + let stderr = + self.stderr.lock().expect("invariant violated: watch stderr capture is poisoned"); + String::clone(&stderr) + } + + fn stop(&mut self) { + if self.child.try_wait().unwrap().is_none() { + self.child.kill().unwrap(); + } + let _ = self.child.wait(); + if let Some(reader) = self.stdout_reader.take() { + reader.join().unwrap(); + } + if let Some(reader) = self.stderr_reader.take() { + reader.join().unwrap(); + } + } +} + +impl Drop for WatchProcess { + fn drop(&mut self) { + self.stop(); + } +} + +fn spawn_reader( + stream: impl std::io::Read + Send + 'static, + capture: Arc>, +) -> JoinHandle<()> { + thread::spawn(move || { + for line in BufReader::new(stream).lines() { + let line = line.unwrap(); + let mut capture = + capture.lock().expect("invariant violated: watch output capture is poisoned"); + capture.push_str(&line); + capture.push('\n'); + } + }) +} diff --git a/tests-e2e/tests/snapshots/cli__add_requires_dependencies.snap b/tests-e2e/tests/snapshots/cli__add_requires_dependencies.snap index ff54ce5d3..208207c8e 100644 --- a/tests-e2e/tests/snapshots/cli__add_requires_dependencies.snap +++ b/tests-e2e/tests/snapshots/cli__add_requires_dependencies.snap @@ -4,16 +4,9 @@ source: tests-e2e/tests/cli.rs status: 2 --- stdout --- stderr -Error! · [missing_required] · command line:1:1 - • - │ - • the following required arguments were not provided: - │ … - │ - │ Usage: iris add [-p --package ] [--test] … - │ - │ For more information, try '--help'. - │ - │ iris add - │ ╰─────── - • +error: the following required arguments were not provided: + … + +Usage: iris add [-p --package ] [--test] … + +For more information, try '--help'. diff --git a/tests-e2e/tests/snapshots/cli__compile_package_requires_value.snap b/tests-e2e/tests/snapshots/cli__compile_package_requires_value.snap deleted file mode 100644 index 6cec5da50..000000000 --- a/tests-e2e/tests/snapshots/cli__compile_package_requires_value.snap +++ /dev/null @@ -1,16 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [missing_flag_value] · command line:1:14 - • - │ - • a value is required for '--package ' but none was supplied - │ - │ For more information, try '--help'. - │ - │ iris compile --package - │ ╰──────── - • diff --git a/tests-e2e/tests/snapshots/cli__compile_requires_input_or_package.snap b/tests-e2e/tests/snapshots/cli__compile_requires_input_or_package.snap deleted file mode 100644 index fafca12af..000000000 --- a/tests-e2e/tests/snapshots/cli__compile_requires_input_or_package.snap +++ /dev/null @@ -1,19 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [missing_required] · command line:1:1 - • - │ - • the following required arguments were not provided: - │ [INPUT]… - │ - │ Usage: iris compile [FLAGS] [INPUT]… - │ - │ For more information, try '--help'. - │ - │ iris compile - │ ╰─────────── - • diff --git a/tests-e2e/tests/snapshots/cli__config_blank_source_program_file.snap b/tests-e2e/tests/snapshots/cli__config_blank_source_program_file.snap deleted file mode 100644 index aeed5edcd..000000000 --- a/tests-e2e/tests/snapshots/cli__config_blank_source_program_file.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [InvalidConfiguration] · config/settings.json:1:40 - • - │ - • source command program must not be empty or whitespace-only - │ - │ {"sources":{"kind":"command","program":" \t\n\u3000"}} - │ ╰──────────── - • diff --git a/tests-e2e/tests/snapshots/cli__config_blank_source_program_inline.snap b/tests-e2e/tests/snapshots/cli__config_blank_source_program_inline.snap deleted file mode 100644 index 0739092c0..000000000 --- a/tests-e2e/tests/snapshots/cli__config_blank_source_program_inline.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [InvalidConfiguration] · --config:1:40 - • - │ - • source command program must not be empty or whitespace-only - │ - │ {"sources":{"kind":"command","program":" \t\n\u3000"}} - │ ╰──────────── - • diff --git a/tests-e2e/tests/snapshots/cli__config_conflict.snap b/tests-e2e/tests/snapshots/cli__config_conflict.snap new file mode 100644 index 000000000..8d9298046 --- /dev/null +++ b/tests-e2e/tests/snapshots/cli__config_conflict.snap @@ -0,0 +1,11 @@ +--- +source: tests-e2e/tests/cli.rs +--- +status: 2 +--- stdout +--- stderr +error: the argument '--config' cannot be used with '--config-file' + +Usage: iris lsp [FLAGS] + +For more information, try '--help'. diff --git a/tests-e2e/tests/snapshots/cli__config_conflicts_with_file.snap b/tests-e2e/tests/snapshots/cli__config_conflicts_with_file.snap deleted file mode 100644 index 3297046ce..000000000 --- a/tests-e2e/tests/snapshots/cli__config_conflicts_with_file.snap +++ /dev/null @@ -1,18 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [conflicting_flags] · command line:1:1 - • - │ - • the argument '--config' cannot be used with '--config-file' - │ - │ Usage: iris lsp [FLAGS] - │ - │ For more information, try '--help'. - │ - │ iris lsp --config {} --config-file missing.json - │ ╰────────────────────────────────────────────── - • diff --git a/tests-e2e/tests/snapshots/cli__config_duplicate_diagnostic_file.snap b/tests-e2e/tests/snapshots/cli__config_duplicate_diagnostic_file.snap deleted file mode 100644 index 43393880e..000000000 --- a/tests-e2e/tests/snapshots/cli__config_duplicate_diagnostic_file.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [InvalidConfiguration] · config/settings.json:1:31 - • - │ - • duplicate field `onSave` - │ - │ {"diagnostics":{"onSave":null,"onSave":true}} - │ ╰─────── - • diff --git a/tests-e2e/tests/snapshots/cli__config_duplicate_diagnostic_inline.snap b/tests-e2e/tests/snapshots/cli__config_duplicate_diagnostic_inline.snap deleted file mode 100644 index a01731817..000000000 --- a/tests-e2e/tests/snapshots/cli__config_duplicate_diagnostic_inline.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [InvalidConfiguration] · --config:1:31 - • - │ - • duplicate field `onSave` - │ - │ {"diagnostics":{"onSave":null,"onSave":true}} - │ ╰─────── - • diff --git a/tests-e2e/tests/snapshots/cli__config_duplicate_program_file.snap b/tests-e2e/tests/snapshots/cli__config_duplicate_program_file.snap deleted file mode 100644 index 7fc6e2242..000000000 --- a/tests-e2e/tests/snapshots/cli__config_duplicate_program_file.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [InvalidConfiguration] · config/settings.json:1:48 - • - │ - • duplicate field `program` - │ - │ {"sources":{"kind":"command","program":"first","program":"second"}} - │ ╰──────── - • diff --git a/tests-e2e/tests/snapshots/cli__config_duplicate_program_inline.snap b/tests-e2e/tests/snapshots/cli__config_duplicate_program_inline.snap deleted file mode 100644 index b51452855..000000000 --- a/tests-e2e/tests/snapshots/cli__config_duplicate_program_inline.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [InvalidConfiguration] · --config:1:48 - • - │ - • duplicate field `program` - │ - │ {"sources":{"kind":"command","program":"first","program":"second"}} - │ ╰──────── - • diff --git a/tests-e2e/tests/snapshots/cli__config_empty_json_file.snap b/tests-e2e/tests/snapshots/cli__config_empty_json_file.snap deleted file mode 100644 index 79aaba2d9..000000000 --- a/tests-e2e/tests/snapshots/cli__config_empty_json_file.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [InvalidConfiguration] · config/settings.json:1:1 - • - │ - • EOF while parsing a value - │ - │ - │ ╰ - • diff --git a/tests-e2e/tests/snapshots/cli__config_empty_json_inline.snap b/tests-e2e/tests/snapshots/cli__config_empty_json_inline.snap deleted file mode 100644 index 1f494a963..000000000 --- a/tests-e2e/tests/snapshots/cli__config_empty_json_inline.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [InvalidConfiguration] · --config:1:1 - • - │ - • EOF while parsing a value - │ - │ - │ ╰ - • diff --git a/tests-e2e/tests/snapshots/cli__config_empty_source_program_file.snap b/tests-e2e/tests/snapshots/cli__config_empty_source_program_file.snap deleted file mode 100644 index d359a522a..000000000 --- a/tests-e2e/tests/snapshots/cli__config_empty_source_program_file.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [InvalidConfiguration] · config/settings.json:1:40 - • - │ - • source command program must not be empty or whitespace-only - │ - │ {"sources":{"kind":"command","program":""}} - │ ╰─ - • diff --git a/tests-e2e/tests/snapshots/cli__config_empty_source_program_inline.snap b/tests-e2e/tests/snapshots/cli__config_empty_source_program_inline.snap deleted file mode 100644 index 1190c1c6d..000000000 --- a/tests-e2e/tests/snapshots/cli__config_empty_source_program_inline.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [InvalidConfiguration] · --config:1:40 - • - │ - • source command program must not be empty or whitespace-only - │ - │ {"sources":{"kind":"command","program":""}} - │ ╰─ - • diff --git a/tests-e2e/tests/snapshots/cli__config_escaped_diagnostic_key_file.snap b/tests-e2e/tests/snapshots/cli__config_escaped_diagnostic_key_file.snap deleted file mode 100644 index fae7f5beb..000000000 --- a/tests-e2e/tests/snapshots/cli__config_escaped_diagnostic_key_file.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [InvalidConfiguration] · config/settings.json:1:17 - • - │ - • unknown field `onOpened`, expected one of `onOpen`, `onSave`, `onChange` - │ - │ {"diagnostics":{"on\u004fpened":true}} - │ ╰────────────── - • diff --git a/tests-e2e/tests/snapshots/cli__config_escaped_diagnostic_key_inline.snap b/tests-e2e/tests/snapshots/cli__config_escaped_diagnostic_key_inline.snap deleted file mode 100644 index bbd993760..000000000 --- a/tests-e2e/tests/snapshots/cli__config_escaped_diagnostic_key_inline.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [InvalidConfiguration] · --config:1:17 - • - │ - • unknown field `onOpened`, expected one of `onOpen`, `onSave`, `onChange` - │ - │ {"diagnostics":{"on\u004fpened":true}} - │ ╰────────────── - • diff --git a/tests-e2e/tests/snapshots/cli__config_file_conflicts_with_literal.snap b/tests-e2e/tests/snapshots/cli__config_file_conflicts_with_literal.snap deleted file mode 100644 index b6cd32275..000000000 --- a/tests-e2e/tests/snapshots/cli__config_file_conflicts_with_literal.snap +++ /dev/null @@ -1,18 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [conflicting_flags] · command line:1:1 - • - │ - • the argument '--config' cannot be used with '--config-file' - │ - │ Usage: iris lsp [FLAGS] - │ - │ For more information, try '--help'. - │ - │ iris lsp --config-file missing.json --config {} - │ ╰────────────────────────────────────────────── - • diff --git a/tests-e2e/tests/snapshots/cli__config_file_invalid_utf8.snap b/tests-e2e/tests/snapshots/cli__config_file_invalid_utf8.snap deleted file mode 100644 index 72a9475cf..000000000 --- a/tests-e2e/tests/snapshots/cli__config_file_invalid_utf8.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [ConfigurationFileRead] · --config-file:1:1 - • - │ - • Cannot read the configuration file: stream did not contain valid UTF-8 - │ - │ invalid-utf8.json - │ ╰──────────────── - • diff --git a/tests-e2e/tests/snapshots/cli__config_file_missing.snap b/tests-e2e/tests/snapshots/cli__config_file_missing.snap index a03749fc9..7f8d7770e 100644 --- a/tests-e2e/tests/snapshots/cli__config_file_missing.snap +++ b/tests-e2e/tests/snapshots/cli__config_file_missing.snap @@ -4,11 +4,4 @@ source: tests-e2e/tests/cli.rs status: 2 --- stdout --- stderr -Error! · [ConfigurationFileRead] · --config-file:1:1 - • - │ - • Cannot read the configuration file: [FILE NOT FOUND] - │ - │ missing.json - │ ╰─────────── - • +error: failed to read configuration file missing.json: [FILE NOT FOUND] diff --git a/tests-e2e/tests/snapshots/cli__config_file_requires_value.snap b/tests-e2e/tests/snapshots/cli__config_file_requires_value.snap deleted file mode 100644 index 8ea044c16..000000000 --- a/tests-e2e/tests/snapshots/cli__config_file_requires_value.snap +++ /dev/null @@ -1,16 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [missing_flag_value] · command line:1:10 - • - │ - • a value is required for '--config-file ' but none was supplied - │ - │ For more information, try '--help'. - │ - │ iris lsp --config-file - │ ╰──────────── - • diff --git a/tests-e2e/tests/snapshots/cli__config_invalid.snap b/tests-e2e/tests/snapshots/cli__config_invalid.snap new file mode 100644 index 000000000..56e35b9f4 --- /dev/null +++ b/tests-e2e/tests/snapshots/cli__config_invalid.snap @@ -0,0 +1,7 @@ +--- +source: tests-e2e/tests/cli.rs +--- +status: 2 +--- stdout +--- stderr +error: invalid configuration in --config: EOF while parsing an object at line 1 column 1 diff --git a/tests-e2e/tests/snapshots/cli__config_invalid_source_arguments_file.snap b/tests-e2e/tests/snapshots/cli__config_invalid_source_arguments_file.snap deleted file mode 100644 index f705cc082..000000000 --- a/tests-e2e/tests/snapshots/cli__config_invalid_source_arguments_file.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [InvalidConfiguration] · config/settings.json:1:62 - • - │ - • invalid type: integer `1`, expected a string - │ - │ {"sources":{"kind":"command","program":"custom","arguments":[1]}} - │ ╰ - • diff --git a/tests-e2e/tests/snapshots/cli__config_invalid_source_arguments_inline.snap b/tests-e2e/tests/snapshots/cli__config_invalid_source_arguments_inline.snap deleted file mode 100644 index 3255288af..000000000 --- a/tests-e2e/tests/snapshots/cli__config_invalid_source_arguments_inline.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [InvalidConfiguration] · --config:1:62 - • - │ - • invalid type: integer `1`, expected a string - │ - │ {"sources":{"kind":"command","program":"custom","arguments":[1]}} - │ ╰ - • diff --git a/tests-e2e/tests/snapshots/cli__config_malformed_json_file.snap b/tests-e2e/tests/snapshots/cli__config_malformed_json_file.snap deleted file mode 100644 index 0e47bb934..000000000 --- a/tests-e2e/tests/snapshots/cli__config_malformed_json_file.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [InvalidConfiguration] · config/settings.json:1:2 - • - │ - • EOF while parsing an object - │ - │ { - │ ╰ - • diff --git a/tests-e2e/tests/snapshots/cli__config_malformed_json_inline.snap b/tests-e2e/tests/snapshots/cli__config_malformed_json_inline.snap deleted file mode 100644 index 011cd7279..000000000 --- a/tests-e2e/tests/snapshots/cli__config_malformed_json_inline.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [InvalidConfiguration] · --config:1:2 - • - │ - • EOF while parsing an object - │ - │ { - │ ╰ - • diff --git a/tests-e2e/tests/snapshots/cli__config_missing_source_program_file.snap b/tests-e2e/tests/snapshots/cli__config_missing_source_program_file.snap deleted file mode 100644 index 37184b0ac..000000000 --- a/tests-e2e/tests/snapshots/cli__config_missing_source_program_file.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [InvalidConfiguration] · config/settings.json:1:30 - • - │ - • missing field `program` - │ - │ {"sources":{"kind":"command"}} - │ ╰ - • diff --git a/tests-e2e/tests/snapshots/cli__config_missing_source_program_inline.snap b/tests-e2e/tests/snapshots/cli__config_missing_source_program_inline.snap deleted file mode 100644 index 78a8aba75..000000000 --- a/tests-e2e/tests/snapshots/cli__config_missing_source_program_inline.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [InvalidConfiguration] · --config:1:30 - • - │ - • missing field `program` - │ - │ {"sources":{"kind":"command"}} - │ ╰ - • diff --git a/tests-e2e/tests/snapshots/cli__config_multiline_eof_file.snap b/tests-e2e/tests/snapshots/cli__config_multiline_eof_file.snap deleted file mode 100644 index 34944613a..000000000 --- a/tests-e2e/tests/snapshots/cli__config_multiline_eof_file.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [InvalidConfiguration] · config/settings.json:3:14 - • - │ - • EOF while parsing a value - │ - │ "onSave": - │ ╰ - • diff --git a/tests-e2e/tests/snapshots/cli__config_multiline_eof_inline.snap b/tests-e2e/tests/snapshots/cli__config_multiline_eof_inline.snap deleted file mode 100644 index e666441e2..000000000 --- a/tests-e2e/tests/snapshots/cli__config_multiline_eof_inline.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [InvalidConfiguration] · --config:3:14 - • - │ - • EOF while parsing a value - │ - │ "onSave": - │ ╰ - • diff --git a/tests-e2e/tests/snapshots/cli__config_multiline_invalid_arguments_file.snap b/tests-e2e/tests/snapshots/cli__config_multiline_invalid_arguments_file.snap deleted file mode 100644 index 87805ceea..000000000 --- a/tests-e2e/tests/snapshots/cli__config_multiline_invalid_arguments_file.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [InvalidConfiguration] · config/settings.json:3:24 - • - │ - • invalid type: integer `12`, expected a string - │ - │ "arguments": ["λ", 12, "sources"], - │ ╰─ - • diff --git a/tests-e2e/tests/snapshots/cli__config_multiline_invalid_arguments_inline.snap b/tests-e2e/tests/snapshots/cli__config_multiline_invalid_arguments_inline.snap deleted file mode 100644 index 261fe1ac8..000000000 --- a/tests-e2e/tests/snapshots/cli__config_multiline_invalid_arguments_inline.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [InvalidConfiguration] · --config:3:24 - • - │ - • invalid type: integer `12`, expected a string - │ - │ "arguments": ["λ", 12, "sources"], - │ ╰─ - • diff --git a/tests-e2e/tests/snapshots/cli__config_multiline_program_before_arguments_file.snap b/tests-e2e/tests/snapshots/cli__config_multiline_program_before_arguments_file.snap deleted file mode 100644 index f32e350ea..000000000 --- a/tests-e2e/tests/snapshots/cli__config_multiline_program_before_arguments_file.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [InvalidConfiguration] · config/settings.json:4:16 - • - │ - • source command program must not be empty or whitespace-only - │ - │ "program": " ", - │ ╰── - • diff --git a/tests-e2e/tests/snapshots/cli__config_multiline_program_before_arguments_inline.snap b/tests-e2e/tests/snapshots/cli__config_multiline_program_before_arguments_inline.snap deleted file mode 100644 index b006dc4ce..000000000 --- a/tests-e2e/tests/snapshots/cli__config_multiline_program_before_arguments_inline.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [InvalidConfiguration] · --config:4:16 - • - │ - • source command program must not be empty or whitespace-only - │ - │ "program": " ", - │ ╰── - • diff --git a/tests-e2e/tests/snapshots/cli__config_multiline_program_before_kind_file.snap b/tests-e2e/tests/snapshots/cli__config_multiline_program_before_kind_file.snap deleted file mode 100644 index ebc37465c..000000000 --- a/tests-e2e/tests/snapshots/cli__config_multiline_program_before_kind_file.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [InvalidConfiguration] · config/settings.json:3:16 - • - │ - • source command program must not be empty or whitespace-only - │ - │ "program": " ", - │ ╰── - • diff --git a/tests-e2e/tests/snapshots/cli__config_multiline_program_before_kind_inline.snap b/tests-e2e/tests/snapshots/cli__config_multiline_program_before_kind_inline.snap deleted file mode 100644 index bd16ec109..000000000 --- a/tests-e2e/tests/snapshots/cli__config_multiline_program_before_kind_inline.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [InvalidConfiguration] · --config:3:16 - • - │ - • source command program must not be empty or whitespace-only - │ - │ "program": " ", - │ ╰── - • diff --git a/tests-e2e/tests/snapshots/cli__config_multiline_unicode_unknown_key_file.snap b/tests-e2e/tests/snapshots/cli__config_multiline_unicode_unknown_key_file.snap deleted file mode 100644 index e0c75018e..000000000 --- a/tests-e2e/tests/snapshots/cli__config_multiline_unicode_unknown_key_file.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [InvalidConfiguration] · config/settings.json:3:18 - • - │ - • unknown field `λ`, expected one of `onOpen`, `onSave`, `onChange` - │ - │ "diagnostics": {"λ": true} - │ ╰── - • diff --git a/tests-e2e/tests/snapshots/cli__config_multiline_unicode_unknown_key_inline.snap b/tests-e2e/tests/snapshots/cli__config_multiline_unicode_unknown_key_inline.snap deleted file mode 100644 index 236738e8c..000000000 --- a/tests-e2e/tests/snapshots/cli__config_multiline_unicode_unknown_key_inline.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [InvalidConfiguration] · --config:3:18 - • - │ - • unknown field `λ`, expected one of `onOpen`, `onSave`, `onChange` - │ - │ "diagnostics": {"λ": true} - │ ╰── - • diff --git a/tests-e2e/tests/snapshots/cli__config_null_source_arguments_file.snap b/tests-e2e/tests/snapshots/cli__config_null_source_arguments_file.snap deleted file mode 100644 index e080a5232..000000000 --- a/tests-e2e/tests/snapshots/cli__config_null_source_arguments_file.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [InvalidConfiguration] · config/settings.json:1:61 - • - │ - • invalid type: null, expected a sequence - │ - │ {"sources":{"kind":"command","program":"custom","arguments":null}} - │ ╰─── - • diff --git a/tests-e2e/tests/snapshots/cli__config_null_source_arguments_inline.snap b/tests-e2e/tests/snapshots/cli__config_null_source_arguments_inline.snap deleted file mode 100644 index ca26a630e..000000000 --- a/tests-e2e/tests/snapshots/cli__config_null_source_arguments_inline.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [InvalidConfiguration] · --config:1:61 - • - │ - • invalid type: null, expected a sequence - │ - │ {"sources":{"kind":"command","program":"custom","arguments":null}} - │ ╰─── - • diff --git a/tests-e2e/tests/snapshots/cli__config_requires_value.snap b/tests-e2e/tests/snapshots/cli__config_requires_value.snap deleted file mode 100644 index 19ff0c746..000000000 --- a/tests-e2e/tests/snapshots/cli__config_requires_value.snap +++ /dev/null @@ -1,16 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [missing_flag_value] · command line:1:10 - • - │ - • a value is required for '--config ' but none was supplied - │ - │ For more information, try '--help'. - │ - │ iris lsp --config - │ ╰─────── - • diff --git a/tests-e2e/tests/snapshots/cli__config_trailing_json_file.snap b/tests-e2e/tests/snapshots/cli__config_trailing_json_file.snap deleted file mode 100644 index 563c30f42..000000000 --- a/tests-e2e/tests/snapshots/cli__config_trailing_json_file.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [InvalidConfiguration] · config/settings.json:1:4 - • - │ - • trailing characters - │ - │ {} false - │ ╰ - • diff --git a/tests-e2e/tests/snapshots/cli__config_trailing_json_inline.snap b/tests-e2e/tests/snapshots/cli__config_trailing_json_inline.snap deleted file mode 100644 index 025adf96a..000000000 --- a/tests-e2e/tests/snapshots/cli__config_trailing_json_inline.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [InvalidConfiguration] · --config:1:4 - • - │ - • trailing characters - │ - │ {} false - │ ╰ - • diff --git a/tests-e2e/tests/snapshots/cli__config_unicode_syntax_error_file.snap b/tests-e2e/tests/snapshots/cli__config_unicode_syntax_error_file.snap deleted file mode 100644 index 0b56bc4b3..000000000 --- a/tests-e2e/tests/snapshots/cli__config_unicode_syntax_error_file.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [InvalidConfiguration] · config/settings.json:2:28 - • - │ - • expected value - │ - │ "diagnostics": {"onSave": λ} - │ ╰ - • diff --git a/tests-e2e/tests/snapshots/cli__config_unicode_syntax_error_inline.snap b/tests-e2e/tests/snapshots/cli__config_unicode_syntax_error_inline.snap deleted file mode 100644 index 7ec77dd24..000000000 --- a/tests-e2e/tests/snapshots/cli__config_unicode_syntax_error_inline.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [InvalidConfiguration] · --config:2:28 - • - │ - • expected value - │ - │ "diagnostics": {"onSave": λ} - │ ╰ - • diff --git a/tests-e2e/tests/snapshots/cli__config_unknown_diagnostic_file.snap b/tests-e2e/tests/snapshots/cli__config_unknown_diagnostic_file.snap deleted file mode 100644 index a630b2c8b..000000000 --- a/tests-e2e/tests/snapshots/cli__config_unknown_diagnostic_file.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [InvalidConfiguration] · config/settings.json:1:17 - • - │ - • unknown field `onOpened`, expected one of `onOpen`, `onSave`, `onChange` - │ - │ {"diagnostics":{"onOpened":false}} - │ ╰───────── - • diff --git a/tests-e2e/tests/snapshots/cli__config_unknown_diagnostic_inline.snap b/tests-e2e/tests/snapshots/cli__config_unknown_diagnostic_inline.snap deleted file mode 100644 index 60da50cc5..000000000 --- a/tests-e2e/tests/snapshots/cli__config_unknown_diagnostic_inline.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [InvalidConfiguration] · --config:1:17 - • - │ - • unknown field `onOpened`, expected one of `onOpen`, `onSave`, `onChange` - │ - │ {"diagnostics":{"onOpened":false}} - │ ╰───────── - • diff --git a/tests-e2e/tests/snapshots/cli__config_unknown_setting_file.snap b/tests-e2e/tests/snapshots/cli__config_unknown_setting_file.snap deleted file mode 100644 index 30d40e63d..000000000 --- a/tests-e2e/tests/snapshots/cli__config_unknown_setting_file.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [InvalidConfiguration] · config/settings.json:1:2 - • - │ - • unknown field `unknown`, expected `sources` or `diagnostics` - │ - │ {"unknown":true} - │ ╰──────── - • diff --git a/tests-e2e/tests/snapshots/cli__config_unknown_setting_inline.snap b/tests-e2e/tests/snapshots/cli__config_unknown_setting_inline.snap deleted file mode 100644 index 60c0bbd2b..000000000 --- a/tests-e2e/tests/snapshots/cli__config_unknown_setting_inline.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [InvalidConfiguration] · --config:1:2 - • - │ - • unknown field `unknown`, expected `sources` or `diagnostics` - │ - │ {"unknown":true} - │ ╰──────── - • diff --git a/tests-e2e/tests/snapshots/cli__config_unknown_source_kind_file.snap b/tests-e2e/tests/snapshots/cli__config_unknown_source_kind_file.snap deleted file mode 100644 index 749389713..000000000 --- a/tests-e2e/tests/snapshots/cli__config_unknown_source_kind_file.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [InvalidConfiguration] · config/settings.json:1:20 - • - │ - • unknown variant `unknown`, expected `spago` or `command` - │ - │ {"sources":{"kind":"unknown"}} - │ ╰──────── - • diff --git a/tests-e2e/tests/snapshots/cli__config_unknown_source_kind_inline.snap b/tests-e2e/tests/snapshots/cli__config_unknown_source_kind_inline.snap deleted file mode 100644 index 0e62f090d..000000000 --- a/tests-e2e/tests/snapshots/cli__config_unknown_source_kind_inline.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [InvalidConfiguration] · --config:1:20 - • - │ - • unknown variant `unknown`, expected `spago` or `command` - │ - │ {"sources":{"kind":"unknown"}} - │ ╰──────── - • diff --git a/tests-e2e/tests/snapshots/cli__config_wrong_diagnostic_object_file.snap b/tests-e2e/tests/snapshots/cli__config_wrong_diagnostic_object_file.snap deleted file mode 100644 index c426b2656..000000000 --- a/tests-e2e/tests/snapshots/cli__config_wrong_diagnostic_object_file.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [InvalidConfiguration] · config/settings.json:1:25 - • - │ - • invalid type: map, expected a boolean - │ - │ {"diagnostics":{"onSave":{}}} - │ ╰ - • diff --git a/tests-e2e/tests/snapshots/cli__config_wrong_diagnostic_object_inline.snap b/tests-e2e/tests/snapshots/cli__config_wrong_diagnostic_object_inline.snap deleted file mode 100644 index 218809d4d..000000000 --- a/tests-e2e/tests/snapshots/cli__config_wrong_diagnostic_object_inline.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [InvalidConfiguration] · --config:1:25 - • - │ - • invalid type: map, expected a boolean - │ - │ {"diagnostics":{"onSave":{}}} - │ ╰ - • diff --git a/tests-e2e/tests/snapshots/cli__config_wrong_diagnostic_type_file.snap b/tests-e2e/tests/snapshots/cli__config_wrong_diagnostic_type_file.snap deleted file mode 100644 index 3bd61da1b..000000000 --- a/tests-e2e/tests/snapshots/cli__config_wrong_diagnostic_type_file.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [InvalidConfiguration] · config/settings.json:1:26 - • - │ - • invalid type: string "false", expected a boolean - │ - │ {"diagnostics":{"onSave":"false"}} - │ ╰────── - • diff --git a/tests-e2e/tests/snapshots/cli__config_wrong_diagnostic_type_inline.snap b/tests-e2e/tests/snapshots/cli__config_wrong_diagnostic_type_inline.snap deleted file mode 100644 index e390499c6..000000000 --- a/tests-e2e/tests/snapshots/cli__config_wrong_diagnostic_type_inline.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [InvalidConfiguration] · --config:1:26 - • - │ - • invalid type: string "false", expected a boolean - │ - │ {"diagnostics":{"onSave":"false"}} - │ ╰────── - • diff --git a/tests-e2e/tests/snapshots/cli__config_wrong_top_level_file.snap b/tests-e2e/tests/snapshots/cli__config_wrong_top_level_file.snap deleted file mode 100644 index 8ac275cee..000000000 --- a/tests-e2e/tests/snapshots/cli__config_wrong_top_level_file.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [InvalidConfiguration] · config/settings.json:1:1 - • - │ - • invalid type: boolean `false`, expected struct ConfigurationSettings - │ - │ false - │ ╰──── - • diff --git a/tests-e2e/tests/snapshots/cli__config_wrong_top_level_inline.snap b/tests-e2e/tests/snapshots/cli__config_wrong_top_level_inline.snap deleted file mode 100644 index 8c3e0d433..000000000 --- a/tests-e2e/tests/snapshots/cli__config_wrong_top_level_inline.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [InvalidConfiguration] · --config:1:1 - • - │ - • invalid type: boolean `false`, expected struct ConfigurationSettings - │ - │ false - │ ╰──── - • diff --git a/tests-e2e/tests/snapshots/cli__docs_package_requires_value.snap b/tests-e2e/tests/snapshots/cli__docs_package_requires_value.snap deleted file mode 100644 index c6ede9927..000000000 --- a/tests-e2e/tests/snapshots/cli__docs_package_requires_value.snap +++ /dev/null @@ -1,16 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [missing_flag_value] · command line:1:11 - • - │ - • a value is required for '--package ' but none was supplied - │ - │ For more information, try '--help'. - │ - │ iris docs --package - │ ╰──────── - • diff --git a/tests-e2e/tests/snapshots/cli__docs_project_requires_value.snap b/tests-e2e/tests/snapshots/cli__docs_project_requires_value.snap deleted file mode 100644 index c9a44983e..000000000 --- a/tests-e2e/tests/snapshots/cli__docs_project_requires_value.snap +++ /dev/null @@ -1,16 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [missing_flag_value] · command line:1:11 - • - │ - • a value is required for '--spago-project ' but none was supplied - │ - │ For more information, try '--help'. - │ - │ iris docs --spago-project - │ ╰────────────── - • diff --git a/tests-e2e/tests/snapshots/cli__docs_requires_package_or_project.snap b/tests-e2e/tests/snapshots/cli__docs_requires_package_or_project.snap deleted file mode 100644 index bd6a1957c..000000000 --- a/tests-e2e/tests/snapshots/cli__docs_requires_package_or_project.snap +++ /dev/null @@ -1,19 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [missing_required] · command line:1:1 - • - │ - • the following required arguments were not provided: - │ --package - │ - │ Usage: iris docs [FLAGS] [SUBCOMMAND] - │ - │ For more information, try '--help'. - │ - │ iris docs - │ ╰──────── - • diff --git a/tests-e2e/tests/snapshots/cli__duplicate_build_scalar.snap b/tests-e2e/tests/snapshots/cli__duplicate_build_scalar.snap deleted file mode 100644 index 3fd5c12a8..000000000 --- a/tests-e2e/tests/snapshots/cli__duplicate_build_scalar.snap +++ /dev/null @@ -1,18 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [duplicate_flag] · command line:1:1 - • - │ - • the argument '--package' cannot be used multiple times - │ - │ Usage: iris build [FLAGS] - │ - │ For more information, try '--help'. - │ - │ iris build --package one --package two - │ ╰───────────────────────────────────── - • diff --git a/tests-e2e/tests/snapshots/cli__duplicate_compile_scalar.snap b/tests-e2e/tests/snapshots/cli__duplicate_compile_scalar.snap deleted file mode 100644 index 05ebda624..000000000 --- a/tests-e2e/tests/snapshots/cli__duplicate_compile_scalar.snap +++ /dev/null @@ -1,18 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [duplicate_flag] · command line:1:1 - • - │ - • the argument '--output' cannot be used multiple times - │ - │ Usage: iris compile [FLAGS] [INPUT]… - │ - │ For more information, try '--help'. - │ - │ iris compile --output one --output two Main.purs - │ ╰─────────────────────────────────────────────── - • diff --git a/tests-e2e/tests/snapshots/cli__duplicate_config_file.snap b/tests-e2e/tests/snapshots/cli__duplicate_config_file.snap deleted file mode 100644 index 899652f9d..000000000 --- a/tests-e2e/tests/snapshots/cli__duplicate_config_file.snap +++ /dev/null @@ -1,18 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [duplicate_flag] · command line:1:1 - • - │ - • the argument '--config-file' cannot be used multiple times - │ - │ Usage: iris lsp [FLAGS] - │ - │ For more information, try '--help'. - │ - │ iris lsp --config-file one --config-file two - │ ╰─────────────────────────────────────────── - • diff --git a/tests-e2e/tests/snapshots/cli__duplicate_docs_scalar.snap b/tests-e2e/tests/snapshots/cli__duplicate_docs_scalar.snap deleted file mode 100644 index 5b37f3c77..000000000 --- a/tests-e2e/tests/snapshots/cli__duplicate_docs_scalar.snap +++ /dev/null @@ -1,18 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [duplicate_flag] · command line:1:1 - • - │ - • the argument '--output' cannot be used multiple times - │ - │ Usage: iris docs [FLAGS] [SUBCOMMAND] - │ - │ For more information, try '--help'. - │ - │ iris docs --output one --output two --package . - │ ╰────────────────────────────────────────────── - • diff --git a/tests-e2e/tests/snapshots/cli__duplicate_lsp_scalar.snap b/tests-e2e/tests/snapshots/cli__duplicate_lsp_scalar.snap deleted file mode 100644 index 66b452993..000000000 --- a/tests-e2e/tests/snapshots/cli__duplicate_lsp_scalar.snap +++ /dev/null @@ -1,18 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [duplicate_flag] · command line:1:1 - • - │ - • the argument '--config' cannot be used multiple times - │ - │ Usage: iris lsp [FLAGS] - │ - │ For more information, try '--help'. - │ - │ iris lsp --config {} --config null - │ ╰───────────────────────────────── - • diff --git a/tests-e2e/tests/snapshots/cli__duplicate_typescript_scalar.snap b/tests-e2e/tests/snapshots/cli__duplicate_typescript_scalar.snap deleted file mode 100644 index e6424b555..000000000 --- a/tests-e2e/tests/snapshots/cli__duplicate_typescript_scalar.snap +++ /dev/null @@ -1,18 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [duplicate_flag] · command line:1:1 - • - │ - • the argument '--output' cannot be used multiple times - │ - │ Usage: iris docs typescript [--output ] - │ - │ For more information, try '--help'. - │ - │ iris docs typescript --output one --output two - │ ╰───────────────────────────────────────────── - • diff --git a/tests-e2e/tests/snapshots/cli__help_build.snap b/tests-e2e/tests/snapshots/cli__help_build.snap index d10b21274..f827b265d 100644 --- a/tests-e2e/tests/snapshots/cli__help_build.snap +++ b/tests-e2e/tests/snapshots/cli__help_build.snap @@ -8,18 +8,13 @@ Build a Spago workspace or package. Usage: iris build [FLAGS] Flags: - --query-log Log level for the query engine. - [possible values: off, error, warn, info, debug, trace] - (default: off) - --checking-log Log level for the type checker. - [possible values: off, error, warn, info, debug, trace] - (default: off) - -p, --package Workspace package to build. - -o, --output Output directory for compiled modules. Defaults to output in the workspace root. - -q, --quiet Suppress build progress output. - --color When to use colors in human-readable diagnostics. - [possible values: auto, always, never] - (default: auto) - --resilient Write JavaScript output even when compilation reports errors. - -h, --help Print help + -p, --package Workspace package to build. + -o, --output Output directory. Defaults to output in the workspace root. + -q, --quiet Suppress build progress and Spago output. + --color When to use colors in diagnostics and progress output. + [possible values: auto, always, never] + (default: auto) + --resilient Write JavaScript output even when compilation reports errors. + --no-diagnostics Suppress compiler warnings and errors without hiding build progress. + -h, --help Print help --- stderr diff --git a/tests-e2e/tests/snapshots/cli__help_compile.snap b/tests-e2e/tests/snapshots/cli__help_compile.snap deleted file mode 100644 index 9f20930ec..000000000 --- a/tests-e2e/tests/snapshots/cli__help_compile.snap +++ /dev/null @@ -1,32 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 0 ---- stdout -Compile PureScript modules to JavaScript (experimental). - -Usage: iris compile [FLAGS] [INPUT]… - -Arguments: - [INPUT]… PureScript source paths or glob patterns. - -Flags: - --query-log Log level for the query engine. - [possible values: off, error, warn, info, debug, trace] - (default: off) - --checking-log Log level for the type checker. - [possible values: off, error, warn, info, debug, trace] - (default: off) - -o, --output Output directory for compiled modules. - (default: output) - -q, --quiet Suppress build progress output. - --color When to use colors in human-readable diagnostics. - [possible values: auto, always, never] - (default: auto) - --package Package folder to compile. - --codegen Code generation targets requested by build tools. - --json-errors Emit a Spago-compatible JSON result. - - Full structured JSON diagnostics are not yet supported. - -h, --help Print help ---- stderr diff --git a/tests-e2e/tests/snapshots/cli__help_docs.snap b/tests-e2e/tests/snapshots/cli__help_docs.snap deleted file mode 100644 index d2dd9cccf..000000000 --- a/tests-e2e/tests/snapshots/cli__help_docs.snap +++ /dev/null @@ -1,30 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 0 ---- stdout -Documentation utilities. - -Usage: iris docs [FLAGS] [SUBCOMMAND] - -Commands: - typescript Generate TypeScript declarations for the documentation JSON schema. - help Print this message or the help of the given subcommand(s) - -Flags: - --query-log Log level for the query engine. - [possible values: off, error, warn, info, debug, trace] - (default: off) - --checking-log Log level for the type checker. - [possible values: off, error, warn, info, debug, trace] - (default: off) - --docs-log Log level for the documentation tool. - [possible values: off, error, warn, info, debug, trace] - (default: info) - --output Output directory for the generated documentation. - (default: docs) - -q, --quiet Suppress documentation progress output. - --spago-project Spago project directory containing spago.lock. - --package Package folder to document. - -h, --help Print help ---- stderr diff --git a/tests-e2e/tests/snapshots/cli__help_docs_typescript.snap b/tests-e2e/tests/snapshots/cli__help_docs_typescript.snap deleted file mode 100644 index 51e7b4bba..000000000 --- a/tests-e2e/tests/snapshots/cli__help_docs_typescript.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 0 ---- stdout -Generate TypeScript declarations for the documentation JSON schema. - -Usage: iris docs typescript [--output ] - -Flags: - --output Output directory for the generated TypeScript schema. - (default: src-generated) - -h, --help Print help ---- stderr diff --git a/tests-e2e/tests/snapshots/cli__help_lsp.snap b/tests-e2e/tests/snapshots/cli__help_lsp.snap index f2c419bae..c0c137951 100644 --- a/tests-e2e/tests/snapshots/cli__help_lsp.snap +++ b/tests-e2e/tests/snapshots/cli__help_lsp.snap @@ -3,22 +3,22 @@ source: tests-e2e/tests/cli.rs --- status: 0 --- stdout -Run the language server. +Run the language server over standard input and output. Usage: iris lsp [FLAGS] Flags: - --query-log Log level for the query engine. - [possible values: off, error, warn, info, debug, trace] - (default: off) - --checking-log Log level for the type checker. - [possible values: off, error, warn, info, debug, trace] - (default: off) - --stdio - --lsp-log Log level for the language server. - [possible values: off, error, warn, info, debug, trace] - (default: info) - --config Language server configuration as a JSON object, read once at startup. - --config-file Language server configuration file, relative to the working directory. - -h, --help Print help + --stdio Use standard input and output for LSP transport. + --query-log Log level for the query engine. + [possible values: off, error, warn, info, debug, trace] + (default: off) + --checking-log Log level for the type checker. + [possible values: off, error, warn, info, debug, trace] + (default: off) + --lsp-log Log level for the language server. + [possible values: off, error, warn, info, debug, trace] + (default: info) + --config Language server configuration as a JSON object, read once at startup. + --config-file Language server configuration file, relative to the working directory. + -h, --help Print help --- stderr diff --git a/tests-e2e/tests/snapshots/cli__help_lsp_short.snap b/tests-e2e/tests/snapshots/cli__help_lsp_short.snap deleted file mode 100644 index 2e6800445..000000000 --- a/tests-e2e/tests/snapshots/cli__help_lsp_short.snap +++ /dev/null @@ -1,21 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 0 ---- stdout -Run the language server. - -Usage: iris lsp [FLAGS] - -Flags: - --query-log Log level for the query engine. [off, error, warn, info, debug, trace] (default: - off) - --checking-log Log level for the type checker. [off, error, warn, info, debug, trace] (default: - off) - --stdio - --lsp-log Log level for the language server. [off, error, warn, info, debug, trace] (default: - info) - --config Language server configuration as a JSON object, read once at startup. - --config-file Language server configuration file, relative to the working directory. - -h, --help Print help ---- stderr diff --git a/tests-e2e/tests/snapshots/cli__help_root.snap b/tests-e2e/tests/snapshots/cli__help_root.snap index a2ab3cac3..64edda2d7 100644 --- a/tests-e2e/tests/snapshots/cli__help_root.snap +++ b/tests-e2e/tests/snapshots/cli__help_root.snap @@ -4,30 +4,21 @@ source: tests-e2e/tests/cli.rs status: 0 --- stdout iris 0.1.0 -Language implementation for the PureScript programming language. +PureScript compiler command-line interface. -Usage: iris [--log-file] +Usage: iris Commands: - help Print this message or the help of the given subcommand(s) - -Project commands: - add Add dependencies to a Spago package. - build Build a Spago workspace or package. - lsp Run the language server. - new Create a Spago project in the current directory. - run Build and run a Spago package with Node.js. - test Build and test one or more Spago packages with Node.js. - watch Build a Spago workspace or package and rebuild when inputs change. - -Legacy commands: - compile Compile PureScript modules to JavaScript (experimental). - -Documentation commands: - docs Documentation utilities. + add Add dependencies to a Spago package. + build Build a Spago workspace or package. + lsp Run the language server over standard input and output. + new Create a Spago project in the current directory. + run Build and run a Spago package with Node.js. + test Build and test one or more Spago packages with Node.js. + watch Build a Spago workspace or package and rebuild when inputs change. + help Print this message or the help of the given subcommand(s) Flags: - --log-file Print log path. - -h, --help Print help - -V, --version Print version + -h, --help Print help + -V, --version Print version --- stderr diff --git a/tests-e2e/tests/snapshots/cli__help_run.snap b/tests-e2e/tests/snapshots/cli__help_run.snap index 4cca69817..bd70b4a97 100644 --- a/tests-e2e/tests/snapshots/cli__help_run.snap +++ b/tests-e2e/tests/snapshots/cli__help_run.snap @@ -11,18 +11,12 @@ Arguments: [-- ARGUMENTS]… Arguments passed to the program. Flags: - --query-log Log level for the query engine. - [possible values: off, error, warn, info, debug, trace] - (default: off) - --checking-log Log level for the type checker. - [possible values: off, error, warn, info, debug, trace] - (default: off) - -p, --package Workspace package to build. - -o, --output Output directory for compiled modules. Defaults to output in the workspace root. - -q, --quiet Suppress build progress output. - --color When to use colors in human-readable diagnostics. - [possible values: auto, always, never] - (default: auto) - --main Module containing the program entry point. - -h, --help Print help + -p, --package Workspace package to build. + -o, --output Output directory. Defaults to output in the workspace root. + -q, --quiet Suppress build progress and Spago output. + --color When to use colors in diagnostics and progress output. + [possible values: auto, always, never] + (default: auto) + --main Module containing the program entry point. + -h, --help Print help --- stderr diff --git a/tests-e2e/tests/snapshots/cli__help_test.snap b/tests-e2e/tests/snapshots/cli__help_test.snap index a925ccbd6..b3d0bc0e5 100644 --- a/tests-e2e/tests/snapshots/cli__help_test.snap +++ b/tests-e2e/tests/snapshots/cli__help_test.snap @@ -11,18 +11,12 @@ Arguments: [-- ARGUMENTS]… Arguments passed to each test program. Flags: - --query-log Log level for the query engine. - [possible values: off, error, warn, info, debug, trace] - (default: off) - --checking-log Log level for the type checker. - [possible values: off, error, warn, info, debug, trace] - (default: off) - -p, --package Workspace package to build. - -o, --output Output directory for compiled modules. Defaults to output in the workspace root. - -q, --quiet Suppress build progress output. - --color When to use colors in human-readable diagnostics. - [possible values: auto, always, never] - (default: auto) - --main Module containing the test entry point. - -h, --help Print help + -p, --package Workspace package to build. + -o, --output Output directory. Defaults to output in the workspace root. + -q, --quiet Suppress build progress and Spago output. + --color When to use colors in diagnostics and progress output. + [possible values: auto, always, never] + (default: auto) + --main Module containing the test entry point. + -h, --help Print help --- stderr diff --git a/tests-e2e/tests/snapshots/cli__help_watch.snap b/tests-e2e/tests/snapshots/cli__help_watch.snap index ba25b8371..3e3815e62 100644 --- a/tests-e2e/tests/snapshots/cli__help_watch.snap +++ b/tests-e2e/tests/snapshots/cli__help_watch.snap @@ -8,17 +8,12 @@ Build a Spago workspace or package and rebuild when inputs change. Usage: iris watch [FLAGS] Flags: - --query-log Log level for the query engine. - [possible values: off, error, warn, info, debug, trace] - (default: off) - --checking-log Log level for the type checker. - [possible values: off, error, warn, info, debug, trace] - (default: off) - -p, --package Workspace package to build. - -o, --output Output directory for compiled modules. Defaults to output in the workspace root. - -q, --quiet Suppress build progress output. - --color When to use colors in human-readable diagnostics. - [possible values: auto, always, never] - (default: auto) - -h, --help Print help + -p, --package Workspace package to build. + -o, --output Output directory. Defaults to output in the workspace root. + -q, --quiet Suppress watch summaries and Spago output. + --color When to use colors in diagnostics and watch output. + [possible values: auto, always, never] + (default: auto) + --no-diagnostics Suppress compiler warnings and errors without hiding watch summaries. + -h, --help Print help --- stderr diff --git a/tests-e2e/tests/snapshots/cli__invalid_choice.snap b/tests-e2e/tests/snapshots/cli__invalid_choice.snap deleted file mode 100644 index 3b9b14c98..000000000 --- a/tests-e2e/tests/snapshots/cli__invalid_choice.snap +++ /dev/null @@ -1,19 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [invalid_choice] · command line:1:20 - • - │ - • invalid value 'alway' for '--color' - │ [possible values: auto, always, never] - │ - │ tip: a similar value exists: 'always' - │ - │ For more information, try '--help'. - │ - │ iris build --color alway - │ ╰──── - • diff --git a/tests-e2e/tests/snapshots/cli__invalid_choice_equals.snap b/tests-e2e/tests/snapshots/cli__invalid_choice_equals.snap deleted file mode 100644 index 77894ea33..000000000 --- a/tests-e2e/tests/snapshots/cli__invalid_choice_equals.snap +++ /dev/null @@ -1,17 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [invalid_choice] · command line:1:20 - • - │ - • invalid value 'verböse' for '--lsp-log' - │ [possible values: off, error, warn, info, debug, trace] - │ - │ For more information, try '--help'. - │ - │ iris lsp --lsp-log=verböse - │ ╰────── - • diff --git a/tests-e2e/tests/snapshots/cli__invalid_choice_escaped.snap b/tests-e2e/tests/snapshots/cli__invalid_choice_escaped.snap deleted file mode 100644 index 00cf9eea4..000000000 --- a/tests-e2e/tests/snapshots/cli__invalid_choice_escaped.snap +++ /dev/null @@ -1,20 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [invalid_choice] · command line:1:21 - • - │ - • invalid value 'warn - │ "λ"' for '--lsp-log' - │ [possible values: off, error, warn, info, debug, trace] - │ - │ tip: a similar value exists: 'warn' - │ - │ For more information, try '--help'. - │ - │ iris lsp --lsp-log "warn\n\"λ\"" - │ ╰────────── - • diff --git a/tests-e2e/tests/snapshots/cli__log_file_requires_subcommand.snap b/tests-e2e/tests/snapshots/cli__log_file_requires_subcommand.snap deleted file mode 100644 index 402bc1c56..000000000 --- a/tests-e2e/tests/snapshots/cli__log_file_requires_subcommand.snap +++ /dev/null @@ -1,40 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [missing_subcommand] · command line:1:1 - • - │ - • iris 0.1.0 - │ Language implementation for the PureScript programming language. - │ - │ Usage: iris [--log-file] - │ - │ Commands: - │ help Print this message or the help of the given subcommand(s) - │ - │ Project commands: - │ add Add dependencies to a Spago package. - │ build Build a Spago workspace or package. - │ lsp Run the language server. - │ new Create a Spago project in the current directory. - │ run Build and run a Spago package with Node.js. - │ test Build and test one or more Spago packages with Node.js. - │ watch Build a Spago workspace or package and rebuild when inputs change. - │ - │ Legacy commands: - │ compile Compile PureScript modules to JavaScript (experimental). - │ - │ Documentation commands: - │ docs Documentation utilities. - │ - │ Flags: - │ --log-file Print log path. - │ -h, --help Print help - │ -V, --version Print version - │ - │ iris --log-file - │ ╰────────────── - • diff --git a/tests-e2e/tests/snapshots/cli__removed_diagnostics_on_change.snap b/tests-e2e/tests/snapshots/cli__removed_diagnostics_on_change.snap deleted file mode 100644 index fcf969b65..000000000 --- a/tests-e2e/tests/snapshots/cli__removed_diagnostics_on_change.snap +++ /dev/null @@ -1,18 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [unknown_flag] · command line:1:10 - • - │ - • unexpected argument '--diagnostics-on-change' found - │ - │ Usage: iris lsp [FLAGS] - │ - │ For more information, try '--help'. - │ - │ iris lsp --diagnostics-on-change - │ ╰────────────────────── - • diff --git a/tests-e2e/tests/snapshots/cli__removed_diagnostics_on_open.snap b/tests-e2e/tests/snapshots/cli__removed_diagnostics_on_open.snap deleted file mode 100644 index 259f73131..000000000 --- a/tests-e2e/tests/snapshots/cli__removed_diagnostics_on_open.snap +++ /dev/null @@ -1,18 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [unknown_flag] · command line:1:10 - • - │ - • unexpected argument '--diagnostics-on-open' found - │ - │ Usage: iris lsp [FLAGS] - │ - │ For more information, try '--help'. - │ - │ iris lsp --diagnostics-on-open false - │ ╰──────────────────── - • diff --git a/tests-e2e/tests/snapshots/cli__removed_diagnostics_on_save.snap b/tests-e2e/tests/snapshots/cli__removed_diagnostics_on_save.snap deleted file mode 100644 index c3c6e70f6..000000000 --- a/tests-e2e/tests/snapshots/cli__removed_diagnostics_on_save.snap +++ /dev/null @@ -1,18 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [unknown_flag] · command line:1:10 - • - │ - • unexpected argument '--diagnostics-on-save' found - │ - │ Usage: iris lsp [FLAGS] - │ - │ For more information, try '--help'. - │ - │ iris lsp --diagnostics-on-save false - │ ╰──────────────────── - • diff --git a/tests-e2e/tests/snapshots/cli__removed_source_command.snap b/tests-e2e/tests/snapshots/cli__removed_source_command.snap deleted file mode 100644 index 4a421c995..000000000 --- a/tests-e2e/tests/snapshots/cli__removed_source_command.snap +++ /dev/null @@ -1,18 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [unknown_flag] · command line:1:10 - • - │ - • unexpected argument '--source-command' found - │ - │ Usage: iris lsp [FLAGS] - │ - │ For more information, try '--help'. - │ - │ iris lsp --source-command custom - │ ╰─────────────── - • diff --git a/tests-e2e/tests/snapshots/cli__root_checking_log.snap b/tests-e2e/tests/snapshots/cli__root_checking_log.snap deleted file mode 100644 index 794ad7c31..000000000 --- a/tests-e2e/tests/snapshots/cli__root_checking_log.snap +++ /dev/null @@ -1,18 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [unknown_flag] · command line:1:6 - • - │ - • unexpected argument '--checking-log' found - │ - │ Usage: iris [--log-file] - │ - │ For more information, try '--help'. - │ - │ iris --checking-log off lsp - │ ╰───────────── - • diff --git a/tests-e2e/tests/snapshots/cli__root_config.snap b/tests-e2e/tests/snapshots/cli__root_config.snap deleted file mode 100644 index ecc621043..000000000 --- a/tests-e2e/tests/snapshots/cli__root_config.snap +++ /dev/null @@ -1,18 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [unknown_flag] · command line:1:6 - • - │ - • unexpected argument '--config' found - │ - │ Usage: iris [--log-file] - │ - │ For more information, try '--help'. - │ - │ iris --config {} lsp - │ ╰─────── - • diff --git a/tests-e2e/tests/snapshots/cli__root_config_file.snap b/tests-e2e/tests/snapshots/cli__root_config_file.snap deleted file mode 100644 index d6ada5a51..000000000 --- a/tests-e2e/tests/snapshots/cli__root_config_file.snap +++ /dev/null @@ -1,20 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [unknown_flag] · command line:1:6 - • - │ - • unexpected argument '--config-file' found - │ - │ tip: a similar argument exists: '--log-file' - │ - │ Usage: iris [--log-file] - │ - │ For more information, try '--help'. - │ - │ iris --config-file missing.json lsp - │ ╰──────────── - • diff --git a/tests-e2e/tests/snapshots/cli__root_lsp_log.snap b/tests-e2e/tests/snapshots/cli__root_lsp_log.snap deleted file mode 100644 index 91a74aec0..000000000 --- a/tests-e2e/tests/snapshots/cli__root_lsp_log.snap +++ /dev/null @@ -1,18 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [unknown_flag] · command line:1:6 - • - │ - • unexpected argument '--lsp-log' found - │ - │ Usage: iris [--log-file] - │ - │ For more information, try '--help'. - │ - │ iris --lsp-log off lsp - │ ╰──────── - • diff --git a/tests-e2e/tests/snapshots/cli__root_query_log.snap b/tests-e2e/tests/snapshots/cli__root_query_log.snap deleted file mode 100644 index d391a4dce..000000000 --- a/tests-e2e/tests/snapshots/cli__root_query_log.snap +++ /dev/null @@ -1,18 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [unknown_flag] · command line:1:6 - • - │ - • unexpected argument '--query-log' found - │ - │ Usage: iris [--log-file] - │ - │ For more information, try '--help'. - │ - │ iris --query-log off lsp - │ ╰────────── - • diff --git a/tests-e2e/tests/snapshots/cli__root_requires_subcommand.snap b/tests-e2e/tests/snapshots/cli__root_requires_subcommand.snap deleted file mode 100644 index ab71d0b44..000000000 --- a/tests-e2e/tests/snapshots/cli__root_requires_subcommand.snap +++ /dev/null @@ -1,40 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [missing_subcommand] · command line:1:1 - • - │ - • iris 0.1.0 - │ Language implementation for the PureScript programming language. - │ - │ Usage: iris [--log-file] - │ - │ Commands: - │ help Print this message or the help of the given subcommand(s) - │ - │ Project commands: - │ add Add dependencies to a Spago package. - │ build Build a Spago workspace or package. - │ lsp Run the language server. - │ new Create a Spago project in the current directory. - │ run Build and run a Spago package with Node.js. - │ test Build and test one or more Spago packages with Node.js. - │ watch Build a Spago workspace or package and rebuild when inputs change. - │ - │ Legacy commands: - │ compile Compile PureScript modules to JavaScript (experimental). - │ - │ Documentation commands: - │ docs Documentation utilities. - │ - │ Flags: - │ --log-file Print log path. - │ -h, --help Print help - │ -V, --version Print version - │ - │ iris - │ ╰─── - • diff --git a/tests-e2e/tests/snapshots/cli__root_stdio.snap b/tests-e2e/tests/snapshots/cli__root_stdio.snap deleted file mode 100644 index eef210fc3..000000000 --- a/tests-e2e/tests/snapshots/cli__root_stdio.snap +++ /dev/null @@ -1,18 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [unknown_flag] · command line:1:6 - • - │ - • unexpected argument '--stdio' found - │ - │ Usage: iris [--log-file] - │ - │ For more information, try '--help'. - │ - │ iris --stdio - │ ╰────── - • diff --git a/tests-e2e/tests/snapshots/cli__root_stdio_before_lsp.snap b/tests-e2e/tests/snapshots/cli__root_stdio_before_lsp.snap deleted file mode 100644 index ace2c1b81..000000000 --- a/tests-e2e/tests/snapshots/cli__root_stdio_before_lsp.snap +++ /dev/null @@ -1,18 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [unknown_flag] · command line:1:6 - • - │ - • unexpected argument '--stdio' found - │ - │ Usage: iris [--log-file] - │ - │ For more information, try '--help'. - │ - │ iris --stdio lsp - │ ╰────── - • diff --git a/tests-e2e/tests/snapshots/cli__run_requires_separator.snap b/tests-e2e/tests/snapshots/cli__run_requires_separator.snap index b9142b3bd..db24d9f35 100644 --- a/tests-e2e/tests/snapshots/cli__run_requires_separator.snap +++ b/tests-e2e/tests/snapshots/cli__run_requires_separator.snap @@ -4,15 +4,8 @@ source: tests-e2e/tests/cli.rs status: 2 --- stdout --- stderr -Error! · [arg_requires_double_dash] · command line:1:1 - • - │ - • '[-- ARGUMENTS]…' can only be given after '--' - │ - │ Usage: iris run [FLAGS] [-- ARGUMENTS]… - │ - │ For more information, try '--help'. - │ - │ iris run argument - │ ╰──────────────── - • +error: '[-- ARGUMENTS]…' can only be given after '--' + +Usage: iris run [FLAGS] [-- ARGUMENTS]… + +For more information, try '--help'. diff --git a/tests-e2e/tests/snapshots/cli__test_requires_separator.snap b/tests-e2e/tests/snapshots/cli__test_requires_separator.snap index 65c15eca5..a42b84a72 100644 --- a/tests-e2e/tests/snapshots/cli__test_requires_separator.snap +++ b/tests-e2e/tests/snapshots/cli__test_requires_separator.snap @@ -4,15 +4,8 @@ source: tests-e2e/tests/cli.rs status: 2 --- stdout --- stderr -Error! · [arg_requires_double_dash] · command line:1:1 - • - │ - • '[-- ARGUMENTS]…' can only be given after '--' - │ - │ Usage: iris test [FLAGS] [-- ARGUMENTS]… - │ - │ For more information, try '--help'. - │ - │ iris test argument - │ ╰───────────────── - • +error: '[-- ARGUMENTS]…' can only be given after '--' + +Usage: iris test [FLAGS] [-- ARGUMENTS]… + +For more information, try '--help'. diff --git a/tests-e2e/tests/snapshots/cli__unicode_unknown_flag.snap b/tests-e2e/tests/snapshots/cli__unicode_unknown_flag.snap deleted file mode 100644 index f4dd449e7..000000000 --- a/tests-e2e/tests/snapshots/cli__unicode_unknown_flag.snap +++ /dev/null @@ -1,18 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [unknown_flag] · command line:1:6 - • - │ - • unexpected argument '--λ' found - │ - │ Usage: iris [--log-file] - │ - │ For more information, try '--help'. - │ - │ iris --λ - │ ╰── - • diff --git a/tests-e2e/tests/snapshots/cli__unknown_build_flag.snap b/tests-e2e/tests/snapshots/cli__unknown_build_flag.snap deleted file mode 100644 index f929228e5..000000000 --- a/tests-e2e/tests/snapshots/cli__unknown_build_flag.snap +++ /dev/null @@ -1,18 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [unknown_flag] · command line:1:12 - • - │ - • unexpected argument '--unknown' found - │ - │ Usage: iris build [FLAGS] - │ - │ For more information, try '--help'. - │ - │ iris build --unknown - │ ╰──────── - • diff --git a/tests-e2e/tests/snapshots/cli__unknown_compile_flag.snap b/tests-e2e/tests/snapshots/cli__unknown_compile_flag.snap deleted file mode 100644 index 2d2a4c1d9..000000000 --- a/tests-e2e/tests/snapshots/cli__unknown_compile_flag.snap +++ /dev/null @@ -1,18 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [unknown_flag] · command line:1:14 - • - │ - • unexpected argument '--unknown' found - │ - │ Usage: iris compile [FLAGS] [INPUT]… - │ - │ For more information, try '--help'. - │ - │ iris compile --unknown Main.purs - │ ╰──────── - • diff --git a/tests-e2e/tests/snapshots/cli__unknown_docs_flag.snap b/tests-e2e/tests/snapshots/cli__unknown_docs_flag.snap deleted file mode 100644 index 0cfc463c7..000000000 --- a/tests-e2e/tests/snapshots/cli__unknown_docs_flag.snap +++ /dev/null @@ -1,18 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [unknown_flag] · command line:1:11 - • - │ - • unexpected argument '--unknown' found - │ - │ Usage: iris docs [FLAGS] [SUBCOMMAND] - │ - │ For more information, try '--help'. - │ - │ iris docs --unknown --package . - │ ╰──────── - • diff --git a/tests-e2e/tests/snapshots/cli__unknown_root_flag.snap b/tests-e2e/tests/snapshots/cli__unknown_root_flag.snap deleted file mode 100644 index f623ed76d..000000000 --- a/tests-e2e/tests/snapshots/cli__unknown_root_flag.snap +++ /dev/null @@ -1,18 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [unknown_flag] · command line:1:6 - • - │ - • unexpected argument '--unknown' found - │ - │ Usage: iris [--log-file] - │ - │ For more information, try '--help'. - │ - │ iris --unknown - │ ╰──────── - • diff --git a/tests-e2e/tests/snapshots/cli__unknown_typescript_flag.snap b/tests-e2e/tests/snapshots/cli__unknown_typescript_flag.snap deleted file mode 100644 index 97bababdc..000000000 --- a/tests-e2e/tests/snapshots/cli__unknown_typescript_flag.snap +++ /dev/null @@ -1,18 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -Error! · [unknown_flag] · command line:1:22 - • - │ - • unexpected argument '--unknown' found - │ - │ Usage: iris docs typescript [--output ] - │ - │ For more information, try '--help'. - │ - │ iris docs typescript --unknown - │ ╰──────── - • diff --git a/tests-e2e/tests/support.rs b/tests-e2e/tests/support.rs index 9856634b2..1b3a42a5f 100644 --- a/tests-e2e/tests/support.rs +++ b/tests-e2e/tests/support.rs @@ -4,6 +4,8 @@ use std::fs; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Output, Stdio}; +use itertools::Itertools; + pub struct TestWorkspace { temporary: tempfile::TempDir, } @@ -95,7 +97,7 @@ impl TestWorkspace { let actual_directory = fields.next().unwrap(); let actual_directory = fs::canonicalize(actual_directory).unwrap(); assert_eq!(actual_directory, expected_directory); - actual_arguments.push(fields.collect::>()); + actual_arguments.push(fields.collect_vec()); } assert_eq!(actual_arguments, expected); }