From b46d27fcfbe1c072201c37c05f4a38f6d1a96c83 Mon Sep 17 00:00:00 2001 From: Justin Garcia Date: Sat, 12 Sep 2026 11:08:15 +0000 Subject: [PATCH 01/26] Expose package dependencies from Spago lockfiles Amp-Thread-ID: https://ampcode.com/threads/T-01a09469-c4cf-723d-9923-3b8452bea9f2 Co-authored-by: Amp --- compiler-lsp/spago/src/lockfile.rs | 69 +++++++++++++++++++++++++--- compiler-lsp/spago/tests/lockfile.rs | 41 +++++++++++++++++ 2 files changed, 104 insertions(+), 6 deletions(-) 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: {}, }, } "#); From 22481e8069a86328a7ca0dd41591556a6c7c33d7 Mon Sep 17 00:00:00 2001 From: Justin Garcia Date: Sat, 12 Sep 2026 11:08:36 +0000 Subject: [PATCH 02/26] Add Ratatui package progress renderer Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-01a09469-c4cf-723d-9923-3b8452bea9f2 --- Cargo.lock | 384 ++++++++++++++++- compiler-bin/Cargo.toml | 1 + compiler-bin/src/progress.rs | 813 ++++++++++++++++++++++++++++++++++- 3 files changed, 1193 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 205df5b02..9290cc54f 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" @@ -1576,6 +1742,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 +1961,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 +2033,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 +2073,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 +2104,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 +2147,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 +2315,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 +2360,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 +2618,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 +2631,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 +2667,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 +2903,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" @@ -2705,6 +2975,7 @@ dependencies = [ "path-absolutize", "prim-constants", "radix_trie", + "ratatui", "rayon", "resolving", "rustc-hash", @@ -2842,6 +3113,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 +3676,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" @@ -3851,6 +4199,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" @@ -4110,6 +4479,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/compiler-bin/Cargo.toml b/compiler-bin/Cargo.toml index 2930b46a1..054d12f6e 100644 --- a/compiler-bin/Cargo.toml +++ b/compiler-bin/Cargo.toml @@ -45,6 +45,7 @@ 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" +ratatui = { version = "0.30.2", default-features = false, features = ["crossterm"] } rayon = "1.12.0" resolving = { version = "0.1.0", path = "../compiler-frontend/resolving" } rustc-hash = "2.1.3" diff --git a/compiler-bin/src/progress.rs b/compiler-bin/src/progress.rs index 1dd3500fe..be19f956b 100644 --- a/compiler-bin/src/progress.rs +++ b/compiler-bin/src/progress.rs @@ -1,17 +1,73 @@ +use std::collections::VecDeque; use std::fmt; -use std::io::{self, IsTerminal}; -use std::sync::OnceLock; +use std::io::{self, IsTerminal, Write}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant}; -use console::{Color, Style}; +use console::{Color, Style, measure_text_width, truncate_str}; use indicatif::{MultiProgress, ProgressBar, ProgressState, ProgressStyle}; +use itertools::Itertools; +use ratatui::backend::CrosstermBackend; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::{Color as RatatuiColor, Modifier, Style as RatatuiStyle}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Paragraph, Widget}; +use ratatui::{Terminal, TerminalOptions, Viewport}; 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); +const PACKAGE_ANIMATION_INTERVAL: Duration = Duration::from_millis(80); +const PACKAGE_HISTORY_LENGTH: usize = 10; +const PACKAGE_REGION_WIDTH: usize = 80; +const PACKAGE_PREPARATION_HEIGHT: u16 = 1; +const PACKAGE_REGION_HEIGHT: u16 = PACKAGE_HISTORY_LENGTH as u16 + 2; static TERMINAL_THEME_MODE: OnceLock> = OnceLock::new(); +type PackageTerminal = Terminal>; + +pub(crate) struct PackageProgress { + state: Arc>, + terminal: Option>>, + appearance: PackageAppearance, + started: Instant, + animation: Option, +} + +struct PackageAnimation { + active: Arc, + thread: Mutex>>, +} + +struct PackageProgressState { + total: usize, + completed_count: usize, + completed: VecDeque, + phase: PackageProgressPhase, +} + +struct CompletedPackage { + name: String, + elapsed: Duration, +} + +enum PackageProgressPhase { + Preparing, + Compiling, + Finished(Duration), +} + +#[derive(Clone, Copy)] +enum PackageAppearance { + TrueColor { foreground: (u8, u8, u8), background: (u8, u8, u8) }, + Ansi, + Plain, +} + pub(crate) fn bar(total: usize, phase: &'static str, show: bool) -> ProgressBar { if !show { return ProgressBar::hidden(); @@ -44,6 +100,519 @@ pub(crate) fn finish(progress: &ProgressBar) { progress.finish(); } +pub(crate) fn packages(show: bool, color: bool) -> PackageProgress { + let state = Arc::new(Mutex::new(PackageProgressState { + total: 0, + completed_count: 0, + completed: VecDeque::with_capacity(PACKAGE_HISTORY_LENGTH), + phase: PackageProgressPhase::Preparing, + })); + let appearance = package_appearance(color); + let terminal = if show && io::stderr().is_terminal() { + let backend = CrosstermBackend::new(io::stderr()); + let options = TerminalOptions { viewport: Viewport::Inline(PACKAGE_PREPARATION_HEIGHT) }; + Terminal::with_options(backend, options).ok().map(|terminal| Arc::new(Mutex::new(terminal))) + } else { + None + }; + let started = Instant::now(); + let animation = terminal.as_ref().map(|terminal| { + let active = Arc::new(AtomicBool::new(true)); + let animation_active = Arc::clone(&active); + let animation_state = Arc::clone(&state); + let animation_terminal = Arc::clone(terminal); + let thread = thread::spawn(move || { + while animation_active.load(Ordering::Acquire) { + thread::sleep(PACKAGE_ANIMATION_INTERVAL); + if !animation_active.load(Ordering::Acquire) { + break; + } + let animation_frame = package_animation_frame(started.elapsed()); + let _ = draw_packages( + &animation_state, + &animation_terminal, + appearance, + animation_frame, + ); + } + }); + PackageAnimation { active, thread: Mutex::new(Some(thread)) } + }); + let progress = PackageProgress { state, terminal, appearance, started, animation }; + progress.draw(); + progress +} + +impl PackageProgress { + pub(crate) fn begin_compilation(&self, total: usize) { + let mut state = + self.state.lock().expect("invariant violated: package progress state is not poisoned"); + state.total = total; + state.phase = PackageProgressPhase::Compiling; + drop(state); + self.expand_terminal(); + self.draw(); + } + + pub(crate) fn complete(&self, name: &str, elapsed: Duration) { + let mut state = + self.state.lock().expect("invariant violated: package progress state is not poisoned"); + if state.completed.len() == PACKAGE_HISTORY_LENGTH { + state.completed.pop_front(); + } + state.completed.push_back(CompletedPackage { name: name.to_owned(), elapsed }); + state.completed_count += 1; + drop(state); + self.draw(); + } + + pub(crate) fn finish(&self, elapsed: Duration) { + self.stop_animation(); + let mut state = + self.state.lock().expect("invariant violated: package progress state is not poisoned"); + state.phase = PackageProgressPhase::Finished(elapsed); + drop(state); + if let Some(terminal) = &self.terminal { + let _ = finish_packages(&self.state, terminal, self.appearance); + } + } + + pub(crate) fn clear(&self) { + self.stop_animation(); + if let Some(terminal) = &self.terminal { + let mut terminal = terminal + .lock() + .expect("invariant violated: package progress terminal is not poisoned"); + let origin = terminal.get_frame().area().as_position(); + let _ = terminal.clear(); + let _ = terminal.set_cursor_position(origin); + let _ = terminal.show_cursor(); + let _ = Write::flush(terminal.backend_mut()); + } + } + + fn draw(&self) { + let Some(terminal) = &self.terminal else { + return; + }; + let animation_frame = package_animation_frame(self.started.elapsed()); + let _ = draw_packages(&self.state, terminal, self.appearance, animation_frame); + } + + fn expand_terminal(&self) { + let Some(terminal) = &self.terminal else { + return; + }; + let mut terminal = + terminal.lock().expect("invariant violated: package progress terminal is not poisoned"); + let origin = terminal.get_frame().area().as_position(); + let _ = terminal.clear(); + let _ = terminal.set_cursor_position(origin); + let _ = Write::flush(terminal.backend_mut()); + + let backend = CrosstermBackend::new(io::stderr()); + let options = TerminalOptions { viewport: Viewport::Inline(PACKAGE_REGION_HEIGHT) }; + if let Ok(expanded) = Terminal::with_options(backend, options) { + *terminal = expanded; + } + } + + fn stop_animation(&self) { + let Some(animation) = &self.animation else { + return; + }; + animation.active.store(false, Ordering::Release); + if let Some(thread) = animation + .thread + .lock() + .expect("invariant violated: package animation thread is not poisoned") + .take() + { + let _ = thread.join(); + } + } +} + +impl Drop for PackageProgress { + fn drop(&mut self) { + self.stop_animation(); + } +} + +fn package_animation_frame(elapsed: Duration) -> usize { + (elapsed.as_millis() / PACKAGE_ANIMATION_INTERVAL.as_millis()) as usize +} + +fn draw_packages( + state: &Arc>, + terminal: &Arc>, + appearance: PackageAppearance, + animation_frame: usize, +) -> io::Result { + let state = state.lock().expect("invariant violated: package progress state is not poisoned"); + let mut terminal = + terminal.lock().expect("invariant violated: package progress terminal is not poisoned"); + let mut viewport = Rect::ZERO; + terminal.draw(|frame| { + viewport = frame.area(); + let content = Rect { width: viewport.width.min(PACKAGE_REGION_WIDTH as u16), ..viewport }; + frame.render_widget(PackageView { state: &state, appearance, animation_frame }, content); + })?; + Ok(viewport) +} + +fn finish_packages( + state: &Arc>, + terminal: &Arc>, + appearance: PackageAppearance, +) -> io::Result<()> { + let area = draw_packages(state, terminal, appearance, 0)?; + let mut terminal = + terminal.lock().expect("invariant violated: package progress terminal is not poisoned"); + 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()?; + let writer = terminal.backend_mut(); + writer.write_all(b"\r\n")?; + Write::flush(writer) + } else { + Ok(()) + } +} + +fn package_appearance(color: bool) -> PackageAppearance { + if !color { + return PackageAppearance::Plain; + } + if !console::true_colors_enabled_stderr() { + return PackageAppearance::Ansi; + } + + let mut options = QueryOptions::default(); + options.timeout = Duration::from_millis(100); + let Ok(palette) = terminal_colorsaurus::color_palette(options) else { + return PackageAppearance::Ansi; + }; + PackageAppearance::TrueColor { + foreground: palette.foreground.scale_to_8bit(), + background: palette.background.scale_to_8bit(), + } +} + +struct PackageView<'a> { + state: &'a PackageProgressState, + appearance: PackageAppearance, + animation_frame: usize, +} + +impl Widget for PackageView<'_> { + fn render(self, area: Rect, buffer: &mut Buffer) { + if area.is_empty() { + return; + } + let width = area.width as usize; + if matches!(self.state.phase, PackageProgressPhase::Preparing) { + Paragraph::new(render_package_preparation( + width, + self.appearance, + self.animation_frame, + )) + .render(Rect::new(area.x, area.y, area.width, 1), buffer); + return; + } + if area.height < PACKAGE_REGION_HEIGHT { + Paragraph::new(render_package_progress( + self.state, + width, + self.appearance, + self.animation_frame, + )) + .render(Rect::new(area.x, area.bottom() - 1, area.width, 1), buffer); + return; + } + + let blank_rows = PACKAGE_HISTORY_LENGTH.saturating_sub(self.state.completed.len()); + for (index, package) in self.state.completed.iter().enumerate() { + let row = blank_rows + index; + let line = Line::styled( + render_completed_package(package, width), + package_history_style(self.appearance, row), + ); + Paragraph::new(line) + .render(Rect::new(area.x, area.y + row as u16, area.width, 1), buffer); + } + + if area.height > PACKAGE_HISTORY_LENGTH as u16 { + let separator = "─".repeat(width); + Paragraph::new(separator).render( + Rect::new(area.x, area.y + PACKAGE_HISTORY_LENGTH as u16, area.width, 1), + buffer, + ); + } + Paragraph::new(render_package_progress( + self.state, + width, + self.appearance, + self.animation_frame, + )) + .render( + Rect::new(area.x, area.y + PACKAGE_HISTORY_LENGTH as u16 + 1, area.width, 1), + buffer, + ); + } +} + +fn render_completed_package(package: &CompletedPackage, width: usize) -> String { + let elapsed_milliseconds = package.elapsed.as_secs_f64() * 1_000.0; + let timing = format!("{elapsed_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 name = sanitize(&package.name); + let name = truncate_str(&name, name_width, "…"); + let padding = name_width - measure_text_width(&name) + 2; + format!("{name}{}{timing}", " ".repeat(padding)) +} + +fn render_package_progress( + state: &PackageProgressState, + width: usize, + appearance: PackageAppearance, + animation_frame: usize, +) -> Line<'static> { + let completed = state.completed_count; + let status = match state.phase { + PackageProgressPhase::Finished(elapsed) => format!("Finished in {elapsed:.2?}"), + PackageProgressPhase::Compiling => "Compiling".to_owned(), + PackageProgressPhase::Preparing => unreachable!("preparation is rendered separately"), + }; + let count = format!("{completed}/{}", state.total); + let count_width = measure_text_width(&count); + if width <= count_width + 4 { + return Line::from(truncate_str(&count, width, "").to_string()); + } + + const MINIMUM_BAR_WIDTH: usize = 8; + let status_width = width.saturating_sub(count_width + 4 + MINIMUM_BAR_WIDTH); + let status = truncate_str(&status, status_width, "…"); + let bar_width = width - measure_text_width(&status) - count_width - 4; + let accent = package_accent_style(appearance); + let mut spans = vec![Span::styled(status.into_owned(), accent), Span::raw(" [")]; + spans.extend(render_package_bar_track( + appearance, + bar_width, + completed, + state.total, + animation_frame, + matches!(state.phase, PackageProgressPhase::Finished(_)), + )); + spans.push(Span::raw("] ")); + spans.push(Span::styled(count, accent)); + Line::from(spans) +} + +fn render_package_preparation( + width: usize, + appearance: PackageAppearance, + animation_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 accent = package_accent_style(appearance); + let mut spans = vec![Span::styled(status, accent), Span::raw(" [")]; + spans.extend(render_package_activity_bar(appearance, bar_width, animation_frame)); + spans.push(Span::raw("]")); + Line::from(spans) +} + +fn render_package_activity_bar( + appearance: PackageAppearance, + width: usize, + animation_frame: usize, +) -> Vec> { + const PULSE_WIDTH: usize = 5; + if matches!(appearance, PackageAppearance::Plain) { + let mut bar = vec!['-'; width]; + if width > 0 { + bar[animation_frame % width] = '>'; + } + return vec![Span::raw(bar.into_iter().collect::())]; + } + + let spans = (0..width).map(|index| { + let cycle = width + PULSE_WIDTH; + let highlight = animation_frame % cycle; + let distance = index + PULSE_WIDTH; + let distance = distance.abs_diff(highlight).min(cycle - distance.abs_diff(highlight)); + let character = if distance < PULSE_WIDTH { "⣿" } else { "⠀" }; + Span::styled( + character, + package_bar_style(appearance, index, width, width, animation_frame, false), + ) + }); + spans.collect_vec() +} + +fn render_package_bar_track( + appearance: PackageAppearance, + width: usize, + completed: usize, + total: usize, + animation_frame: usize, + finished: bool, +) -> Vec> { + let filled_eighths = if total == 0 { width * 8 } else { width * 8 * completed / total }; + if matches!(appearance, PackageAppearance::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 cell_eighths = filled_eighths.saturating_sub(index * 8).min(8); + Span::styled( + BRAILLE_FILL[cell_eighths], + package_bar_style(appearance, index, width, filled, animation_frame, finished), + ) + }); + spans.collect_vec() +} + +fn package_bar_style( + appearance: PackageAppearance, + index: usize, + width: usize, + filled: usize, + animation_frame: usize, + finished: bool, +) -> RatatuiStyle { + let style = RatatuiStyle::default(); + match appearance { + PackageAppearance::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 highlight = animation_frame % filled; + let distance = index.abs_diff(highlight); + let brightness = match distance { + 0 => 0.35, + 1 => 0.2, + 2 => 0.08, + _ => 0.0, + }; + let gold = (248, 216, 120); + let tint = |channel: u8, gold: u8| { + (channel as f32 + (gold as f32 - channel as f32) * brightness).round() as u8 + }; + color = (tint(color.0, gold.0), tint(color.1, gold.1), tint(color.2, gold.2)); + } + style.fg(RatatuiColor::Rgb(color.0, color.1, color.2)) + } + PackageAppearance::TrueColor { foreground, .. } if index == filled => style + .fg(RatatuiColor::Rgb(foreground.0, foreground.1, foreground.2)) + .add_modifier(Modifier::BOLD), + PackageAppearance::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(RatatuiColor::Rgb( + blend(foreground.0, background.0), + blend(foreground.1, background.1), + blend(foreground.2, background.2), + )) + } + PackageAppearance::Ansi if index < filled => { + let color = if index < filled / 3 { + RatatuiColor::Blue + } else if index < filled * 2 / 3 { + RatatuiColor::Cyan + } else { + RatatuiColor::Green + }; + let style = style.fg(color); + if !finished && index == animation_frame % filled.max(1) { + style.add_modifier(Modifier::BOLD) + } else { + style + } + } + PackageAppearance::Ansi if index == filled => { + style.fg(RatatuiColor::White).add_modifier(Modifier::BOLD) + } + PackageAppearance::Ansi => style.fg(RatatuiColor::DarkGray), + PackageAppearance::Plain => unreachable!("plain package bars are rendered without styles"), + } +} + +fn package_history_style(appearance: PackageAppearance, row: usize) -> RatatuiStyle { + let style = RatatuiStyle::default(); + match appearance { + PackageAppearance::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.fg(RatatuiColor::Rgb( + blend(foreground.0, background.0), + blend(foreground.1, background.1), + blend(foreground.2, background.2), + )) + } + PackageAppearance::Ansi if row + 1 == PACKAGE_HISTORY_LENGTH => { + style.fg(RatatuiColor::Green).add_modifier(Modifier::BOLD) + } + PackageAppearance::Ansi if row * 2 < PACKAGE_HISTORY_LENGTH => { + style.add_modifier(Modifier::DIM) + } + PackageAppearance::Ansi | PackageAppearance::Plain => style, + } +} + +fn package_accent_style(appearance: PackageAppearance) -> RatatuiStyle { + match appearance { + PackageAppearance::Plain => RatatuiStyle::default(), + PackageAppearance::TrueColor { .. } | PackageAppearance::Ansi => { + RatatuiStyle::default().fg(RatatuiColor::Cyan) + } + } +} + +fn sanitize(name: &str) -> String { + let characters = + name.chars().map(|character| if character.is_control() { '�' } else { character }); + characters.collect() +} + pub(crate) fn report_completion(elapsed: Duration) { if !io::stderr().is_terminal() { return; @@ -112,3 +681,241 @@ fn phase_style(theme_mode: Option, intensity: u8) -> Style { }; style.fg(Color::TrueColor(intensity, intensity, intensity)) } + +#[cfg(test)] +mod tests { + use super::*; + use itertools::Itertools; + + fn package_state(names: &[&str], total: usize) -> PackageProgressState { + let completed = names.iter().map(|name| CompletedPackage { + name: (*name).to_owned(), + elapsed: Duration::from_millis(125), + }); + let completed = completed.collect(); + PackageProgressState { + total, + completed_count: names.len(), + completed, + phase: PackageProgressPhase::Compiling, + } + } + + fn render_state( + state: &PackageProgressState, + width: u16, + appearance: PackageAppearance, + ) -> Buffer { + render_state_at_height(state, width, PACKAGE_REGION_HEIGHT, appearance) + } + + fn render_state_at_height( + state: &PackageProgressState, + width: u16, + height: u16, + appearance: PackageAppearance, + ) -> Buffer { + let area = Rect::new(0, 0, width, height); + let mut buffer = Buffer::empty(area); + PackageView { state, appearance, animation_frame: 0 }.render(area, &mut buffer); + buffer + } + + fn row_text(buffer: &Buffer, row: u16) -> String { + let characters = (0..buffer.area.width).map(|column| { + buffer + .cell((column, row)) + .expect("invariant violated: rendered package row has no cell") + .symbol() + }); + characters.collect() + } + + #[test] + fn package_frame_has_ten_history_rows_and_two_status_rows() { + let names = ["one", "two", "three"]; + let state = package_state(&names, 12); + + let buffer = render_state(&state, 60, PackageAppearance::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).contains("3/12")); + } + + #[test] + fn package_frame_transitions_from_preparation_to_compilation() { + let progress = packages(false, false); + let state = progress.state.lock().unwrap(); + let preparing = render_state(&state, 60, PackageAppearance::Plain); + drop(state); + + progress.begin_compilation(12); + let state = progress.state.lock().unwrap(); + let compiling = render_state(&state, 60, PackageAppearance::Plain); + + assert!(row_text(&preparing, 0).starts_with("Preparing [")); + assert!(!row_text(&preparing, 0).contains("0/0")); + assert!((1..PACKAGE_REGION_HEIGHT).all(|row| row_text(&preparing, row).trim().is_empty())); + assert!(row_text(&compiling, 11).starts_with("Compiling [")); + assert!(row_text(&compiling, 11).ends_with("0/12")); + } + + #[test] + fn package_frame_caps_width_and_aligns_timing_digits() { + let state = PackageProgressState { + total: 2, + completed_count: 2, + completed: VecDeque::from([ + CompletedPackage { + name: "short".to_owned(), + elapsed: Duration::from_micros(9_500), + }, + CompletedPackage { + name: "a-much-longer-package-name".to_owned(), + elapsed: Duration::from_micros(123_450), + }, + ]), + phase: PackageProgressPhase::Compiling, + }; + + let buffer = render_state(&state, PACKAGE_REGION_WIDTH as u16, PackageAppearance::Plain); + let short = row_text(&buffer, 8); + let long = row_text(&buffer, 9); + + assert_eq!(measure_text_width(&short), PACKAGE_REGION_WIDTH); + assert_eq!(measure_text_width(&long), PACKAGE_REGION_WIDTH); + assert_eq!(short.find('.'), long.find('.')); + assert!(short.ends_with("9.50 ms")); + assert!(long.ends_with("123.45 ms")); + } + + #[test] + fn package_frame_keeps_only_the_latest_ten_completions() { + let names = (1..=12).map(|number| format!("package-{number}")).collect_vec(); + let progress = packages(false, false); + progress.begin_compilation(names.len()); + for name in &names { + progress.complete(name, Duration::from_millis(125)); + } + let state = progress.state.lock().unwrap(); + + let buffer = render_state(&state, 60, PackageAppearance::Plain); + let frame = (0..PACKAGE_HISTORY_LENGTH as u16).map(|row| row_text(&buffer, row)).join("\n"); + + assert!(!frame.contains("package-1 ")); + assert!(!frame.contains("package-2 ")); + assert!(frame.contains("package-3 ")); + assert!(frame.contains("package-12 ")); + } + + #[test] + fn package_frame_preserves_progress_status() { + let state = package_state(&["completed"], 12); + + let buffer = render_state(&state, 60, PackageAppearance::Plain); + let progress = row_text(&buffer, 11); + + assert!(progress.contains("1/12")); + assert!(progress.contains('[')); + assert!(progress.contains(']')); + assert_eq!(measure_text_width(&progress), 60); + } + + #[test] + fn package_bar_does_not_exceed_extremely_narrow_terminals() { + let state = package_state(&[], 12); + + for width in 1..=8 { + let buffer = render_state(&state, width, PackageAppearance::Plain); + assert_eq!(measure_text_width(&row_text(&buffer, 11)), width as usize); + } + } + + #[test] + fn short_terminal_keeps_status_visible() { + let state = package_state(&["completed"], 12); + + let buffer = render_state_at_height(&state, 40, 4, PackageAppearance::Plain); + + assert!(row_text(&buffer, 3).contains("1/12")); + assert!(!row_text(&buffer, 3).contains("completed")); + } + + #[test] + fn package_frame_sanitizes_and_truncates_names_without_wrapping() { + let state = package_state(&["a\nvery-long-package-name"], 1); + + let buffer = render_state(&state, 18, PackageAppearance::Plain); + + let package = row_text(&buffer, 9); + assert!(package.contains('�')); + assert!(package.contains('…')); + assert!(package.ends_with("125.00 ms")); + } + + #[test] + fn package_history_uses_fixed_row_opacity_and_shifts_on_completion() { + let appearance = + PackageAppearance::TrueColor { foreground: (240, 220, 200), background: (20, 30, 40) }; + let mut state = package_state(&["first"], 2); + + let first_frame = render_state(&state, 60, appearance); + let first_style = first_frame.cell((0, 9)).unwrap().style(); + + state.completed.push_back(CompletedPackage { + name: "second".to_owned(), + elapsed: Duration::from_millis(125), + }); + state.completed_count += 1; + let second_frame = render_state(&state, 60, appearance); + let shifted_style = second_frame.cell((0, 8)).unwrap().style(); + let newest_style = second_frame.cell((0, 9)).unwrap().style(); + + assert_eq!(first_style.fg, Some(RatatuiColor::Rgb(240, 220, 200))); + assert_eq!(shifted_style.fg, Some(RatatuiColor::Rgb(220, 203, 185))); + assert_eq!(newest_style, first_style); + } + + #[test] + fn true_color_history_fades_from_oldest_to_newest_row() { + let appearance = + PackageAppearance::TrueColor { foreground: (240, 220, 200), background: (20, 30, 40) }; + + assert_eq!(package_history_style(appearance, 0).fg, Some(RatatuiColor::Rgb(60, 64, 69))); + assert_eq!(package_history_style(appearance, 9).fg, Some(RatatuiColor::Rgb(240, 220, 200))); + } + + #[test] + fn true_color_package_bar_uses_iris_costume_gradient() { + let appearance = + PackageAppearance::TrueColor { foreground: (240, 240, 240), background: (20, 20, 20) }; + + let bar = render_package_bar_track(appearance, 7, 7, 7, 0, true); + + assert_eq!(bar.first().unwrap().style.fg, Some(RatatuiColor::Rgb(240, 128, 136))); + assert_eq!(bar[2].style.fg, Some(RatatuiColor::Rgb(248, 152, 184))); + assert_eq!(bar[4].style.fg, Some(RatatuiColor::Rgb(184, 136, 224))); + assert_eq!(bar.last().unwrap().style.fg, Some(RatatuiColor::Rgb(136, 72, 192))); + } + + #[test] + fn colored_package_bar_uses_braille_subcells() { + let bar = render_package_bar_track(PackageAppearance::Ansi, 2, 1, 3, 0, false); + + assert_eq!(bar[0].content, "⣇"); + assert_eq!(bar[1].content, "⠀"); + } + + #[test] + fn true_color_package_bar_animates_a_highlight() { + let appearance = + PackageAppearance::TrueColor { foreground: (240, 240, 240), background: (20, 20, 20) }; + let first = render_package_bar_track(appearance, 6, 3, 6, 0, false); + let second = render_package_bar_track(appearance, 6, 3, 6, 1, false); + + assert_ne!(first[0].style.fg, second[0].style.fg); + assert_ne!(first[1].style.fg, second[1].style.fg); + } +} From 3dada42c3a53002b62a0f65551bf3851515d05ae Mon Sep 17 00:00:00 2001 From: Justin Garcia Date: Sat, 12 Sep 2026 11:09:47 +0000 Subject: [PATCH 03/26] Schedule project builds by package dependencies Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-01a09469-c4cf-723d-9923-3b8452bea9f2 --- Cargo.lock | 1 + compiler-bin/Cargo.toml | 1 + compiler-bin/src/compile.rs | 731 +++++++++++++++++++++-- compiler-bin/src/project.rs | 13 +- tests-e2e/tests/package_manager/build.rs | 9 +- 5 files changed, 685 insertions(+), 70 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9290cc54f..5dbffd234 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2973,6 +2973,7 @@ dependencies = [ "notify", "parking_lot", "path-absolutize", + "petgraph", "prim-constants", "radix_trie", "ratatui", diff --git a/compiler-bin/Cargo.toml b/compiler-bin/Cargo.toml index 054d12f6e..2fb6961c1 100644 --- a/compiler-bin/Cargo.toml +++ b/compiler-bin/Cargo.toml @@ -43,6 +43,7 @@ lsp-types.workspace = true notify = "8.2.0" parking_lot = "0.12.5" path-absolutize = "4.0.1" +petgraph = "0.8.3" prim-constants = { version = "0.1.0", path = "../compiler-core/prim-constants" } radix_trie = "0.3.0" ratatui = { version = "0.30.2", default-features = false, features = ["crossterm"] } diff --git a/compiler-bin/src/compile.rs b/compiler-bin/src/compile.rs index c068b7423..235ae21e5 100644 --- a/compiler-bin/src/compile.rs +++ b/compiler-bin/src/compile.rs @@ -1,7 +1,8 @@ -use std::collections::{BTreeSet, HashSet}; +use std::collections::{BTreeSet, HashMap, HashSet}; use std::io::IsTerminal; use std::path::{Path, PathBuf}; -use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; use std::time::Instant; use std::{fs, io, process}; @@ -9,6 +10,9 @@ use building::{DiskObservation, QueryError, SourceUnitKey}; use diagnostics::Severity; use files::{FileId, ForeignSourceKind}; use indicatif::MultiProgress; +use itertools::Itertools; +use petgraph::algo::tarjan_scc; +use petgraph::prelude::DiGraphMap; use rayon::prelude::*; use thiserror::Error; use url::Url; @@ -34,6 +38,24 @@ pub(crate) struct BuildConfig<'a> { pub resilience: Resilience, } +pub(crate) struct PackageInput { + pub name: String, + pub sources: Vec, + pub dependencies: Vec, +} + +struct PackageJob { + name: String, + source_ids: Vec, + dependencies: Vec, +} + +struct PackageGroup { + packages: Vec, + dependencies: Vec, + dependents: Vec, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Resilience { Strict, @@ -56,6 +78,10 @@ pub(crate) enum CompileError { Io(#[from] io::Error), #[error(transparent)] Package(#[from] package::PackageError), + #[error("source {path} belongs to both Spago packages {first_package} and {second_package}")] + ConflictingPackageSource { path: PathBuf, first_package: String, second_package: String }, + #[error("Spago selected source {0}, but the lockfile does not assign it to a package")] + UnownedPackageSource(PathBuf), #[error(transparent)] Query(#[from] QueryError), #[error(transparent)] @@ -104,27 +130,35 @@ fn compile(config: CompileConfig) -> Result<(), CompileError> { compile_source_paths(&build_config, source_paths, started, preparation_progress) } -pub(crate) fn compile_inputs( +pub(crate) fn compile_package_inputs( root: &Path, output: &Path, inputs: &[PathBuf], + packages: Vec, 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, + let color = use_color(color); + let package_progress = progress::packages(!quiet, color); + let prepared = (|| { + let walked = walk::walk(root, inputs)?; + let source_paths = walked.files.into_iter().collect::>(); + let packages = select_package_sources(&source_paths, packages)?; + Ok::<_, CompileError>((source_paths, packages)) + })(); + let (source_paths, packages) = match prepared { + Ok(prepared) => prepared, + Err(error) => { + package_progress.clear(); + return Err(error); + } }; - compile_source_paths(&build_config, source_paths, started, preparation_progress) + + let build_config = + BuildConfig { output, current_directory: root, color, progress: !quiet, resilience }; + compile_package_source_paths(&build_config, source_paths, packages, started, &package_progress) } fn compile_source_paths( @@ -157,17 +191,329 @@ fn compile_source_paths( Ok(()) } +fn select_package_sources( + source_paths: &BTreeSet, + packages: Vec, +) -> Result, CompileError> { + let mut owners: HashMap = HashMap::new(); + for package in &packages { + for source in &package.sources { + let source = dunce::canonicalize(source)?; + if let Some(first_package) = owners.get(&source) { + if first_package != &package.name { + return Err(CompileError::ConflictingPackageSource { + path: source, + first_package: String::clone(first_package), + second_package: String::clone(&package.name), + }); + } + } else { + owners.insert(source, String::clone(&package.name)); + } + } + } + + let mut selected: HashMap> = HashMap::new(); + for source in source_paths { + let canonical = dunce::canonicalize(source)?; + let Some(owner) = owners.get(&canonical) else { + return Err(CompileError::UnownedPackageSource(source.to_path_buf())); + }; + selected.entry(String::clone(owner)).or_default().push(source.to_path_buf()); + } + + let retained = selected.keys().map(String::as_str).collect::>(); + let packages_by_name = + packages.iter().map(|package| (package.name.as_str(), package)).collect::>(); + let contracted_dependencies = + packages.iter().filter(|package| retained.contains(&*package.name)).map(|package| { + let mut dependencies = vec![]; + let mut visited = HashSet::new(); + let mut pending = package.dependencies.iter().rev().map(String::as_str).collect_vec(); + while let Some(dependency) = pending.pop() { + if !visited.insert(dependency) { + continue; + } + if retained.contains(dependency) { + dependencies.push(dependency.to_owned()); + } else if let Some(package) = packages_by_name.get(dependency) { + pending.extend(package.dependencies.iter().rev().map(String::as_str)); + } + } + (String::clone(&package.name), dependencies) + }); + let contracted_dependencies = contracted_dependencies.collect::>(); + let packages = packages.into_iter().filter_map(|package| { + let sources = selected.remove(&package.name)?; + let dependencies = contracted_dependencies[&package.name].clone(); + Some(PackageInput { sources, dependencies, ..package }) + }); + Ok(packages.collect_vec()) +} + +fn compile_package_source_paths( + config: &BuildConfig<'_>, + source_paths: BTreeSet, + packages: Vec, + started: Instant, + package_progress: &progress::PackageProgress, +) -> Result<(), CompileError> { + let compilation = (|| { + let mut compilation = CompilationState::new(); + let mut source_ids = HashMap::new(); + for path in source_paths { + let file_id = load_source(&mut compilation, &path)?; + source_ids.insert(path, file_id); + } + let jobs = package_jobs(packages, &source_ids); + + if jobs.is_empty() { + return Err(io::Error::new(io::ErrorKind::NotFound, "no input files found").into()); + } + + package_progress.begin_compilation(jobs.len()); + schedule_package_jobs(&jobs, package_progress, &|package| { + query_package(&compilation, &package.source_ids) + })?; + Ok::<_, CompileError>(compilation) + })(); + let compilation = match compilation { + Ok(compilation) => compilation, + Err(error) => { + package_progress.clear(); + return Err(error); + } + }; + + let finalization_config = BuildConfig { + output: config.output, + current_directory: config.current_directory, + color: config.color, + progress: false, + resilience: config.resilience, + }; + package_progress.finish(started.elapsed()); + let source_ids = compilation.input_source_ids(); + if matches!( + finalize_build(&compilation, &source_ids, &finalization_config)?, + BuildOutcome::Diagnostics + ) { + return Err(CompileError::Diagnostics); + } + Ok(()) +} + +fn package_jobs( + packages: Vec, + source_ids: &HashMap, +) -> Vec { + let package_indices = packages + .iter() + .enumerate() + .map(|(index, package)| (String::clone(&package.name), index)) + .collect::>(); + let jobs = packages.into_iter().map(|package| PackageJob { + name: package.name, + source_ids: package.sources.iter().map(|source| source_ids[source]).collect_vec(), + dependencies: package + .dependencies + .iter() + .filter_map(|dependency| package_indices.get(dependency).copied()) + .collect_vec(), + }); + jobs.collect_vec() +} + +struct PackageScheduler<'a, F> { + jobs: &'a [PackageJob], + groups: Vec, + remaining: Vec, + package_progress: &'a progress::PackageProgress, + execute: &'a F, + failed: AtomicBool, + error: Mutex>, +} + +impl<'a, F> PackageScheduler<'a, F> +where + F: Fn(&PackageJob) -> Result<(), CompileError> + Sync, +{ + fn spawn<'scope>(&'scope self, scope: &rayon::Scope<'scope>, group_index: usize) { + scope.spawn(move |scope| { + let results = self.groups[group_index].packages.par_iter().map(|&package_index| { + let package = &self.jobs[package_index]; + let started = Instant::now(); + let result = (self.execute)(package); + if result.is_ok() { + self.package_progress.complete(&package.name, started.elapsed()); + } + result + }); + let result = results.collect::, _>>(); + 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 &self.groups[group_index].dependents { + if self.remaining[dependent].fetch_sub(1, Ordering::AcqRel) == 1 { + self.spawn(scope, dependent); + } + } + }); + } +} + +fn schedule_package_jobs( + jobs: &[PackageJob], + package_progress: &progress::PackageProgress, + execute: &F, +) -> Result<(), CompileError> +where + F: Fn(&PackageJob) -> Result<(), CompileError> + Sync, +{ + let groups = package_groups(jobs); + let remaining = groups.iter().map(|group| AtomicUsize::new(group.dependencies.len())); + let remaining = remaining.collect_vec(); + let scheduler = PackageScheduler { + jobs, + groups, + remaining, + package_progress, + execute, + failed: AtomicBool::new(false), + error: Mutex::new(None), + }; + + rayon::scope(|scope| { + for (group_index, group) in scheduler.groups.iter().enumerate() { + if group.dependencies.is_empty() { + scheduler.spawn(scope, group_index); + } + } + }); + + scheduler + .error + .into_inner() + .expect("invariant violated: package build error is not poisoned") + .map_or(Ok(()), Err) +} + +fn query_package( + compilation: &CompilationState, + source_ids: &[FileId], +) -> Result<(), CompileError> { + let queried = source_ids.par_iter().map(|&file_id| { + analyse_source(compilation, file_id)?; + elaborate_source(compilation, file_id)?; + generate_source(compilation, file_id) + }); + queried.collect::, _>>()?; + Ok(()) +} + +fn analyse_source(compilation: &CompilationState, file_id: FileId) -> Result<(), CompileError> { + let engine = compilation.snapshot(); + engine.stabilized(file_id)?; + engine.indexed(file_id)?; + engine.resolved(file_id)?; + engine.lowered(file_id)?; + Ok(()) +} + +fn elaborate_source(compilation: &CompilationState, file_id: FileId) -> Result<(), CompileError> { + let engine = compilation.snapshot(); + engine.checked(file_id)?; + engine.foreign_validation(file_id)?; + Ok(()) +} + +fn generate_source(compilation: &CompilationState, file_id: FileId) -> Result<(), CompileError> { + let engine = compilation.snapshot(); + let _ = engine.javascript(file_id)?; + Ok(()) +} + +fn package_groups(jobs: &[PackageJob]) -> Vec { + let mut graph = DiGraphMap::::default(); + for package in 0..jobs.len() { + graph.add_node(package); + } + for (package, job) in jobs.iter().enumerate() { + for &dependency in &job.dependencies { + graph.add_edge(package, dependency, ()); + } + } + + let mut group_packages = tarjan_scc(&graph); + for packages in &mut group_packages { + packages.sort_unstable(); + } + group_packages.sort_unstable_by_key(|packages| packages[0]); + let mut package_to_group = vec![usize::MAX; jobs.len()]; + for (group, packages) in group_packages.iter().enumerate() { + for &package in packages { + package_to_group[package] = group; + } + } + + let dependencies = group_packages.iter().enumerate().map(|(group, packages)| { + let mut dependencies = packages + .iter() + .flat_map(|&package| &jobs[package].dependencies) + .map(|&package| package_to_group[package]) + .filter(|&dependency| dependency != group) + .collect_vec(); + dependencies.sort_unstable(); + dependencies.dedup(); + dependencies + }); + let dependencies = dependencies.collect_vec(); + let mut dependents = vec![vec![]; group_packages.len()]; + for (group, dependencies) in dependencies.iter().enumerate() { + for &dependency in dependencies { + dependents[dependency].push(group); + } + } + + let groups = group_packages.into_iter().zip(dependencies).zip(dependents).map( + |((packages, dependencies), dependents)| PackageGroup { + packages, + dependencies, + dependents, + }, + ); + groups.collect_vec() +} + 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)?; + query_sources(compilation, &source_ids, config.progress)?; + finalize_build(compilation, &source_ids, config) +} + +fn finalize_build( + compilation: &CompilationState, + source_ids: &[FileId], + config: &BuildConfig<'_>, +) -> Result { + 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 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)) } } @@ -175,7 +521,7 @@ pub(crate) fn build( pub(crate) fn load_source( compilation: &mut CompilationState, path: &Path, -) -> Result<(), CompileError> { +) -> Result { let source_url = Url::from_file_path(path).map_err(|()| CompileError::InvalidPath(path.to_path_buf()))?; let foreign_path = path.with_extension("js"); @@ -185,7 +531,12 @@ pub(crate) fn load_source( 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())); + let change = compilation + .observe_source(SourceUnitKey::clone(&unit), DiskObservation::Found(content.into())); + 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()); @@ -196,7 +547,7 @@ pub(crate) fn load_source( }; compilation.observe_foreign(SourceUnitKey::clone(&unit), kind, disk); } - Ok(()) + Ok(file_id) } pub(crate) fn use_color(choice: ColorChoice) -> bool { @@ -223,14 +574,53 @@ fn report_diagnostics( source_ids: &[FileId], config: &BuildConfig<'_>, ) -> Result { + 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 query_sources( + compilation: &CompilationState, + source_ids: &[FileId], + show_progress: bool, +) -> Result<(), CompileError> { let progress = MultiProgress::new(); progress.set_move_cursor(true); - let analysing_progress = - progress::phase(&progress, source_ids.len(), "Analyse", config.progress); + let analysing_progress = progress::phase(&progress, source_ids.len(), "Analyse", show_progress); let checking_progress = - progress::phase(&progress, source_ids.len(), "Elaborate", config.progress); + progress::phase(&progress, source_ids.len(), "Elaborate", show_progress); let generating_progress = - progress::phase(&progress, source_ids.len(), "Generate", config.progress); + progress::phase(&progress, source_ids.len(), "Generate", show_progress); let module_names = source_ids.par_iter().map(|&file_id| { let engine = compilation.snapshot(); @@ -240,10 +630,7 @@ fn report_diagnostics( 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)?; + analyse_source(compilation, file_id)?; analysing_progress.inc(1); Ok::<_, CompileError>(module_name) }); @@ -251,12 +638,10 @@ fn report_diagnostics( 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)?; + elaborate_source(compilation, file_id)?; checking_progress.inc(1); Ok::<_, CompileError>(()) }); @@ -264,50 +649,16 @@ fn report_diagnostics( 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)?; + generate_source(compilation, 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) + Ok(()) } fn collect_modules( @@ -389,3 +740,251 @@ fn write_if_changed(path: &Path, content: &[u8]) -> io::Result<()> { Err(error) => Err(error), } } + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Condvar, mpsc}; + use std::thread; + use std::time::Duration; + use tempfile::tempdir; + + fn package(name: &str, dependencies: Vec) -> PackageJob { + PackageJob { name: name.to_owned(), source_ids: vec![], dependencies } + } + + #[test] + fn package_source_ownership_must_be_unique() { + let directory = tempdir().unwrap(); + let source = directory.path().join("Shared.purs"); + fs::write(&source, "module Shared where\n").unwrap(); + let source_paths = BTreeSet::from([PathBuf::clone(&source)]); + let packages = vec![ + PackageInput { + name: "first".to_owned(), + sources: vec![PathBuf::clone(&source)], + dependencies: vec![], + }, + PackageInput { name: "second".to_owned(), sources: vec![source], dependencies: vec![] }, + ]; + + let result = select_package_sources(&source_paths, packages); + + assert!(matches!( + result, + Err(CompileError::ConflictingPackageSource { + first_package, + second_package, + .. + }) if first_package == "first" && second_package == "second" + )); + } + + #[test] + fn source_empty_packages_preserve_dependency_reachability() { + let directory = tempdir().unwrap(); + let library = directory.path().join("Library.purs"); + let application = directory.path().join("Application.purs"); + fs::write(&library, "module Library where\n").unwrap(); + fs::write(&application, "module Application where\n").unwrap(); + let source_paths = BTreeSet::from([PathBuf::clone(&library), PathBuf::clone(&application)]); + let packages = vec![ + PackageInput { + name: "library".to_owned(), + sources: vec![library], + dependencies: vec![], + }, + PackageInput { + name: "metadata-only".to_owned(), + sources: vec![], + dependencies: vec!["library".to_owned()], + }, + PackageInput { + name: "application".to_owned(), + sources: vec![application], + dependencies: vec!["metadata-only".to_owned()], + }, + ]; + + let selected = select_package_sources(&source_paths, packages).unwrap(); + let dependencies = selected + .into_iter() + .map(|package| (package.name, package.dependencies)) + .collect::>(); + + assert_eq!(dependencies["application"], ["library"]); + assert!(dependencies["library"].is_empty()); + } + + #[test] + fn package_groups_preserve_dependency_edges_and_independent_roots() { + let jobs = [ + package("root-a", vec![]), + package("root-b", vec![]), + package("middle", vec![0]), + package("leaf", vec![1, 2]), + ]; + + let groups = package_groups(&jobs); + + assert_eq!(groups.len(), 4); + assert!(groups[0].dependencies.is_empty()); + assert!(groups[1].dependencies.is_empty()); + assert_eq!(groups[2].dependencies, vec![0]); + assert_eq!(groups[3].dependencies, vec![1, 2]); + assert_eq!(groups[0].dependents, vec![2]); + assert_eq!(groups[1].dependents, vec![3]); + assert_eq!(groups[2].dependents, vec![3]); + } + + #[test] + fn package_cycles_are_scheduled_as_one_group() { + let jobs = [ + package("root", vec![]), + package("cycle-a", vec![0, 2]), + package("cycle-b", vec![1]), + package("downstream", vec![2]), + ]; + + let groups = package_groups(&jobs); + + assert_eq!(groups.len(), 3); + assert_eq!(groups[0].packages, vec![0]); + assert_eq!(groups[1].packages, vec![1, 2]); + assert_eq!(groups[2].packages, vec![3]); + assert_eq!(groups[1].dependencies, vec![0]); + assert_eq!(groups[2].dependencies, vec![1]); + assert_eq!(groups[0].dependents, vec![1]); + assert_eq!(groups[1].dependents, vec![2]); + } + + #[test] + fn package_downstream_of_cycle_waits_for_the_cycle() { + let jobs = vec![ + package("root", vec![]), + package("cycle-a", vec![0, 2]), + package("cycle-b", vec![1]), + package("downstream", vec![2]), + ]; + let gate = Arc::new((Mutex::new(false), Condvar::new())); + let worker_gate = Arc::clone(&gate); + let (started, starts) = mpsc::channel(); + let worker = thread::spawn(move || { + let pool = rayon::ThreadPoolBuilder::new().num_threads(2).build().unwrap(); + let progress = progress::packages(false, false); + pool.install(|| { + schedule_package_jobs(&jobs, &progress, &|package| { + started.send(String::clone(&package.name)).unwrap(); + if package.name == "cycle-b" { + let (lock, condition) = &*worker_gate; + let open = lock.lock().unwrap(); + drop(condition.wait_while(open, |open| !*open).unwrap()); + } + Ok(()) + }) + }) + }); + + assert_eq!(starts.recv_timeout(Duration::from_secs(1)).unwrap(), "root"); + let cycle = [ + starts.recv_timeout(Duration::from_secs(1)).unwrap(), + starts.recv_timeout(Duration::from_secs(1)).unwrap(), + ]; + assert_eq!( + cycle.into_iter().collect::>(), + BTreeSet::from(["cycle-a".to_owned(), "cycle-b".to_owned(),]) + ); + assert!(starts.recv_timeout(Duration::from_millis(100)).is_err()); + let (lock, condition) = &*gate; + *lock.lock().unwrap() = true; + condition.notify_all(); + + assert_eq!(starts.recv_timeout(Duration::from_secs(1)).unwrap(), "downstream"); + worker.join().unwrap().unwrap(); + } + + #[test] + fn independent_packages_start_concurrently() { + let jobs = vec![package("root-a", vec![]), package("root-b", vec![])]; + let gate = Arc::new((Mutex::new(false), Condvar::new())); + let worker_gate = Arc::clone(&gate); + let (started, starts) = mpsc::channel(); + let worker = thread::spawn(move || { + let pool = rayon::ThreadPoolBuilder::new().num_threads(2).build().unwrap(); + let progress = progress::packages(false, false); + pool.install(|| { + schedule_package_jobs(&jobs, &progress, &|package| { + started.send(String::clone(&package.name)).unwrap(); + let (lock, condition) = &*worker_gate; + let open = lock.lock().unwrap(); + drop(condition.wait_while(open, |open| !*open).unwrap()); + Ok(()) + }) + }) + }); + + let first = starts.recv_timeout(Duration::from_secs(1)).unwrap(); + let second = starts.recv_timeout(Duration::from_secs(1)); + let (lock, condition) = &*gate; + *lock.lock().unwrap() = true; + condition.notify_all(); + + assert!(second.is_ok(), "only {first} started before independent work was released"); + worker.join().unwrap().unwrap(); + } + + #[test] + fn dependent_package_waits_for_its_prerequisite() { + let jobs = vec![package("root", vec![]), package("dependent", vec![0])]; + let gate = Arc::new((Mutex::new(false), Condvar::new())); + let worker_gate = Arc::clone(&gate); + let (started, starts) = mpsc::channel(); + let worker = thread::spawn(move || { + let pool = rayon::ThreadPoolBuilder::new().num_threads(2).build().unwrap(); + let progress = progress::packages(false, false); + pool.install(|| { + schedule_package_jobs(&jobs, &progress, &|package| { + started.send(String::clone(&package.name)).unwrap(); + if package.name == "root" { + let (lock, condition) = &*worker_gate; + let open = lock.lock().unwrap(); + drop(condition.wait_while(open, |open| !*open).unwrap()); + } + Ok(()) + }) + }) + }); + + assert_eq!(starts.recv_timeout(Duration::from_secs(1)).unwrap(), "root"); + assert!(starts.recv_timeout(Duration::from_millis(100)).is_err()); + let (lock, condition) = &*gate; + *lock.lock().unwrap() = true; + condition.notify_all(); + + assert_eq!(starts.recv_timeout(Duration::from_secs(1)).unwrap(), "dependent"); + worker.join().unwrap().unwrap(); + } + + #[test] + fn packages_are_scheduled_exactly_once() { + let jobs = vec![ + package("root-a", vec![]), + package("root-b", vec![]), + package("fan-in", vec![0, 1]), + package("leaf", vec![2]), + ]; + let executions = Mutex::new(HashMap::new()); + let progress = progress::packages(false, false); + + schedule_package_jobs(&jobs, &progress, &|package| { + let mut executions = executions.lock().unwrap(); + *executions.entry(String::clone(&package.name)).or_insert(0) += 1; + Ok(()) + }) + .unwrap(); + + let executions = executions.into_inner().unwrap(); + assert_eq!(executions.len(), jobs.len()); + assert!(executions.values().all(|&count| count == 1)); + } +} diff --git a/compiler-bin/src/project.rs b/compiler-bin/src/project.rs index fa18d617b..acc739cb0 100644 --- a/compiler-bin/src/project.rs +++ b/compiler-bin/src/project.rs @@ -2,6 +2,7 @@ use std::path::{Path, PathBuf}; use std::process::{self, Command, ExitStatus}; use std::{env, fs, io}; +use itertools::Itertools; use thiserror::Error; use url::Url; @@ -23,6 +24,8 @@ pub enum ProjectError { #[error(transparent)] Spago(#[from] SpagoError), #[error(transparent)] + SpagoLock(#[from] spago::LockfileGlobSetError), + #[error(transparent)] Watch(#[from] WatchError), #[error(transparent)] Workspace(#[from] WorkspaceError), @@ -232,10 +235,18 @@ fn compile_workspace( resilience: Resilience, ) -> Result<(), ProjectError> { let (sources, output) = prepare_workspace_build(workspace, current_directory, config)?; - compile::compile_inputs( + let packages = spago::source_files_by_package(&workspace.root)?; + let packages = packages.into_iter().map(|(name, package)| compile::PackageInput { + name: name.to_string(), + sources: package.sources, + dependencies: package.dependencies.into_iter().map(|name| name.to_string()).collect_vec(), + }); + let packages = packages.collect_vec(); + compile::compile_package_inputs( &workspace.root, &output, &sources, + packages, config.quiet, config.color, resilience, diff --git a/tests-e2e/tests/package_manager/build.rs b/tests-e2e/tests/package_manager/build.rs index 544f89b30..292a50e4c 100644 --- a/tests-e2e/tests/package_manager/build.rs +++ b/tests-e2e/tests/package_manager/build.rs @@ -186,10 +186,13 @@ fn builds_the_whole_workspace_from_a_root_package_subdirectory() { r#"workspace: {} package: name: application - dependencies: [] + dependencies: [library] "#, ); - workspace.write("src/Application.purs", "module Application where\n"); + workspace.write( + "src/Application.purs", + "module Application where\n\nimport Library (library)\n\napplication = library\n", + ); workspace.write( "packages/library/spago.yaml", r#"package: @@ -197,7 +200,7 @@ package: dependencies: [] "#, ); - workspace.write("packages/library/src/Library.purs", "module Library where\n"); + workspace.write("packages/library/src/Library.purs", "module Library where\n\nlibrary = 42\n"); let output = workspace.command_in("src", &["build", "--quiet"]); assert_success(&output); From fc7d01d58f7ffe9073a0fc97b5f9aba18ef61791 Mon Sep 17 00:00:00 2001 From: Justin Garcia Date: Sat, 12 Sep 2026 11:10:10 +0000 Subject: [PATCH 04/26] Allow project builds to suppress diagnostics Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-01a09469-c4cf-723d-9923-3b8452bea9f2 --- compiler-bin/src/cli.rs | 8 +- compiler-bin/src/compile.rs | 57 +++++++----- compiler-bin/src/lib.rs | 5 +- compiler-bin/src/project.rs | 10 ++- compiler-bin/src/watch.rs | 1 + compiler-lsp/spago/src/command.rs | 26 ++++-- tests-e2e/tests/package_manager/build.rs | 89 +++++++++++++++---- ...r__build__backend_failure_diagnostics.snap | 5 +- ...silient_initializer_cycle_diagnostics.snap | 5 +- ...__build__resilient_source_diagnostics.snap | 5 +- ..._strict_initializer_cycle_diagnostics.snap | 5 +- ...__build__suppressed_error_diagnostics.snap | 6 ++ ...build__suppressed_warning_diagnostics.snap | 6 ++ .../tests/snapshots/cli__help_build.snap | 1 + 14 files changed, 172 insertions(+), 57 deletions(-) create mode 100644 tests-e2e/tests/package_manager/snapshots/package_manager__build__suppressed_error_diagnostics.snap create mode 100644 tests-e2e/tests/package_manager/snapshots/package_manager__build__suppressed_warning_diagnostics.snap diff --git a/compiler-bin/src/cli.rs b/compiler-bin/src/cli.rs index e88b5604f..6333e79f0 100644 --- a/compiler-bin/src/cli.rs +++ b/compiler-bin/src/cli.rs @@ -274,6 +274,10 @@ pub struct ProjectBuildCommandOptions { /// Write JavaScript output even when compilation reports errors. #[usage(long)] pub resilient: bool, + + /// Suppress compiler warnings and errors without hiding build progress. + #[usage(long)] + pub no_diagnostics: bool, } #[derive(Debug, Args)] @@ -521,10 +525,12 @@ mod tests { #[test] fn build_accepts_resilient_output() { - let options = build(&["--resilient"]); + let options = build(&["--resilient", "--no-diagnostics"]); assert!(options.resilient); + assert!(options.no_diagnostics); assert!(!build(&[]).resilient); + assert!(!build(&[]).no_diagnostics); } #[test] diff --git a/compiler-bin/src/compile.rs b/compiler-bin/src/compile.rs index 235ae21e5..09382550c 100644 --- a/compiler-bin/src/compile.rs +++ b/compiler-bin/src/compile.rs @@ -35,6 +35,7 @@ pub(crate) struct BuildConfig<'a> { pub current_directory: &'a Path, pub color: bool, pub progress: bool, + pub diagnostics: bool, pub resilience: Resilience, } @@ -71,7 +72,7 @@ pub(crate) enum BuildOutcome { #[derive(Debug, Error)] pub(crate) enum CompileError { #[error("compilation failed")] - Diagnostics, + Diagnostics { reported: bool }, #[error("failed to convert path to a file URL: {0}")] InvalidPath(PathBuf), #[error(transparent)] @@ -91,7 +92,7 @@ pub(crate) enum CompileError { pub fn start(config: CompileConfig) { let json_errors = config.json_errors; if let Err(error) = compile(config) { - if !matches!(error, CompileError::Diagnostics) { + if !matches!(error, CompileError::Diagnostics { .. }) { eprintln!("Compilation exited: {error}"); } tracing::error!(?error, "Compilation exited"); @@ -125,6 +126,7 @@ fn compile(config: CompileConfig) -> Result<(), CompileError> { current_directory: ¤t_directory, color: use_color(config.color), progress: !config.quiet, + diagnostics: true, resilience: Resilience::Strict, }; compile_source_paths(&build_config, source_paths, started, preparation_progress) @@ -137,6 +139,7 @@ pub(crate) fn compile_package_inputs( packages: Vec, quiet: bool, color: ColorChoice, + diagnostics: bool, resilience: Resilience, ) -> Result<(), CompileError> { let started = Instant::now(); @@ -156,8 +159,14 @@ pub(crate) fn compile_package_inputs( } }; - let build_config = - BuildConfig { output, current_directory: root, color, progress: !quiet, resilience }; + let build_config = BuildConfig { + output, + current_directory: root, + color, + progress: !quiet, + diagnostics, + resilience, + }; compile_package_source_paths(&build_config, source_paths, packages, started, &package_progress) } @@ -181,7 +190,7 @@ fn compile_source_paths( } if matches!(build(&compilation, config)?, BuildOutcome::Diagnostics) { - return Err(CompileError::Diagnostics); + return Err(CompileError::Diagnostics { reported: true }); } if config.progress { @@ -290,6 +299,7 @@ fn compile_package_source_paths( current_directory: config.current_directory, color: config.color, progress: false, + diagnostics: config.diagnostics, resilience: config.resilience, }; package_progress.finish(started.elapsed()); @@ -298,7 +308,7 @@ fn compile_package_source_paths( finalize_build(&compilation, &source_ids, &finalization_config)?, BuildOutcome::Diagnostics ) { - return Err(CompileError::Diagnostics); + return Err(CompileError::Diagnostics { reported: config.diagnostics }); } Ok(()) } @@ -582,29 +592,32 @@ fn report_diagnostics( .any(|diagnostic| diagnostic.severity == Severity::Error); let has_diagnostics = diagnostics.iter().any(|collected| !collected.diagnostics().is_empty()); - if has_diagnostics { + if config.diagnostics && 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; + if config.diagnostics { + 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}"); } - 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) } diff --git a/compiler-bin/src/lib.rs b/compiler-bin/src/lib.rs index 3b94d01e7..091cc3bd4 100644 --- a/compiler-bin/src/lib.rs +++ b/compiler-bin/src/lib.rs @@ -50,7 +50,9 @@ pub fn run() { } else { compile::Resilience::Strict }; - project::start(project::build(project_build_config(options.build), resilience)); + let mut config = project_build_config(options.build); + config.diagnostics = !options.no_diagnostics; + project::start(project::build(config, resilience)); } cli::Command::Add(options) => { project::start(project::add(project::AddProjectConfig { @@ -134,5 +136,6 @@ fn project_build_config(options: cli::ProjectBuildOptions) -> project::BuildProj output: options.output, quiet: options.quiet, color: options.color, + diagnostics: true, } } diff --git a/compiler-bin/src/project.rs b/compiler-bin/src/project.rs index acc739cb0..dd87de66e 100644 --- a/compiler-bin/src/project.rs +++ b/compiler-bin/src/project.rs @@ -68,6 +68,7 @@ pub struct BuildProjectConfig { pub output: Option, pub quiet: bool, pub color: ColorChoice, + pub diagnostics: bool, } pub struct AddProjectConfig { @@ -90,7 +91,9 @@ pub struct TestProjectConfig { pub fn start(result: Result<(), ProjectError>) { if let Err(error) = result { - eprintln!("{error}"); + if !matches!(&error, ProjectError::Compile(CompileError::Diagnostics { reported: false })) { + eprintln!("{error}"); + } let exit_code = match error { ProjectError::NodeFailed(status) => status.code().unwrap_or(1), _ => 1, @@ -249,6 +252,7 @@ fn compile_workspace( packages, config.quiet, config.color, + config.diagnostics, resilience, )?; Ok(()) @@ -260,8 +264,8 @@ fn prepare_workspace_build( 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())?; + spago.fetch(workspace.selected.as_deref(), !config.quiet)?; + let sources = spago.source_globs(workspace.selected.as_deref(), !config.quiet)?; let output = output(workspace, config); Ok((sources, output)) } diff --git a/compiler-bin/src/watch.rs b/compiler-bin/src/watch.rs index 6c2f8e891..0020a0d04 100644 --- a/compiler-bin/src/watch.rs +++ b/compiler-bin/src/watch.rs @@ -111,6 +111,7 @@ fn rebuild(workspace: &mut WatchWorkspace, config: &WatchConfig) -> Result<(), C current_directory: &workspace.current_directory, color: compile::use_color(config.color), progress: false, + diagnostics: true, resilience: Resilience::Strict, }; match compile::build(&workspace.compilation, &build_config)? { 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/tests-e2e/tests/package_manager/build.rs b/tests-e2e/tests/package_manager/build.rs index 292a50e4c..6f5d1144e 100644 --- a/tests-e2e/tests/package_manager/build.rs +++ b/tests-e2e/tests/package_manager/build.rs @@ -1,5 +1,19 @@ +use std::process::Output; + use super::support::{TestWorkspace, assert_success}; +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}") + ); + }); +} + fn diagnostic_settings(workspace: &TestWorkspace) -> insta::Settings { let mut settings = insta::Settings::clone_current(); settings.set_strip_ansi_escape_codes(true); @@ -76,15 +90,8 @@ broken = missing ); 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); + snapshot_output("resilient_source_diagnostics", &output); let generated = workspace.read("output/Main/index.js"); assert!(generated.contains("Generated code reached a source error")); @@ -96,6 +103,60 @@ broken = missing ); } +#[test] +fn suppresses_error_diagnostics_without_changing_failure_status() { + let workspace = TestWorkspace::empty(); + workspace.write( + "spago.yaml", + r#"workspace: {} +package: + name: application + dependencies: [] +"#, + ); + workspace.write( + "src/Main.purs", + r#"module Main where + +broken = missing +"#, + ); + + let output = workspace.command(&["build", "--quiet", "--no-diagnostics"]); + + snapshot_output("suppressed_error_diagnostics", &output); +} + +#[test] +fn suppresses_warning_diagnostics_without_changing_success_status() { + let workspace = TestWorkspace::empty(); + workspace.write( + "spago.yaml", + r#"workspace: {} +package: + name: application + dependencies: [] +"#, + ); + workspace.write( + "src/Main.purs", + r#"module Main where + +data Choice = Present Int | Absent + +value (Present number) = number +"#, + ); + + let reported = workspace.command(&["build", "--quiet"]); + assert_success(&reported); + let reported_stderr = String::from_utf8_lossy(&reported.stderr); + assert!(reported_stderr.contains("Warning! · [MissingPatterns]")); + + let output = workspace.command(&["build", "--quiet", "--no-diagnostics"]); + snapshot_output("suppressed_warning_diagnostics", &output); +} + #[test] fn gates_initializer_cycle_output_on_resilience() { let workspace = TestWorkspace::empty(); @@ -120,16 +181,12 @@ 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); + snapshot_output("strict_initializer_cycle_diagnostics", &strict); 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); + snapshot_output("resilient_initializer_cycle_diagnostics", &resilient); let generated = workspace.read("output/Main/index.js"); assert!(generated.contains("Top-level value initializer cycle")); assert!(!generated.contains("@__PURE__")); @@ -167,10 +224,8 @@ partialProps = props ); 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); + snapshot_output("backend_failure_diagnostics", &output); assert!(!workspace.path().join("output/Main/index.js").exists()); workspace.assert_spago_calls( "", 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 index 6bb46435b..c63671f61 100644 --- 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 @@ -1,7 +1,10 @@ --- source: tests-e2e/tests/package_manager/build.rs -expression: stderr --- +status: 1 +--- stdout +--- stderr + Error! · [FunctionalCodegen] · src/Main.purs:5:1 • │ 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 index 95323ade0..f4a8428db 100644 --- 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 @@ -1,7 +1,10 @@ --- source: tests-e2e/tests/package_manager/build.rs -expression: stderr --- +status: 1 +--- stdout +--- stderr + Error! · [JavaScriptInitializerCycle] · src/Main.purs:6:1 • │ 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 index 562f63cb4..60faf7b87 100644 --- 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 @@ -1,7 +1,10 @@ --- source: tests-e2e/tests/package_manager/build.rs -expression: stderr --- +status: 1 +--- stdout +--- stderr + Error! · [NotInScope] · src/Main.purs:5:10 • │ 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 index 95323ade0..f4a8428db 100644 --- 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 @@ -1,7 +1,10 @@ --- source: tests-e2e/tests/package_manager/build.rs -expression: stderr --- +status: 1 +--- stdout +--- stderr + Error! · [JavaScriptInitializerCycle] · src/Main.purs:6:1 • │ 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__build__suppressed_warning_diagnostics.snap b/tests-e2e/tests/package_manager/snapshots/package_manager__build__suppressed_warning_diagnostics.snap new file mode 100644 index 000000000..031c32610 --- /dev/null +++ b/tests-e2e/tests/package_manager/snapshots/package_manager__build__suppressed_warning_diagnostics.snap @@ -0,0 +1,6 @@ +--- +source: tests-e2e/tests/package_manager/build.rs +--- +status: 0 +--- stdout +--- stderr diff --git a/tests-e2e/tests/snapshots/cli__help_build.snap b/tests-e2e/tests/snapshots/cli__help_build.snap index d10b21274..9097d54c9 100644 --- a/tests-e2e/tests/snapshots/cli__help_build.snap +++ b/tests-e2e/tests/snapshots/cli__help_build.snap @@ -21,5 +21,6 @@ Flags: [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 From d54515c0b23c692ba6788cdc4a4fd7aa418c9ea6 Mon Sep 17 00:00:00 2001 From: Justin Garcia Date: Sat, 12 Sep 2026 14:37:07 +0000 Subject: [PATCH 05/26] Add reusable package progress reporting Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-01a09469-c4cf-723d-9923-3b8452bea9f2 --- Cargo.lock | 10 + Cargo.toml | 1 + compiler-progress/Cargo.toml | 14 + compiler-progress/src/lib.rs | 842 +++++++++++++++++++++++++++++++++++ 4 files changed, 867 insertions(+) create mode 100644 compiler-progress/Cargo.toml create mode 100644 compiler-progress/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 5dbffd234..03fc3a72d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -559,6 +559,16 @@ dependencies = [ "zmij", ] +[[package]] +name = "compiler-progress" +version = "0.1.0" +dependencies = [ + "console", + "itertools 0.15.0", + "ratatui", + "terminal-colorsaurus", +] + [[package]] name = "compiler-scripts" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index bd7475658..462fbe5d0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "compiler-core/*", "compiler-frontend/*", "compiler-lsp/*", + "compiler-progress", "compiler-scripts", "tests-compatibility", "tests-e2e", diff --git a/compiler-progress/Cargo.toml b/compiler-progress/Cargo.toml new file mode 100644 index 000000000..726575a0d --- /dev/null +++ b/compiler-progress/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "compiler-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"] } +terminal-colorsaurus = "1.0.3" diff --git a/compiler-progress/src/lib.rs b/compiler-progress/src/lib.rs new file mode 100644 index 000000000..eeb719017 --- /dev/null +++ b/compiler-progress/src/lib.rs @@ -0,0 +1,842 @@ +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 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; + +type ProgressTerminal = Terminal>; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ProgressOutcome { + Succeeded, + Diagnostics, + Failed, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ProgressEvent { + Preparing, + PlanReady { package_count: usize }, + PackageCompleted { package_name: String, duration: Duration }, + Finalizing { duration: Duration }, + Finished { duration: Duration, outcome: ProgressOutcome }, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CompletedPackage { + pub package_name: String, + 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: (*name).to_owned(), + duration: Duration::from_millis(125), + }); + } + model + } + + 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: "diagnostic-package".to_owned(), + 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, + 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: package_name.to_owned(), + 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, + }); + } +} From 1b93b1bd4205f71bb53ed9d1daa7e23bf93d1aac Mon Sep 17 00:00:00 2001 From: Justin Garcia Date: Sat, 12 Sep 2026 14:37:17 +0000 Subject: [PATCH 06/26] Add experimental package-aware build command Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-01a09469-c4cf-723d-9923-3b8452bea9f2 --- Cargo.lock | 27 ++ Cargo.toml | 1 + compiler-bin-v2/Cargo.toml | 39 +++ compiler-bin-v2/build.rs | 22 ++ compiler-bin-v2/src/bin/iris.rs | 3 + compiler-bin-v2/src/build.rs | 9 + compiler-bin-v2/src/build/compilation.rs | 111 ++++++++ compiler-bin-v2/src/build/compile.rs | 258 +++++++++++++++++ compiler-bin-v2/src/build/events.rs | 80 ++++++ compiler-bin-v2/src/build/executor.rs | 245 ++++++++++++++++ compiler-bin-v2/src/build/plan.rs | 340 +++++++++++++++++++++++ compiler-bin-v2/src/build/project.rs | 89 ++++++ compiler-bin-v2/src/build/walk.rs | 217 +++++++++++++++ compiler-bin-v2/src/build/workspace.rs | 197 +++++++++++++ compiler-bin-v2/src/cli.rs | 140 ++++++++++ compiler-bin-v2/src/lib.rs | 17 ++ 16 files changed, 1795 insertions(+) create mode 100644 compiler-bin-v2/Cargo.toml create mode 100644 compiler-bin-v2/build.rs create mode 100644 compiler-bin-v2/src/bin/iris.rs create mode 100644 compiler-bin-v2/src/build.rs create mode 100644 compiler-bin-v2/src/build/compilation.rs create mode 100644 compiler-bin-v2/src/build/compile.rs create mode 100644 compiler-bin-v2/src/build/events.rs create mode 100644 compiler-bin-v2/src/build/executor.rs create mode 100644 compiler-bin-v2/src/build/plan.rs create mode 100644 compiler-bin-v2/src/build/project.rs create mode 100644 compiler-bin-v2/src/build/walk.rs create mode 100644 compiler-bin-v2/src/build/workspace.rs create mode 100644 compiler-bin-v2/src/cli.rs create mode 100644 compiler-bin-v2/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 03fc3a72d..e5f1cee13 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3010,6 +3010,33 @@ dependencies = [ "walkdir", ] +[[package]] +name = "purescript-iris-v2" +version = "0.1.0" +dependencies = [ + "building", + "compiler-progress", + "diagnostics", + "dunce", + "files", + "globset", + "ignore", + "itertools 0.15.0", + "javascript", + "line-index", + "path-absolutize", + "petgraph", + "prim-constants", + "rayon", + "serde", + "serde_yml", + "spago", + "thiserror", + "url", + "usage-rs", + "walkdir", +] + [[package]] name = "quinn" version = "0.11.11" diff --git a/Cargo.toml b/Cargo.toml index 462fbe5d0..1b6efdd4d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "compiler-bin", + "compiler-bin-v2", "compiler-backend/*", "compiler-core/*", "compiler-frontend/*", diff --git a/compiler-bin-v2/Cargo.toml b/compiler-bin-v2/Cargo.toml new file mode 100644 index 000000000..5d512bea9 --- /dev/null +++ b/compiler-bin-v2/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "purescript-iris-v2" +version = "0.1.0" +edition = "2024" +authors = ["Justin Garcia "] +license = "BSD-3-Clause" +description = "Experimental package-aware PureScript build command." +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-v2" +path = "src/bin/iris.rs" + +[dependencies] +building = { version = "0.1.0", path = "../compiler-core/building" } +compiler-progress = { version = "0.1.0", path = "../compiler-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" +spago = { version = "0.1.0", path = "../compiler-lsp/spago" } +thiserror = "2.0.20" +url = "2.5.8" +usage = { package = "usage-rs", version = "6.8.0" } +walkdir = "2.5.0" diff --git a/compiler-bin-v2/build.rs b/compiler-bin-v2/build.rs new file mode 100644 index 000000000..1be63a34e --- /dev/null +++ b/compiler-bin-v2/build.rs @@ -0,0 +1,22 @@ +use std::env; + +fn main() { + println!("cargo::rerun-if-env-changed=IRIS_BUILD_REVISION"); + + 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!( + (7..=64).contains(&revision.len()) + && revision.bytes().all(|byte| byte.is_ascii_hexdigit()), + "IRIS_BUILD_REVISION must contain 7 to 64 hexadecimal characters" + ); + format!("{package_version}-dev.{}", revision.to_ascii_lowercase()) + } + Err(env::VarError::NotPresent) => package_version, + Err(env::VarError::NotUnicode(_)) => panic!("IRIS_BUILD_REVISION must be Unicode"), + }; + + println!("cargo::rustc-env=IRIS_VERSION={version}"); +} diff --git a/compiler-bin-v2/src/bin/iris.rs b/compiler-bin-v2/src/bin/iris.rs new file mode 100644 index 000000000..1bb959bd4 --- /dev/null +++ b/compiler-bin-v2/src/bin/iris.rs @@ -0,0 +1,3 @@ +fn main() { + std::process::exit(purescript_iris_v2::run()); +} diff --git a/compiler-bin-v2/src/build.rs b/compiler-bin-v2/src/build.rs new file mode 100644 index 000000000..a0c58f486 --- /dev/null +++ b/compiler-bin-v2/src/build.rs @@ -0,0 +1,9 @@ +pub mod events; +pub mod executor; +pub mod plan; + +mod compilation; +mod compile; +pub(crate) mod project; +mod walk; +mod workspace; diff --git a/compiler-bin-v2/src/build/compilation.rs b/compiler-bin-v2/src/build/compilation.rs new file mode 100644 index 000000000..4b303cce7 --- /dev/null +++ b/compiler-bin-v2/src/build/compilation.rs @@ -0,0 +1,111 @@ +//! Compiler query state used by build execution. + +use std::sync::Arc; + +use building::{ + DiskObservation, FileLifecycle, ForeignEvent, LifecycleChange, LifecycleEvent, QueryEngine, + SourceEvent, SourceUnitKey, +}; +use files::{FileId, ForeignSourceKind}; +use itertools::Itertools; +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_sources(&self) -> Vec { + let sources = self + .files + .source_ids() + .filter(|&file_id| self.files.source_metadata(file_id) == Some(&SourceRole::Input)); + sources.collect_vec() + } + + 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_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) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn prim_modules_are_not_input_sources() { + let compilation = CompilationState::new(); + + assert!(compilation.input_sources().is_empty()); + assert!(compilation.snapshot().module_file("Prim").is_some()); + } +} diff --git a/compiler-bin-v2/src/build/compile.rs b/compiler-bin-v2/src/build/compile.rs new file mode 100644 index 000000000..69766a61f --- /dev/null +++ b/compiler-bin-v2/src/build/compile.rs @@ -0,0 +1,258 @@ +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; +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(crate) 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) fn build(config: BuildConfig<'_>) -> Result<(), CompileError> { + let BuildConfig { root, output, source_globs, packages, color, diagnostics, resilient, events } = + config; + let started = Instant::now(); + let selected_paths = walk::walk(&root, &source_globs)?.files; + let selected_sources = selected_paths.into_iter().map(|path| { + let identity = dunce::canonicalize(&path)?; + Ok(SelectedSource { path, identity }) + }); + let selected_sources = selected_sources.collect::, io::Error>>()?; + let package_inputs = packages.into_iter().map(|package| { + let source_identities = package.source_identities.into_iter().map(dunce::canonicalize); + let source_identities = source_identities.collect::, io::Error>>()?; + Ok(PackageInput { source_identities, ..package }) + }); + let package_inputs = package_inputs.collect::, io::Error>>()?; + let plan = BuildPlan::new(selected_sources, package_inputs)?; + if plan.is_empty() { + return Err(CompileError::NoInputs); + } + events.send(BuildEvent::PlanReady { package_count: plan.package_count() }); + + let mut compilation = CompilationState::new(); + let source_paths = plan.packages().flat_map(|package| package.source_paths.iter()).cloned(); + let source_paths = source_paths.collect::>(); + let mut sources = HashMap::new(); + for path in source_paths { + sources.insert(PathBuf::clone(&path), load_source(&mut compilation, &path)?); + } + + executor::execute_parallel(&plan, events, &|package| { + let package_sources = package.source_paths.iter().map(|path| sources[path]); + let package_sources = package_sources.collect_vec(); + query_package(&compilation, &package_sources) + })?; + + let duration = started.elapsed(); + events.send(BuildEvent::Finalizing { duration }); + let all_sources = compilation.input_sources(); + let (diagnostic_collections, has_errors) = collect_diagnostics(&compilation, &all_sources)?; + if !has_errors || resilient { + let modules = collect_modules(&compilation, &all_sources)?; + write_modules(&compilation, &modules, &output)?; + } + let outcome = if has_errors { BuildOutcome::Diagnostics } else { BuildOutcome::Succeeded }; + events.send(BuildEvent::Finished { duration: started.elapsed(), outcome }); + if diagnostics { + report_diagnostics(&compilation, diagnostic_collections, &root, color); + } + if has_errors { + return Err(CompileError::Diagnostics { reported: diagnostics }); + } + Ok(()) +} + +fn load_source(compilation: &mut CompilationState, path: &Path) -> Result { + 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())); + 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(compilation: &CompilationState, sources: &[FileId]) -> Result<(), CompileError> { + sources.par_iter().try_for_each(|&file_id| { + let engine = compilation.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> { + 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).display().to_string() + } 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, +) -> Result<(), CompileError> { + if modules.iter().any(|module| module.requires_runtime()) { + fs::create_dir_all(output)?; + write_if_changed( + &output.join(javascript::runtime_filename()), + javascript::runtime_source().as_bytes(), + )?; + } + modules.par_iter().try_for_each(|module| write_module(compilation, module, output)) +} + +fn write_module( + compilation: &CompilationState, + module: &javascript::Module, + output: &Path, +) -> Result<(), CompileError> { + let output_path = output.join(module.filename()); + fs::create_dir_all( + output_path.parent().expect("invariant violated: module filename has no parent"), + )?; + write_if_changed(&output_path, module.source().as_bytes())?; + 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())?; + } + 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-v2/src/build/events.rs b/compiler-bin-v2/src/build/events.rs new file mode 100644 index 000000000..dde9f6ce1 --- /dev/null +++ b/compiler-bin-v2/src/build/events.rs @@ -0,0 +1,80 @@ +//! Typed notifications emitted by build execution. + +use std::time::Duration; + +use compiler_progress::{ProgressEvent, ProgressOutcome, ProgressReporter}; + +use std::sync::Mutex; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BuildOutcome { + Succeeded, + Diagnostics, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum BuildEvent { + Preparing, + PlanReady { package_count: usize }, + PackageCompleted { package_name: String, 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 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, + }, + }, + }; + 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-v2/src/build/executor.rs b/compiler-bin-v2/src/build/executor.rs new file mode 100644 index 000000000..e7556dc53 --- /dev/null +++ b/compiler-bin-v2/src/build/executor.rs @@ -0,0 +1,245 @@ +use std::sync::Mutex; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::time::Instant; + +use itertools::Itertools; +use rayon::prelude::*; + +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: String::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::super::events::{BuildEvent, RecordedBuildEvents}; + use super::super::plan::{PackageInput, SelectedSource}; + use super::*; + + 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: "root".to_owned(), + source_identities: vec![PathBuf::from("root")], + dependencies: vec![], + }, + PackageInput { + name: "independent".to_owned(), + source_identities: vec![PathBuf::from("independent")], + dependencies: vec![], + }, + PackageInput { + name: "dependent".to_owned(), + source_identities: vec![PathBuf::from("dependent")], + dependencies: vec!["root".to_owned(), "independent".to_owned()], + }, + ]; + 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(String::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(["root".to_owned(), "independent".to_owned(), "dependent".to_owned()]) + ); + 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(String::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-v2/src/build/plan.rs b/compiler-bin-v2/src/build/plan.rs new file mode 100644 index 000000000..da18810b7 --- /dev/null +++ b/compiler-bin-v2/src/build/plan.rs @@ -0,0 +1,340 @@ +//! 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 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: String, + pub source_identities: Vec, + pub dependencies: Vec, +} + +#[derive(Debug)] +pub struct PlannedPackage { + pub name: String, + 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(String), + #[error("source {path} belongs to both Spago packages {first_package} and {second_package}")] + ConflictingPackageSource { path: PathBuf, first_package: String, second_package: String }, + #[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(String::clone(&package.name)) { + return Err(BuildPlanError::DuplicatePackage(String::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: String::clone(first_package), + second_package: String::clone(&package.name), + }); + } + } else { + owners.insert(PathBuf::clone(source_identity), String::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(String::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); + (String::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(String::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(dependency.to_owned()); + } else if let Some(package) = packages_by_name.get(dependency) { + pending.extend(package.dependencies.iter().rev().map(String::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| (*dependency).to_owned()); + PackageInput { + name: name.to_owned(), + 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("duplicate".to_owned())); + } + + #[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-v2/src/build/project.rs b/compiler-bin-v2/src/build/project.rs new file mode 100644 index 000000000..dce124748 --- /dev/null +++ b/compiler-bin-v2/src/build/project.rs @@ -0,0 +1,89 @@ +use std::io::IsTerminal; +use std::path::PathBuf; +use std::{env, io}; + +use compiler_progress::ProgressRuntime; +use itertools::Itertools; +use thiserror::Error; + +use super::compile::{self, CompileError}; +use super::events::{BuildEvent, BuildEventSink, ProgressEventSink}; +use super::plan::PackageInput; +use super::workspace::{Workspace, WorkspaceError}; +use crate::cli::ColorChoice; + +pub(crate) struct BuildConfig { + pub package: Option, + pub output: Option, + pub quiet: bool, + pub color: ColorChoice, + pub resilient: bool, + pub diagnostics: bool, +} + +#[derive(Debug, Error)] +pub(crate) enum ProjectError { + #[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), +} + +impl ProjectError { + pub fn diagnostics_were_suppressed(&self) -> bool { + matches!(self, ProjectError::Compile(CompileError::Diagnostics { reported: false })) + } +} + +pub(crate) fn build(config: BuildConfig) -> Result<(), ProjectError> { + let color = use_color(config.color); + let current_directory = env::current_dir().map_err(ProjectError::CurrentDirectory)?; + let workspace = Workspace::discover(¤t_directory, config.package.as_deref())?; + let spago = spago::SpagoCommand::new(¤t_directory)?; + spago.fetch(workspace.selected.as_deref(), !config.quiet)?; + let source_globs = spago.source_globs(workspace.selected.as_deref(), !config.quiet)?; + let package_sources = spago::source_files_by_package(&workspace.root)?; + + let progress = ProgressRuntime::start(!config.quiet, color); + let events = ProgressEventSink::new(progress.reporter()); + events.send(BuildEvent::Preparing); + let packages = package_sources.into_iter().map(|(name, package)| { + let dependencies = package.dependencies.into_iter().map(|name| name.to_string()); + PackageInput { + name: name.to_string(), + source_identities: package.sources, + dependencies: dependencies.collect_vec(), + } + }); + let packages = packages.collect_vec(); + let output = config.output.unwrap_or_else(|| workspace.root.join("output")); + + compile::build(compile::BuildConfig { + root: workspace.root, + output, + source_globs, + packages, + color, + diagnostics: config.diagnostics, + resilient: config.resilient, + events: &events, + })?; + Ok(()) +} + +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, + } +} diff --git a/compiler-bin-v2/src/build/walk.rs b/compiler-bin-v2/src/build/walk.rs new file mode 100644 index 000000000..b86afb11e --- /dev/null +++ b/compiler-bin-v2/src/build/walk.rs @@ -0,0 +1,217 @@ +//! 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 files: Vec, +} + +#[derive(Debug, Error)] +pub enum Error { + #[error(transparent)] + GlobSetError(#[from] globset::Error), + #[error(transparent)] + WalkDirError(#[from] walkdir::Error), +} + +pub fn walk(root: &Path, paths: impl IntoIterator>) -> Result { + walk_filtered(root, paths, std::iter::empty::<&Path>()) +} + +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; + } + + for entry in WalkDir::new(root) { + let path = entry?.into_path(); + if globs.is_match(&path) && !excludes.is_match(&path) { + files_from_glob.insert(path); + } + } + } + + files.extend(files_from_glob); + + Ok(Walk { 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 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-v2/src/build/workspace.rs b/compiler-bin-v2/src/build/workspace.rs new file mode 100644 index 000000000..a8b3d17f7 --- /dev/null +++ b/compiler-bin-v2/src/build/workspace.rs @@ -0,0 +1,197 @@ +//! 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; + +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("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(Debug, Clone, Deserialize)] +pub struct Manifest { + pub workspace: Option, + pub package: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PackageManifest { + pub name: String, +} + +#[derive(Debug, Clone)] +pub struct WorkspacePackage { + pub root: PathBuf, +} + +#[derive(Debug)] +pub struct Workspace { + pub root: PathBuf, + pub selected: Option, +} + +impl Workspace { + 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 { root, selected }) + } +} + +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(|entry| { + 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())) + }); + let mut manifests = manifests.collect::, _>>()?; + 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 = String::clone(&package.name); + let workspace_package = WorkspacePackage { root: package_root.to_path_buf() }; + if let Some(previous) = packages.insert(String::clone(&name), workspace_package) { + return Err(WorkspaceError::DuplicatePackage { + name, + first: previous.root, + second: package_root.to_path_buf(), + }); + } + } + Ok(packages) +} + +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-v2/src/cli.rs b/compiler-bin-v2/src/cli.rs new file mode 100644 index 000000000..a19dfeac6 --- /dev/null +++ b/compiler-bin-v2/src/cli.rs @@ -0,0 +1,140 @@ +use std::ffi::OsStr; +use std::path::PathBuf; + +use itertools::Itertools; +use usage::{Args, Subcommands, ValueEnum}; + +use crate::build::project::BuildConfig; + +#[derive(Debug, usage::Cli)] +#[usage( + bin = "iris-v2", + 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 { + /// Build a Spago workspace or package. + Build(BuildOptions), +} + +#[derive(Debug, Args)] +#[usage(args_override_self = false)] +pub struct BuildOptions { + /// 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, + + /// 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(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +pub enum ColorChoice { + Auto, + Always, + Never, +} + +impl Command { + pub fn into_config(self) -> BuildConfig { + let Command::Build(options) = self; + BuildConfig { + package: options.package, + output: options.output, + quiet: options.quiet, + color: options.color, + resilient: options.resilient, + diagnostics: !options.no_diagnostics, + } + } +} + +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-v2 {}", 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 exposes_only_the_build_command() { + let arguments = ["iris-v2", "build", "--quiet", "--no-diagnostics", "--resilient"]; + let arguments = arguments.iter().map(OsStr::new).collect_vec(); + let program = Program::try_parse_from(&arguments).unwrap(); + let config = program.command.into_config(); + + assert!(config.quiet); + assert!(!config.diagnostics); + assert!(config.resilient); + } + + #[test] + fn rejects_other_commands() { + let arguments = [OsStr::new("iris-v2"), OsStr::new("lsp")]; + assert!(Program::try_parse_from(&arguments).is_err()); + } +} diff --git a/compiler-bin-v2/src/lib.rs b/compiler-bin-v2/src/lib.rs new file mode 100644 index 000000000..5cdd174f6 --- /dev/null +++ b/compiler-bin-v2/src/lib.rs @@ -0,0 +1,17 @@ +pub mod build; +mod cli; + +pub(crate) const VERSION: &str = env!("IRIS_VERSION"); + +pub fn run() -> i32 { + let program = cli::Program::parse_with_diagnostics(); + match build::project::build(program.command.into_config()) { + Ok(()) => 0, + Err(error) => { + if !error.diagnostics_were_suppressed() { + eprintln!("{error}"); + } + 1 + } + } +} From 390580cd6062e9428378e09af0f9fbb518ea2253 Mon Sep 17 00:00:00 2001 From: Justin Garcia Date: Sat, 12 Sep 2026 14:37:48 +0000 Subject: [PATCH 07/26] Test the experimental package-aware build command Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-01a09469-c4cf-723d-9923-3b8452bea9f2 --- Cargo.lock | 1 + tests-e2e/Cargo.toml | 5 + tests-e2e/src/bin/iris-v2-e2e.rs | 3 + tests-e2e/tests/package_manager.rs | 2 + tests-e2e/tests/package_manager/build_v2.rs | 116 ++++++++++++++++++ ...d_v2__v2_suppressed_error_diagnostics.snap | 6 + tests-e2e/tests/support.rs | 21 +++- 7 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 tests-e2e/src/bin/iris-v2-e2e.rs create mode 100644 tests-e2e/tests/package_manager/build_v2.rs create mode 100644 tests-e2e/tests/package_manager/snapshots/package_manager__build_v2__v2_suppressed_error_diagnostics.snap diff --git a/Cargo.lock b/Cargo.lock index e5f1cee13..2eddf55f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4132,6 +4132,7 @@ dependencies = [ "itertools 0.15.0", "lsp-types", "purescript-iris", + "purescript-iris-v2", "regex", "serde_json", "tempfile", diff --git a/tests-e2e/Cargo.toml b/tests-e2e/Cargo.toml index 65e543119..3b2524af3 100644 --- a/tests-e2e/Cargo.toml +++ b/tests-e2e/Cargo.toml @@ -7,6 +7,7 @@ autotests = false [dependencies] purescript-iris = { path = "../compiler-bin" } +purescript-iris-v2 = { path = "../compiler-bin-v2" } [dev-dependencies] async-lsp = { version = "0.2.4", features = ["tokio"] } @@ -24,6 +25,10 @@ url = "2.5.8" name = "iris-e2e" path = "src/bin/iris-e2e.rs" +[[bin]] +name = "iris-v2-e2e" +path = "src/bin/iris-v2-e2e.rs" + [[bin]] name = "spago-e2e" path = "src/bin/spago-e2e.rs" diff --git a/tests-e2e/src/bin/iris-v2-e2e.rs b/tests-e2e/src/bin/iris-v2-e2e.rs new file mode 100644 index 000000000..1bb959bd4 --- /dev/null +++ b/tests-e2e/src/bin/iris-v2-e2e.rs @@ -0,0 +1,3 @@ +fn main() { + std::process::exit(purescript_iris_v2::run()); +} diff --git a/tests-e2e/tests/package_manager.rs b/tests-e2e/tests/package_manager.rs index 33b387005..4405d9ce8 100644 --- a/tests-e2e/tests/package_manager.rs +++ b/tests-e2e/tests/package_manager.rs @@ -2,6 +2,8 @@ mod add; #[path = "package_manager/build.rs"] mod build; +#[path = "package_manager/build_v2.rs"] +mod build_v2; #[path = "package_manager/new.rs"] mod new; #[path = "package_manager/run.rs"] diff --git a/tests-e2e/tests/package_manager/build_v2.rs b/tests-e2e/tests/package_manager/build_v2.rs new file mode 100644 index 000000000..00e9927a8 --- /dev/null +++ b/tests-e2e/tests/package_manager/build_v2.rs @@ -0,0 +1,116 @@ +use std::process::Output; + +use super::support::{TestWorkspace, assert_success}; + +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_the_selected_workspace_package() { + let workspace = TestWorkspace::empty(); + workspace.write("spago.yaml", "workspace: {}\n"); + workspace.write( + "packages/application/spago.yaml", + "package:\n name: application\n dependencies: []\n", + ); + 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"); + + let output = workspace.v2_command_in("packages/application/src", &["build", "--quiet"]); + + assert_success(&output); + 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_the_whole_workspace_from_a_root_package_subdirectory() { + let workspace = TestWorkspace::empty(); + workspace.write( + "spago.yaml", + "workspace: {}\npackage:\n name: application\n dependencies: [library]\n", + ); + workspace.write( + "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.v2_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"]]); +} + +#[test] +fn excludes_gitignored_packages_from_selection() { + let workspace = TestWorkspace::empty(); + 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"); + + let output = workspace.v2_command(&["build", "--quiet", "--package", "ignored"]); + + 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 excludes_nested_workspaces_from_selection() { + let workspace = TestWorkspace::empty(); + workspace + .write("spago.yaml", "workspace: {}\npackage:\n name: application\n dependencies: []\n"); + workspace.write( + "nested/spago.yaml", + "workspace: {}\npackage:\n name: nested\n dependencies: []\n", + ); + workspace + .write("nested/packages/child/spago.yaml", "package:\n name: child\n dependencies: []\n"); + + let output = workspace.v2_command(&["build", "--quiet", "--package", "child"]); + + 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 suppresses_diagnostics_without_changing_failure_status() { + 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\nbroken = missing\n"); + + let output = workspace.v2_command(&["build", "--quiet", "--no-diagnostics"]); + + snapshot_output("v2_suppressed_error_diagnostics", &output); +} diff --git a/tests-e2e/tests/package_manager/snapshots/package_manager__build_v2__v2_suppressed_error_diagnostics.snap b/tests-e2e/tests/package_manager/snapshots/package_manager__build_v2__v2_suppressed_error_diagnostics.snap new file mode 100644 index 000000000..925abec7f --- /dev/null +++ b/tests-e2e/tests/package_manager/snapshots/package_manager__build_v2__v2_suppressed_error_diagnostics.snap @@ -0,0 +1,6 @@ +--- +source: tests-e2e/tests/package_manager/build_v2.rs +--- +status: 1 +--- stdout +--- stderr diff --git a/tests-e2e/tests/support.rs b/tests-e2e/tests/support.rs index 9856634b2..50624b056 100644 --- a/tests-e2e/tests/support.rs +++ b/tests-e2e/tests/support.rs @@ -53,6 +53,16 @@ impl TestWorkspace { self.command_builder(directory, arguments).output().unwrap() } + pub fn v2_command(&self, arguments: &[&str]) -> Output { + self.v2_command_in("", arguments) + } + + pub fn v2_command_in(&self, directory: &str, arguments: &[&str]) -> Output { + self.command_builder_with(env!("CARGO_BIN_EXE_iris-v2-e2e"), directory, arguments) + .output() + .unwrap() + } + pub fn spawn(&self, arguments: &[&str]) -> Child { self.spawn_in("", arguments) } @@ -67,9 +77,18 @@ impl TestWorkspace { } pub fn command_builder(&self, directory: &str, arguments: &[&str]) -> Command { + self.command_builder_with(env!("CARGO_BIN_EXE_iris-e2e"), directory, arguments) + } + + fn command_builder_with( + &self, + executable: &str, + directory: &str, + arguments: &[&str], + ) -> Command { let current_directory = self.path().join(directory); fs::create_dir_all(¤t_directory).unwrap(); - let mut command = Command::new(env!("CARGO_BIN_EXE_iris-e2e")); + let mut command = Command::new(executable); command .args(arguments) .current_dir(current_directory) From 8b2d1b0bba3a8f0ee3e5ca198597749544d3d351 Mon Sep 17 00:00:00 2001 From: Justin Garcia Date: Sat, 12 Sep 2026 14:57:49 +0000 Subject: [PATCH 08/26] Split experimental CLI into subsystem crates Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-01a09469-c4cf-723d-9923-3b8452bea9f2 --- Cargo.lock | 46 +++++++++++-------- Cargo.toml | 5 +- compiler-bin-v2/Cargo.toml | 39 ---------------- compiler-bin-v2/build/Cargo.toml | 30 ++++++++++++ .../{src/build => build/src}/compilation.rs | 0 .../{src/build => build/src}/compile.rs | 0 .../{src/build => build/src}/events.rs | 0 .../{src/build => build/src}/executor.rs | 4 +- .../{src/build.rs => build/src/lib.rs} | 4 +- .../{src/build => build/src}/plan.rs | 0 .../{src/build => build/src}/project.rs | 38 +++++++-------- .../{src/build => build/src}/walk.rs | 0 .../{src/build => build/src}/workspace.rs | 0 compiler-bin-v2/iris/Cargo.toml | 21 +++++++++ compiler-bin-v2/{ => iris}/build.rs | 0 compiler-bin-v2/{ => iris}/src/bin/iris.rs | 0 compiler-bin-v2/{ => iris}/src/cli.rs | 18 ++++++-- compiler-bin-v2/{ => iris}/src/lib.rs | 3 +- .../progress}/Cargo.toml | 0 .../progress}/src/lib.rs | 0 tests-e2e/Cargo.toml | 2 +- 21 files changed, 119 insertions(+), 91 deletions(-) delete mode 100644 compiler-bin-v2/Cargo.toml create mode 100644 compiler-bin-v2/build/Cargo.toml rename compiler-bin-v2/{src/build => build/src}/compilation.rs (100%) rename compiler-bin-v2/{src/build => build/src}/compile.rs (100%) rename compiler-bin-v2/{src/build => build/src}/events.rs (100%) rename compiler-bin-v2/{src/build => build/src}/executor.rs (98%) rename compiler-bin-v2/{src/build.rs => build/src/lib.rs} (61%) rename compiler-bin-v2/{src/build => build/src}/plan.rs (100%) rename compiler-bin-v2/{src/build => build/src}/project.rs (74%) rename compiler-bin-v2/{src/build => build/src}/walk.rs (100%) rename compiler-bin-v2/{src/build => build/src}/workspace.rs (100%) create mode 100644 compiler-bin-v2/iris/Cargo.toml rename compiler-bin-v2/{ => iris}/build.rs (100%) rename compiler-bin-v2/{ => iris}/src/bin/iris.rs (100%) rename compiler-bin-v2/{ => iris}/src/cli.rs (89%) rename compiler-bin-v2/{ => iris}/src/lib.rs (80%) rename {compiler-progress => compiler-bin-v2/progress}/Cargo.toml (100%) rename {compiler-progress => compiler-bin-v2/progress}/src/lib.rs (100%) diff --git a/Cargo.lock b/Cargo.lock index 2eddf55f0..1fee046b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1737,6 +1737,32 @@ 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", + "compiler-progress", + "diagnostics", + "dunce", + "files", + "globset", + "ignore", + "itertools 0.15.0", + "javascript", + "line-index", + "path-absolutize", + "petgraph", + "prim-constants", + "rayon", + "serde", + "serde_yml", + "spago", + "thiserror", + "url", + "walkdir", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -3014,27 +3040,9 @@ dependencies = [ name = "purescript-iris-v2" version = "0.1.0" dependencies = [ - "building", - "compiler-progress", - "diagnostics", - "dunce", - "files", - "globset", - "ignore", + "iris-build", "itertools 0.15.0", - "javascript", - "line-index", - "path-absolutize", - "petgraph", - "prim-constants", - "rayon", - "serde", - "serde_yml", - "spago", - "thiserror", - "url", "usage-rs", - "walkdir", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 1b6efdd4d..a2749d012 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,13 @@ [workspace] members = [ "compiler-bin", - "compiler-bin-v2", + "compiler-bin-v2/build", + "compiler-bin-v2/iris", + "compiler-bin-v2/progress", "compiler-backend/*", "compiler-core/*", "compiler-frontend/*", "compiler-lsp/*", - "compiler-progress", "compiler-scripts", "tests-compatibility", "tests-e2e", diff --git a/compiler-bin-v2/Cargo.toml b/compiler-bin-v2/Cargo.toml deleted file mode 100644 index 5d512bea9..000000000 --- a/compiler-bin-v2/Cargo.toml +++ /dev/null @@ -1,39 +0,0 @@ -[package] -name = "purescript-iris-v2" -version = "0.1.0" -edition = "2024" -authors = ["Justin Garcia "] -license = "BSD-3-Clause" -description = "Experimental package-aware PureScript build command." -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-v2" -path = "src/bin/iris.rs" - -[dependencies] -building = { version = "0.1.0", path = "../compiler-core/building" } -compiler-progress = { version = "0.1.0", path = "../compiler-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" -spago = { version = "0.1.0", path = "../compiler-lsp/spago" } -thiserror = "2.0.20" -url = "2.5.8" -usage = { package = "usage-rs", version = "6.8.0" } -walkdir = "2.5.0" diff --git a/compiler-bin-v2/build/Cargo.toml b/compiler-bin-v2/build/Cargo.toml new file mode 100644 index 000000000..75f33d7ff --- /dev/null +++ b/compiler-bin-v2/build/Cargo.toml @@ -0,0 +1,30 @@ +[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" } +compiler-progress = { version = "0.1.0", path = "../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" +spago = { version = "0.1.0", path = "../../compiler-lsp/spago" } +thiserror = "2.0.20" +url = "2.5.8" +walkdir = "2.5.0" diff --git a/compiler-bin-v2/src/build/compilation.rs b/compiler-bin-v2/build/src/compilation.rs similarity index 100% rename from compiler-bin-v2/src/build/compilation.rs rename to compiler-bin-v2/build/src/compilation.rs diff --git a/compiler-bin-v2/src/build/compile.rs b/compiler-bin-v2/build/src/compile.rs similarity index 100% rename from compiler-bin-v2/src/build/compile.rs rename to compiler-bin-v2/build/src/compile.rs diff --git a/compiler-bin-v2/src/build/events.rs b/compiler-bin-v2/build/src/events.rs similarity index 100% rename from compiler-bin-v2/src/build/events.rs rename to compiler-bin-v2/build/src/events.rs diff --git a/compiler-bin-v2/src/build/executor.rs b/compiler-bin-v2/build/src/executor.rs similarity index 98% rename from compiler-bin-v2/src/build/executor.rs rename to compiler-bin-v2/build/src/executor.rs index e7556dc53..4ccb0e023 100644 --- a/compiler-bin-v2/src/build/executor.rs +++ b/compiler-bin-v2/build/src/executor.rs @@ -137,9 +137,9 @@ mod tests { use itertools::Itertools; - use super::super::events::{BuildEvent, RecordedBuildEvents}; - use super::super::plan::{PackageInput, SelectedSource}; 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| { diff --git a/compiler-bin-v2/src/build.rs b/compiler-bin-v2/build/src/lib.rs similarity index 61% rename from compiler-bin-v2/src/build.rs rename to compiler-bin-v2/build/src/lib.rs index a0c58f486..121bd01ef 100644 --- a/compiler-bin-v2/src/build.rs +++ b/compiler-bin-v2/build/src/lib.rs @@ -4,6 +4,8 @@ pub mod plan; mod compilation; mod compile; -pub(crate) mod project; +mod project; mod walk; mod workspace; + +pub use project::{BuildConfig, BuildError, build}; diff --git a/compiler-bin-v2/src/build/plan.rs b/compiler-bin-v2/build/src/plan.rs similarity index 100% rename from compiler-bin-v2/src/build/plan.rs rename to compiler-bin-v2/build/src/plan.rs diff --git a/compiler-bin-v2/src/build/project.rs b/compiler-bin-v2/build/src/project.rs similarity index 74% rename from compiler-bin-v2/src/build/project.rs rename to compiler-bin-v2/build/src/project.rs index dce124748..68c91e01d 100644 --- a/compiler-bin-v2/src/build/project.rs +++ b/compiler-bin-v2/build/src/project.rs @@ -1,4 +1,3 @@ -use std::io::IsTerminal; use std::path::PathBuf; use std::{env, io}; @@ -10,19 +9,18 @@ use super::compile::{self, CompileError}; use super::events::{BuildEvent, BuildEventSink, ProgressEventSink}; use super::plan::PackageInput; use super::workspace::{Workspace, WorkspaceError}; -use crate::cli::ColorChoice; -pub(crate) struct BuildConfig { +pub struct BuildConfig { pub package: Option, pub output: Option, pub quiet: bool, - pub color: ColorChoice, + pub color: bool, pub resilient: bool, pub diagnostics: bool, } #[derive(Debug, Error)] -pub(crate) enum ProjectError { +enum ProjectError { #[error(transparent)] Compile(#[from] CompileError), #[error(transparent)] @@ -35,14 +33,21 @@ pub(crate) enum ProjectError { CurrentDirectory(io::Error), } -impl ProjectError { +#[derive(Debug, Error)] +#[error(transparent)] +pub struct BuildError(ProjectError); + +impl BuildError { pub fn diagnostics_were_suppressed(&self) -> bool { - matches!(self, ProjectError::Compile(CompileError::Diagnostics { reported: false })) + matches!(self.0, ProjectError::Compile(CompileError::Diagnostics { reported: false })) } } -pub(crate) fn build(config: BuildConfig) -> Result<(), ProjectError> { - let color = use_color(config.color); +pub fn build(config: BuildConfig) -> Result<(), BuildError> { + build_project(config).map_err(BuildError) +} + +fn build_project(config: BuildConfig) -> Result<(), ProjectError> { let current_directory = env::current_dir().map_err(ProjectError::CurrentDirectory)?; let workspace = Workspace::discover(¤t_directory, config.package.as_deref())?; let spago = spago::SpagoCommand::new(¤t_directory)?; @@ -50,7 +55,7 @@ pub(crate) fn build(config: BuildConfig) -> Result<(), ProjectError> { let source_globs = spago.source_globs(workspace.selected.as_deref(), !config.quiet)?; let package_sources = spago::source_files_by_package(&workspace.root)?; - let progress = ProgressRuntime::start(!config.quiet, color); + let progress = ProgressRuntime::start(!config.quiet, config.color); let events = ProgressEventSink::new(progress.reporter()); events.send(BuildEvent::Preparing); let packages = package_sources.into_iter().map(|(name, package)| { @@ -69,21 +74,10 @@ pub(crate) fn build(config: BuildConfig) -> Result<(), ProjectError> { output, source_globs, packages, - color, + color: config.color, diagnostics: config.diagnostics, resilient: config.resilient, events: &events, })?; Ok(()) } - -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, - } -} diff --git a/compiler-bin-v2/src/build/walk.rs b/compiler-bin-v2/build/src/walk.rs similarity index 100% rename from compiler-bin-v2/src/build/walk.rs rename to compiler-bin-v2/build/src/walk.rs diff --git a/compiler-bin-v2/src/build/workspace.rs b/compiler-bin-v2/build/src/workspace.rs similarity index 100% rename from compiler-bin-v2/src/build/workspace.rs rename to compiler-bin-v2/build/src/workspace.rs diff --git a/compiler-bin-v2/iris/Cargo.toml b/compiler-bin-v2/iris/Cargo.toml new file mode 100644 index 000000000..6d485a93d --- /dev/null +++ b/compiler-bin-v2/iris/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "purescript-iris-v2" +version = "0.1.0" +edition = "2024" +authors = ["Justin Garcia "] +license = "BSD-3-Clause" +description = "Experimental 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-v2" +path = "src/bin/iris.rs" + +[dependencies] +iris-build = { version = "0.1.0", path = "../build" } +itertools = "0.15.0" +usage = { package = "usage-rs", version = "6.8.0" } diff --git a/compiler-bin-v2/build.rs b/compiler-bin-v2/iris/build.rs similarity index 100% rename from compiler-bin-v2/build.rs rename to compiler-bin-v2/iris/build.rs diff --git a/compiler-bin-v2/src/bin/iris.rs b/compiler-bin-v2/iris/src/bin/iris.rs similarity index 100% rename from compiler-bin-v2/src/bin/iris.rs rename to compiler-bin-v2/iris/src/bin/iris.rs diff --git a/compiler-bin-v2/src/cli.rs b/compiler-bin-v2/iris/src/cli.rs similarity index 89% rename from compiler-bin-v2/src/cli.rs rename to compiler-bin-v2/iris/src/cli.rs index a19dfeac6..5f3ea7786 100644 --- a/compiler-bin-v2/src/cli.rs +++ b/compiler-bin-v2/iris/src/cli.rs @@ -1,11 +1,12 @@ +use std::env; use std::ffi::OsStr; +use std::io::{self, IsTerminal}; use std::path::PathBuf; +use iris_build::BuildConfig; use itertools::Itertools; use usage::{Args, Subcommands, ValueEnum}; -use crate::build::project::BuildConfig; - #[derive(Debug, usage::Cli)] #[usage( bin = "iris-v2", @@ -67,13 +68,24 @@ impl Command { package: options.package, output: options.output, quiet: options.quiet, - color: options.color, + color: use_color(options.color), resilient: options.resilient, diagnostics: !options.no_diagnostics, } } } +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(); diff --git a/compiler-bin-v2/src/lib.rs b/compiler-bin-v2/iris/src/lib.rs similarity index 80% rename from compiler-bin-v2/src/lib.rs rename to compiler-bin-v2/iris/src/lib.rs index 5cdd174f6..a5758b4c6 100644 --- a/compiler-bin-v2/src/lib.rs +++ b/compiler-bin-v2/iris/src/lib.rs @@ -1,11 +1,10 @@ -pub mod build; mod cli; pub(crate) const VERSION: &str = env!("IRIS_VERSION"); pub fn run() -> i32 { let program = cli::Program::parse_with_diagnostics(); - match build::project::build(program.command.into_config()) { + match iris_build::build(program.command.into_config()) { Ok(()) => 0, Err(error) => { if !error.diagnostics_were_suppressed() { diff --git a/compiler-progress/Cargo.toml b/compiler-bin-v2/progress/Cargo.toml similarity index 100% rename from compiler-progress/Cargo.toml rename to compiler-bin-v2/progress/Cargo.toml diff --git a/compiler-progress/src/lib.rs b/compiler-bin-v2/progress/src/lib.rs similarity index 100% rename from compiler-progress/src/lib.rs rename to compiler-bin-v2/progress/src/lib.rs diff --git a/tests-e2e/Cargo.toml b/tests-e2e/Cargo.toml index 3b2524af3..58a004782 100644 --- a/tests-e2e/Cargo.toml +++ b/tests-e2e/Cargo.toml @@ -7,7 +7,7 @@ autotests = false [dependencies] purescript-iris = { path = "../compiler-bin" } -purescript-iris-v2 = { path = "../compiler-bin-v2" } +purescript-iris-v2 = { path = "../compiler-bin-v2/iris" } [dev-dependencies] async-lsp = { version = "0.2.4", features = ["tokio"] } From fb6a42f478db527eb05c991190dd4c4f4639970a Mon Sep 17 00:00:00 2001 From: Justin Garcia Date: Sat, 12 Sep 2026 15:47:51 +0000 Subject: [PATCH 09/26] Add LSP support to iris-v2 Amp-Thread-ID: https://ampcode.com/threads/T-01a09469-c4cf-723d-9923-3b8452bea9f2 Co-authored-by: Amp --- Cargo.lock | 36 + Cargo.toml | 1 + compiler-bin-v2/iris/Cargo.toml | 6 + compiler-bin-v2/iris/src/cli.rs | 124 +- compiler-bin-v2/iris/src/lib.rs | 41 +- compiler-bin-v2/iris/src/logging.rs | 57 + compiler-bin-v2/lsp/Cargo.toml | 36 + compiler-bin-v2/lsp/src/lib.rs | 42 + compiler-bin-v2/lsp/src/server.rs | 1344 +++++++++++++++++ .../lsp/src/server/capabilities.rs | 175 +++ compiler-bin-v2/lsp/src/server/error.rs | 104 ++ compiler-bin-v2/lsp/src/server/event.rs | 316 ++++ compiler-bin-v2/lsp/src/server/extension.rs | 20 + compiler-bin-v2/lsp/src/server/tests.rs | 304 ++++ compiler-bin-v2/lsp/src/walk.rs | 215 +++ 15 files changed, 2798 insertions(+), 23 deletions(-) create mode 100644 compiler-bin-v2/iris/src/logging.rs create mode 100644 compiler-bin-v2/lsp/Cargo.toml create mode 100644 compiler-bin-v2/lsp/src/lib.rs create mode 100644 compiler-bin-v2/lsp/src/server.rs create mode 100644 compiler-bin-v2/lsp/src/server/capabilities.rs create mode 100644 compiler-bin-v2/lsp/src/server/error.rs create mode 100644 compiler-bin-v2/lsp/src/server/event.rs create mode 100644 compiler-bin-v2/lsp/src/server/extension.rs create mode 100644 compiler-bin-v2/lsp/src/server/tests.rs create mode 100644 compiler-bin-v2/lsp/src/walk.rs diff --git a/Cargo.lock b/Cargo.lock index 1fee046b1..2394a367e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1763,6 +1763,36 @@ dependencies = [ "walkdir", ] +[[package]] +name = "iris-lsp" +version = "0.1.0" +dependencies = [ + "analyzer", + "async-lsp", + "building", + "configuration", + "dunce", + "files", + "globset", + "itertools 0.15.0", + "lsp-types", + "parking_lot", + "path-absolutize", + "prim-constants", + "rustc-hash", + "serde", + "serde_json", + "spago", + "tempfile", + "thiserror", + "tokio", + "tokio-util", + "tower", + "tracing", + "url", + "walkdir", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -3040,8 +3070,14 @@ dependencies = [ name = "purescript-iris-v2" version = "0.1.0" dependencies = [ + "configuration", "iris-build", + "iris-lsp", "itertools 0.15.0", + "serde_json", + "thiserror", + "tracing", + "tracing-subscriber", "usage-rs", ] diff --git a/Cargo.toml b/Cargo.toml index a2749d012..7f990ceb1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ members = [ "compiler-bin", "compiler-bin-v2/build", "compiler-bin-v2/iris", + "compiler-bin-v2/lsp", "compiler-bin-v2/progress", "compiler-backend/*", "compiler-core/*", diff --git a/compiler-bin-v2/iris/Cargo.toml b/compiler-bin-v2/iris/Cargo.toml index 6d485a93d..c19a830bb 100644 --- a/compiler-bin-v2/iris/Cargo.toml +++ b/compiler-bin-v2/iris/Cargo.toml @@ -16,6 +16,12 @@ name = "iris-v2" path = "src/bin/iris.rs" [dependencies] +configuration = { version = "0.1.0", path = "../../compiler-lsp/configuration" } iris-build = { version = "0.1.0", path = "../build" } +iris-lsp = { version = "0.1.0", path = "../lsp" } 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-v2/iris/src/cli.rs b/compiler-bin-v2/iris/src/cli.rs index 5f3ea7786..9d71649f9 100644 --- a/compiler-bin-v2/iris/src/cli.rs +++ b/compiler-bin-v2/iris/src/cli.rs @@ -1,12 +1,17 @@ -use std::env; 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; 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-v2", @@ -24,6 +29,13 @@ pub struct Program { pub enum Command { /// Build a Spago workspace or package. Build(BuildOptions), + /// Run the language server over standard input and output. + Lsp(LspOptions), +} + +pub struct LspConfig { + pub configuration: Configuration, + pub logging: LoggingFilters, } #[derive(Debug, Args)] @@ -61,20 +73,101 @@ pub enum ColorChoice { Never, } -impl Command { +impl BuildOptions { pub fn into_config(self) -> BuildConfig { - let Command::Build(options) = self; BuildConfig { - package: options.package, - output: options.output, - quiet: options.quiet, - color: use_color(options.color), - resilient: options.resilient, - diagnostics: !options.no_diagnostics, + package: self.package, + output: self.output, + quiet: self.quiet, + color: use_color(self.color), + resilient: self.resilient, + 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 => { @@ -133,20 +226,17 @@ mod tests { use super::*; #[test] - fn exposes_only_the_build_command() { + fn maps_build_options_to_configuration() { let arguments = ["iris-v2", "build", "--quiet", "--no-diagnostics", "--resilient"]; let arguments = arguments.iter().map(OsStr::new).collect_vec(); let program = Program::try_parse_from(&arguments).unwrap(); - let config = program.command.into_config(); + 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); } - - #[test] - fn rejects_other_commands() { - let arguments = [OsStr::new("iris-v2"), OsStr::new("lsp")]; - assert!(Program::try_parse_from(&arguments).is_err()); - } } diff --git a/compiler-bin-v2/iris/src/lib.rs b/compiler-bin-v2/iris/src/lib.rs index a5758b4c6..7328c345f 100644 --- a/compiler-bin-v2/iris/src/lib.rs +++ b/compiler-bin-v2/iris/src/lib.rs @@ -1,16 +1,45 @@ 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 iris_build::build(program.command.into_config()) { - Ok(()) => 0, - Err(error) => { - if !error.diagnostics_were_suppressed() { - eprintln!("{error}"); + match program.command { + 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::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 + } } - 1 } } } diff --git a/compiler-bin-v2/iris/src/logging.rs b/compiler-bin-v2/iris/src/logging.rs new file mode 100644 index 000000000..3fd17f68f --- /dev/null +++ b/compiler-bin-v2/iris/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("purescript-iris-v2.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-v2/lsp/Cargo.toml b/compiler-bin-v2/lsp/Cargo.toml new file mode 100644 index 000000000..0ccb5f51c --- /dev/null +++ b/compiler-bin-v2/lsp/Cargo.toml @@ -0,0 +1,36 @@ +[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" +itertools = "0.15.0" +lsp-types.workspace = true +parking_lot = "0.12.5" +path-absolutize = "4.0.1" +prim-constants = { version = "0.1.0", path = "../../compiler-core/prim-constants" } +rustc-hash = "2.1.3" +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.151" +spago = { version = "0.1.0", path = "../../compiler-lsp/spago" } +tempfile = "3.27.0" +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" + +[target.'cfg(not(unix))'.dependencies] +tokio-util = { version = "0.7.19", features = ["compat"] } diff --git a/compiler-bin-v2/lsp/src/lib.rs b/compiler-bin-v2/lsp/src/lib.rs new file mode 100644 index 000000000..81a6a0d7d --- /dev/null +++ b/compiler-bin-v2/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-v2/lsp/src/server.rs b/compiler-bin-v2/lsp/src/server.rs new file mode 100644 index 000000000..e27599ea1 --- /dev/null +++ b/compiler-bin-v2/lsp/src/server.rs @@ -0,0 +1,1344 @@ +pub mod capabilities; +pub mod error; +pub mod event; +pub mod extension; + +#[cfg(test)] +mod tests; + +use std::borrow::BorrowMut; +use std::collections::BTreeMap; +use std::ops::ControlFlow; +use std::path::PathBuf; +use std::sync::Arc; +use std::{env, fs, io, mem, process}; + +use analyzer::completion::SuggestionsCache; +use analyzer::position::PositionEncoding; +use analyzer::symbols::WorkspaceSymbolsCache; +use analyzer::{AnalyzerCapabilities, AnalyzerContext, AnalyzerHost}; +use async_lsp::client_monitor::ClientProcessMonitorLayer; +use async_lsp::concurrency::ConcurrencyLayer; +use async_lsp::panic::CatchUnwindLayer; +use async_lsp::router::Router; +use async_lsp::server::LifecycleLayer; +use async_lsp::{ClientSocket, LanguageClient, ResponseError}; +use building::QueryEngine; +use building::lifecycle::{ + AnalysisInvalidation, DiskObservation, DocumentKey, DocumentKind, FileLifecycle, ForeignEvent, + LifecycleChange, LifecycleEvent, ReloadFailure, SourceEvent, SourceUnitKey, +}; +use configuration::{Configuration, ConfigurationSettings, SourceDiscovery}; +use files::{FileId, ForeignSourceKind}; +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 rustc_hash::FxHashSet; +use tempfile::TempDir; +use tokio::task; +use tower::ServiceBuilder; + +use crate::server::capabilities::{ + ConfigurationCapabilities, negotiate_analyzer_capabilities, + negotiate_configuration_capabilities, negotiate_position_encoding, +}; +use crate::server::error::{AnalyzerResultExt, LspError}; +use crate::{ServerConfig, ServerError, walk}; + +fn materialize_prim() -> io::Result { + let directory = TempDir::new()?; + for (name, content) in MODULE_MAP { + let path = directory.path().join(format!("{name}.purs")); + fs::write(path, content)?; + } + Ok(directory) +} + +fn configure_materialized_prim( + engine: &QueryEngine, + files: &mut FileLifecycle, + directory: &TempDir, +) { + for (name, content) in MODULE_MAP { + let path = directory.path().join(format!("{name}.purs")); + 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); + } +} + +pub struct State { + pub startup_config: Arc, + pub config: Arc, + pub client: ClientSocket, + + pub engine: QueryEngine, + pub files: Arc>>, + pub diagnostics: event::DiagnosticScheduler, + + pub workspace_symbols_cache: Arc>, + pub suggestions_cache: Arc>, + + pub root: Option, + 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, + _materialized_prim_directory: Arc, +} + +impl State { + fn new( + config: Arc, + client: ClientSocket, + name: String, + version: String, + prim_directory: Arc, + ) -> State { + let engine = QueryEngine::default(); + let mut files = FileLifecycle::default(); + configure_materialized_prim(&engine, &mut files, &prim_directory); + + let files = Arc::new(RwLock::new(files)); + let diagnostics = event::DiagnosticScheduler::default(); + + let workspace_symbols_cache = WorkspaceSymbolsCache::default(); + let workspace_symbols_cache = Arc::new(RwLock::new(workspace_symbols_cache)); + + let suggestions_cache = SuggestionsCache::default(); + let suggestions_cache = Arc::new(RwLock::new(suggestions_cache)); + + let root = None; + 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; + + State { + startup_config: Arc::clone(&config), + config, + client, + engine, + files, + diagnostics, + workspace_symbols_cache, + suggestions_cache, + root, + configuration_scope, + configuration_capabilities, + configuration_generation, + workspace_loaded, + selected_sources, + excluded_sources, + position_encoding, + analyzer_capabilities, + watched_files_dynamic_registration, + name, + version, + _materialized_prim_directory: prim_directory, + } + } + + fn spawn( + &self, + action: impl FnOnce(StateSnapshot) -> T + Send + 'static, + ) -> task::JoinHandle + where + T: Send + 'static, + { + let snapshot = StateSnapshot { + engine: self.engine.snapshot(), + files: Arc::clone(&self.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 || action(snapshot)) + } + + fn invalidate_workspace_symbols(&self) { + let mut cache = self.workspace_symbols_cache.write(); + mem::take(&mut *cache); + } + + fn invalidate_suggestions_cache(&self) { + let mut cache = self.suggestions_cache.write(); + mem::take(&mut *cache); + } +} + +struct StateSnapshot { + engine: QueryEngine, + files: Arc>>, + workspace_symbols_cache: Arc>, + suggestions_cache: Arc>, + position_encoding: PositionEncoding, + analyzer_capabilities: AnalyzerCapabilities, +} + +impl StateSnapshot { + fn with_analyzer_context( + &self, + 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); + action(&context) + } +} + +struct LspAnalyzerHost<'a> { + queries: &'a QueryEngine, + files: RwLockReadGuard<'a, FileLifecycle>, +} + +impl AnalyzerHost for LspAnalyzerHost<'_> { + type Queries = QueryEngine; + + fn queries(&self) -> &QueryEngine { + self.queries + } + + fn file_id(&self, uri: &str) -> Option { + self.files.source_id(uri) + } + + fn file_uri(&self, file_id: FileId) -> Result, url::ParseError> { + let Some(uri) = self.files.source_path(file_id) else { + return Ok(None); + }; + Url::parse(&uri).map(Some) + } + + fn active_files(&self) -> impl Iterator { + self.files.source_ids() + } + + fn is_editable(&self, file_id: FileId) -> bool { + self.files.source_metadata(file_id).copied().unwrap_or(false) + } +} + +fn initialize( + state: &mut State, + parameters: extension::CustomInitializeParams, +) -> impl Future> + use<> { + let position_encoding = negotiate_position_encoding(¶meters.initialize_params); + state.position_encoding = position_encoding; + 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(¶meters.initialize_params.capabilities); + + state.configuration_scope = parameters + .initialize_params + .workspace_folders + .and_then(|folders| folders.first().map(|folder| Url::clone(&folder.uri))); + state.root = state + .configuration_scope + .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(server_info), + capabilities: ServerCapabilities { + completion_provider: Some(CompletionOptions { + resolve_provider: Some(true), + trigger_characters: Some(vec![".".to_string()]), + all_commit_characters: None, + work_done_progress_options: WorkDoneProgressOptions { + work_done_progress: None, + }, + completion_item: Some(CompletionOptionsCompletionItem { + label_details_support: Some(true), + }), + }), + code_action_provider: Some(CodeActionProviderCapability::Options( + CodeActionOptions { + code_action_kinds: Some(vec![CodeActionKind::QUICKFIX]), + ..CodeActionOptions::default() + }, + )), + definition_provider: Some(OneOf::Left(true)), + hover_provider: Some(HoverProviderCapability::Simple(true)), + references_provider: Some(OneOf::Left(true)), + rename_provider: Some(OneOf::Right(RenameOptions { + prepare_provider: Some(true), + work_done_progress_options: WorkDoneProgressOptions { + work_done_progress: None, + }, + })), + document_highlight_provider: Some(OneOf::Left(true)), + workspace_symbol_provider: Some(OneOf::Left(true)), + document_symbol_provider: Some(OneOf::Left(true)), + semantic_tokens_provider: Some( + SemanticTokensServerCapabilities::SemanticTokensOptions( + SemanticTokensOptions { + work_done_progress_options: WorkDoneProgressOptions { + work_done_progress: None, + }, + legend: SemanticTokensLegend { + token_types: analyzer::semantic_tokens::TOKEN_TYPES.to_vec(), + token_modifiers: analyzer::semantic_tokens::TOKEN_MODIFIERS + .to_vec(), + }, + range: Some(false), + full: Some(SemanticTokensFullOptions::Bool(true)), + }, + ), + ), + text_document_sync: Some(TextDocumentSyncCapability::Options( + TextDocumentSyncOptions { + open_close: Some(true), + change: Some(TextDocumentSyncKind::INCREMENTAL), + save: Some(TextDocumentSyncSaveOptions::Supported(true)), + ..TextDocumentSyncOptions::default() + }, + )), + position_encoding: Some(PositionEncodingKind::from(position_encoding)), + ..ServerCapabilities::default() + }, + }) + } +} + +fn watched_files_dynamic_registration(capabilities: &ClientCapabilities) -> bool { + capabilities + .workspace + .as_ref() + .and_then(|workspace| workspace.did_change_watched_files.as_ref()) + .and_then(|watched_files| watched_files.dynamic_registration) + .unwrap_or(false) +} + +fn shutdown(_state: &mut State, (): ()) -> impl Future> + use<> { + async { Ok(()) } +} + +fn initialized(state: &mut State, _: InitializedParams) -> Result<(), LspError> { + let _span = tracing::info_span!("initialization").entered(); + register_file_watcher(state); + register_configuration_changes(state); + + if state.configuration_capabilities.workspace_configuration { + request_workspace_configuration(state); + Ok(()) + } else { + apply_configuration(state, Arc::clone(&state.startup_config)) + } +} + +fn register_configuration_changes(state: &State) { + if !state.configuration_capabilities.dynamic_registration { + return; + } + + let registration = Registration { + id: "iris-workspace-configuration".to_string(), + method: notification::DidChangeConfiguration::METHOD.to_string(), + register_options: None, + }; + let parameters = RegistrationParams { registrations: vec![registration] }; + let mut client = ClientSocket::clone(&state.client); + task::spawn(async move { + if let Err(error) = client.register_capability(parameters).await { + tracing::warn!("Failed to register workspace configuration changes: {error}"); + } + }); +} + +struct ConfigurationReceived { + generation: u64, + result: Result, String>, +} + +fn request_workspace_configuration(state: &mut State) { + state.configuration_generation = state.configuration_generation.wrapping_add(1); + let generation = state.configuration_generation; + let parameters = ConfigurationParams { + items: vec![ConfigurationItem { + scope_uri: state.configuration_scope.clone(), + section: Some("iris.server".to_string()), + }], + }; + let mut client = ClientSocket::clone(&state.client); + task::spawn(async move { + let result = tokio::time::timeout( + std::time::Duration::from_secs(10), + client.configuration(parameters), + ) + .await + .map_err(|_| "workspace/configuration request timed out".to_string()) + .and_then(|result| result.map_err(|error| error.to_string())); + if let Err(error) = client.emit(ConfigurationReceived { generation, result }) { + tracing::error!("Failed to deliver workspace configuration: {error}"); + } + }); +} + +fn finish_workspace_configuration( + state: &mut State, + ConfigurationReceived { generation, result }: ConfigurationReceived, +) -> Result<(), LspError> { + if generation != state.configuration_generation { + return Ok(()); + } + + let configuration = result + .map_err(|error| format!("Failed to retrieve Iris settings: {error}")) + .and_then(|mut values| { + if values.len() != 1 { + return Err(format!( + "Invalid workspace/configuration response: expected one item, received {}", + values.len() + )); + } + let value = values.pop().expect("invariant violated: expected one configuration item"); + serde_json::from_value::>(value) + .map(|settings| settings.unwrap_or_default().apply_to(&state.startup_config)) + .map_err(|error| format!("Invalid Iris settings: {error}")) + }); + + match configuration { + Ok(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 { + apply_configuration(state, Arc::clone(&state.startup_config))?; + } + } + Ok(()) + } + Err(error) => { + report_configuration_error(state, &error); + if state.workspace_loaded { + Ok(()) + } else { + apply_configuration(state, Arc::clone(&state.startup_config)) + } + } + } +} + +fn did_change_configuration( + state: &mut State, + _: DidChangeConfigurationParams, +) -> Result<(), LspError> { + if state.configuration_capabilities.workspace_configuration { + request_workspace_configuration(state); + } + Ok(()) +} + +fn report_configuration_error(state: &mut State, error: &str) { + tracing::error!("{error}"); + let message = if state.workspace_loaded { + format!("{error}. The previous Iris settings remain active.") + } else { + format!("{error}. Iris will use its startup settings.") + }; + if let Err(error) = + state.client.show_message(ShowMessageParams { typ: MessageType::ERROR, message }) + { + tracing::warn!("Failed to report configuration error: {error}"); + } +} + +fn register_file_watcher(state: &State) { + if !state.watched_files_dynamic_registration { + return; + } + + let parameters = file_watcher_registration(); + let mut client = ClientSocket::clone(&state.client); + task::spawn(async move { + if let Err(error) = client.register_capability(parameters).await { + tracing::warn!("Failed to register source file watcher: {error}"); + } + }); +} + +fn file_watcher_registration() -> RegistrationParams { + let options = DidChangeWatchedFilesRegistrationOptions { + watchers: vec![ + FileSystemWatcher { + glob_pattern: GlobPattern::String("**/*.purs".to_string()), + kind: None, + }, + FileSystemWatcher { + glob_pattern: GlobPattern::String("**/*.js".to_string()), + kind: None, + }, + FileSystemWatcher { + glob_pattern: GlobPattern::String("**/*.jsx".to_string()), + kind: None, + }, + ], + }; + let register_options = serde_json::to_value(options) + .expect("invariant violated: watched file registration options must serialize"); + let registration = Registration { + id: "purescript-source-files".to_string(), + method: notification::DidChangeWatchedFiles::METHOD.to_string(), + register_options: Some(register_options), + }; + RegistrationParams { registrations: vec![registration] } +} + +fn exit(_state: &mut State, (): ()) -> Result<(), LspError> { + Ok(()) +} + +fn discover_manual( + root: &std::path::Path, + program: &str, + arguments: &[String], +) -> Result, LspError> { + tracing::info!("Using '{}'", program); + + let mut command = process::Command::new(program); + command.args(arguments); + + let output = command.output()?; + if !output.status.success() { + return Err(LspError::SourceCommandFailed(output.status)); + } + let output = str::from_utf8(&output.stdout)?; + + let walk::Walk { files, .. } = walk::walk(root, output.lines())?; + let files = files.into_iter().map(|file| { + let editable = file.starts_with(root); + (file, editable) + }); + Ok(files.collect_vec()) +} + +fn discover_spago(root: &std::path::Path) -> Result, LspError> { + 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)) + }); + + Ok(files.sorted().collect_vec()) +} + +fn apply_configuration( + state: &mut State, + configuration: Arc, +) -> Result<(), LspError> { + 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 { + 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)); + } + + let refresh_diagnostics = state.workspace_loaded; + reconcile_files(state, &prepared, refresh_diagnostics)?; + state.config = configuration; + state.workspace_loaded = true; + Ok(()) +} + +fn reconcile_files( + state: &mut State, + files: &BTreeMap, bool)>, + refresh_diagnostics: bool, +) -> 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 mut lifecycle_change = LifecycleChange::default(); + for source in state.selected_sources.difference(&selected_sources).cloned().collect_vec() { + 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 }, + }; + lifecycle_change.combine(apply_lifecycle_event(state, event)); + for kind in ForeignSourceKind::ALL { + let event = LifecycleEvent::Foreign { + unit: SourceUnitKey::clone(&unit), + kind, + event: ForeignEvent::DiskObserved { disk: DiskObservation::NotFound }, + }; + lifecycle_change.combine(apply_lifecycle_event(state, event)); + } + } + for (file, (content, editable)) in files { + let uri = + Url::from_file_path(file).map_err(|_| LspError::PathParseFail(PathBuf::clone(file)))?; + let unit = source_unit_from_source_uri(&uri)?; + let event = LifecycleEvent::Source { + unit: SourceUnitKey::clone(&unit), + event: SourceEvent::DiskObserved { + disk: DiskObservation::Found(Arc::clone(content)), + metadata: *editable, + }, + }; + 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)?; + } + + state.excluded_sources.extend(state.selected_sources.difference(&selected_sources).cloned()); + for selected in &selected_sources { + state.excluded_sources.remove(selected); + } + state.selected_sources = selected_sources; + + tracing::info!("Loaded {} files.", files.len()); + + Ok(()) +} + +fn definition( + snapshot: StateSnapshot, + parameters: GotoDefinitionParams, +) -> Result, LspError> { + let _span = tracing::info_span!("definition").entered(); + 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) + }); + + result.on_non_fatal(None) +} + +fn hover(snapshot: StateSnapshot, parameters: HoverParams) -> Result, LspError> { + let _span = tracing::info_span!("hover").entered(); + 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)); + + result.on_non_fatal(None) +} + +fn code_action( + snapshot: StateSnapshot, + parameters: CodeActionParams, +) -> Result, LspError> { + let _span = tracing::info_span!("code_action").entered(); + 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) + }); + + result.on_non_fatal(None) +} + +fn completion( + snapshot: StateSnapshot, + parameters: CompletionParams, +) -> Result, LspError> { + let _span = tracing::info_span!("completion").entered(); + let uri = parameters.text_document_position.text_document.uri; + let position = parameters.text_document_position.position; + + let mut cache = snapshot.suggestions_cache.write(); + + let result = snapshot.with_analyzer_context(|context| { + analyzer::completion::implementation(context, &mut cache, uri, position) + }); + + result.on_non_fatal(None) +} + +fn resolve_completion_item( + snapshot: StateSnapshot, + item: CompletionItem, +) -> Result { + let _span = tracing::info_span!("resolve_completion_item").entered(); + analyzer::completion::resolve::implementation(&snapshot.engine, item) + .or_else(|(error, item)| Err(error).on_non_fatal(item)) +} + +fn references( + snapshot: StateSnapshot, + parameters: ReferenceParams, +) -> Result>, LspError> { + let _span = tracing::info_span!("references").entered(); + 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) + }); + + result.on_non_fatal(None) +} + +fn rename( + snapshot: StateSnapshot, + parameters: RenameParams, +) -> Result, LspError> { + let _span = tracing::info_span!("rename").entered(); + 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) + }); + + result.on_non_fatal(None) +} + +fn prepare_rename( + snapshot: StateSnapshot, + parameters: TextDocumentPositionParams, +) -> Result, LspError> { + let _span = tracing::info_span!("prepare_rename").entered(); + let uri = parameters.text_document.uri; + let position = parameters.position; + + let result = + snapshot.with_analyzer_context(|context| analyzer::rename::prepare(context, uri, position)); + + result.on_non_fatal(None) +} + +fn document_highlight( + snapshot: StateSnapshot, + parameters: DocumentHighlightParams, +) -> Result>, LspError> { + let _span = tracing::info_span!("document_highlight").entered(); + 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) + }); + + result.on_non_fatal(None) +} + +fn workspace_symbols( + snapshot: StateSnapshot, + 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, ¶meters.query) + }); + + result.on_non_fatal(None) +} + +fn document_symbols( + snapshot: StateSnapshot, + parameters: DocumentSymbolParams, +) -> Result, LspError> { + let _span = tracing::info_span!("document_symbols").entered(); + let uri = parameters.text_document.uri; + let result = + snapshot.with_analyzer_context(|context| analyzer::symbols::document(context, uri)); + + result.on_non_fatal(None) +} + +fn semantic_tokens( + snapshot: StateSnapshot, + parameters: SemanticTokensParams, +) -> Result, LspError> { + let _span = tracing::info_span!("semantic_tokens").entered(); + 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)) + }); + + result.on_non_fatal(None) +} + +fn document_content( + state: &State, + document: DocumentKind, + uri: &Url, +) -> Result, LspError> { + 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) + } + DocumentKind::Foreign(_) => { + let file_id = files + .foreign_id(uri.as_str()) + .ok_or_else(|| LspError::InvalidContentChange(Url::clone(uri)))?; + state + .engine + .foreign_content(file_id) + .ok_or_else(|| LspError::InvalidContentChange(Url::clone(uri))) + } + } +} + +fn apply_content_changes( + uri: &Url, + content: &str, + content_changes: &[TextDocumentContentChangeEvent], + position_encoding: PositionEncoding, +) -> Result, LspError> { + let mut content = content.to_string(); + for content_change in content_changes { + let Some(range) = content_change.range else { + content = String::clone(&content_change.text); + continue; + }; + + let positions = analyzer::position::PositionConverter::new(&content, position_encoding); + let start = positions + .protocol_position_to_utf8(range.start) + .and_then(|position| positions.utf8_position_to_offset(position)); + + let end = positions + .protocol_position_to_utf8(range.end) + .and_then(|position| positions.utf8_position_to_offset(position)); + + let (Some(start), Some(end)) = (start, end) else { + return Err(LspError::InvalidContentChange(Url::clone(uri))); + }; + + let start = usize::from(start); + let end = usize::from(end); + + if start > end { + return Err(LspError::InvalidContentChange(Url::clone(uri))); + } + + content.replace_range(start..end, &content_change.text); + } + Ok(Arc::from(content)) +} + +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, ¶meters.content_changes, state.position_encoding)?; + let event = match document { + DocumentKind::Foreign(kind) => LifecycleEvent::Foreign { + unit, + kind, + event: ForeignEvent::Changed { + text: content, + version: parameters.text_document.version, + }, + }, + DocumentKind::Source => LifecycleEvent::Source { + unit, + 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(¶meters.text_document.uri))?; + } + + Ok(()) +} + +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 { + DocumentKind::Foreign(kind) => { + let event = LifecycleEvent::Foreign { + unit, + kind, + event: ForeignEvent::Opened { + 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 event = LifecycleEvent::Source { + unit: SourceUnitKey::clone(&unit), + event: SourceEvent::Opened { + text: Arc::from(parameters.text_document.text.as_str()), + version: parameters.text_document.version, + metadata: editable, + }, + }; + let mut change = apply_lifecycle_event(state, event); + change.combine(observe_sibling_foreign(state, &unit)?); + change + } + }; + finish_lifecycle_change(state, &change)?; + + if state.config.diagnostics.on_open { + emit_associated_diagnostics(state, parameters.text_document.uri)?; + } + + Ok(()) +} + +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 change = match document { + DocumentKind::Foreign(kind) => { + let event = + LifecycleEvent::Foreign { unit, kind, event: ForeignEvent::Closed { disk } }; + apply_lifecycle_event(state, event) + } + DocumentKind::Source => { + let document = DocumentKey::Source(SourceUnitKey::clone(&unit)); + let was_open = state.files.read().is_open(&document); + let event = LifecycleEvent::Source { + unit: SourceUnitKey::clone(&unit), + event: SourceEvent::Closed { disk }, + }; + let mut change = apply_lifecycle_event(state, event); + if was_open { + change.combine(observe_sibling_foreign(state, &unit)?); + } + change + } + }; + finish_lifecycle_change(state, &change)?; + emit_diagnostics_for_change(state, &change)?; + Ok(()) +} + +fn did_save(state: &mut State, parameters: DidSaveTextDocumentParams) -> Result<(), LspError> { + state.invalidate_suggestions_cache(); + + if state.config.diagnostics.on_save { + emit_associated_diagnostics(state, parameters.text_document.uri)?; + } + Ok(()) +} + +fn did_change_watched_files( + state: &mut State, + parameters: DidChangeWatchedFilesParams, +) -> Result<(), LspError> { + let mut source_units = FxHashSet::default(); + let mut foreign_units = FxHashSet::default(); + 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()) { + 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()) { + continue; + } + source_units.insert(unit); + } + None => {} + } + } + + let mut lifecycle_change = LifecycleChange::default(); + 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) { + continue; + } + let uri = Url::parse(unit.source())?; + let disk = observe_disk(&uri); + let source_found = matches!(disk, DiskObservation::Found(_)); + let metadata = source_editable(state, &unit, &uri); + let event = LifecycleEvent::Source { + unit: SourceUnitKey::clone(&unit), + event: SourceEvent::DiskObserved { disk, metadata }, + }; + lifecycle_change.combine(apply_lifecycle_event(state, event)); + if source_found { + lifecycle_change.combine(observe_sibling_foreign(state, &unit)?); + observed_foreign.insert(unit); + } + } + + for (unit, kind) in foreign_units { + if observed_foreign.contains(&unit) { + continue; + } + let document = DocumentKey::Foreign(SourceUnitKey::clone(&unit), kind); + if state.files.read().is_open(&document) { + continue; + } + let tracked = { + let files = state.files.read(); + files.source_id(unit.source()).is_some() + || files.foreign_id(unit.foreign_for(kind)).is_some() + }; + if !tracked { + continue; + } + let uri = Url::parse(unit.foreign_for(kind))?; + let event = LifecycleEvent::Foreign { + unit, + kind, + event: ForeignEvent::DiskObserved { disk: observe_disk(&uri) }, + }; + lifecycle_change.combine(apply_lifecycle_event(state, event)); + } + + finish_lifecycle_change(state, &lifecycle_change)?; + emit_diagnostics_for_change(state, &lifecycle_change)?; + Ok(()) +} + +fn document_kind(uri: &Url) -> Option { + if uri.path().ends_with(".js") { + Some(DocumentKind::Foreign(ForeignSourceKind::JavaScript)) + } else if uri.path().ends_with(".jsx") { + Some(DocumentKind::Foreign(ForeignSourceKind::Jsx)) + } else if uri.path().ends_with(".purs") { + Some(DocumentKind::Source) + } else { + None + } +} + +fn source_unit_from_document_uri(uri: &Url) -> Result<(DocumentKind, SourceUnitKey), LspError> { + let document = + document_kind(uri).ok_or_else(|| LspError::UnsupportedDocumentUri(Url::clone(uri)))?; + let unit = match document { + DocumentKind::Source => source_unit_from_source_uri(uri)?, + DocumentKind::Foreign(_) => source_unit_from_foreign_uri(uri)?, + }; + Ok((document, unit)) +} + +fn file_uri_with_extension(uri: &Url, extension: &str) -> Result { + if uri.scheme() != "file" || uri.to_file_path().is_err() { + return Err(LspError::InvalidFileUri(Url::clone(uri))); + } + let uri_path = uri.path(); + let file_name_start = uri_path.rfind('/').map_or(0, |index| index + 1); + let extension_start = uri_path[file_name_start..] + .rfind('.') + .filter(|index| *index > 0) + .map_or(uri_path.len(), |index| file_name_start + index); + let mut sibling_path = String::from(&uri_path[..extension_start]); + sibling_path.push('.'); + sibling_path.push_str(extension); + + let mut sibling_uri = Url::clone(uri); + sibling_uri.set_path(&sibling_path); + Ok(sibling_uri) +} + +fn source_unit_from_source_uri(source_uri: &Url) -> Result { + let javascript_uri = file_uri_with_extension(source_uri, "js")?; + let jsx_uri = file_uri_with_extension(source_uri, "jsx")?; + Ok(SourceUnitKey::with_foreign_sources( + source_uri.as_str(), + javascript_uri.as_str(), + jsx_uri.as_str(), + )) +} + +fn source_unit_from_foreign_uri(foreign_uri: &Url) -> Result { + let source_uri = file_uri_with_extension(foreign_uri, "purs")?; + source_unit_from_source_uri(&source_uri) +} + +fn emit_associated_diagnostics(state: &mut State, uri: Url) -> Result<(), LspError> { + let (_, unit) = source_unit_from_document_uri(&uri)?; + event::emit_collect_diagnostics(state, Url::parse(unit.source())?) +} + +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) +} + +fn finish_lifecycle_change(state: &mut State, change: &LifecycleChange) -> Result<(), LspError> { + state.diagnostics.invalidate(change, &state.files.read()); + if !matches!(change.analysis(), AnalysisInvalidation::None) { + state.invalidate_workspace_symbols(); + state.invalidate_suggestions_cache(); + } + for warning in change.warnings() { + tracing::warn!("{warning}"); + } + for removed in change.removed_sources() { + state.client.publish_diagnostics(PublishDiagnosticsParams { + uri: Url::parse(&removed.locator)?, + diagnostics: vec![], + version: None, + })?; + } + Ok(()) +} + +fn observe_sibling_foreign( + state: &mut State, + unit: &SourceUnitKey, +) -> Result { + 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) { + continue; + } + let uri = Url::parse(unit.foreign_for(kind))?; + let event = LifecycleEvent::Foreign { + unit: SourceUnitKey::clone(unit), + kind, + event: ForeignEvent::DiskObserved { disk: observe_disk(&uri) }, + }; + change.combine(apply_lifecycle_event(state, event)); + } + Ok(change) +} + +fn observe_disk(uri: &Url) -> DiskObservation { + let path = uri.to_file_path().expect("invariant violated: expected a valid file URI"); + match fs::read_to_string(path) { + Ok(content) => DiskObservation::Found(Arc::from(content)), + Err(error) if error.kind() == io::ErrorKind::NotFound => DiskObservation::NotFound, + Err(error) => { + let kind = error.kind(); + DiskObservation::Failed(ReloadFailure::new(kind, error.to_string())) + } + } +} + +fn source_editable(state: &State, unit: &SourceUnitKey, uri: &Url) -> bool { + let previous = { + 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() + }; + previous.unwrap_or_else(|| match (&state.root, uri.to_file_path()) { + (Some(root), Ok(path)) => path.starts_with(root), + (Some(_), Err(())) => false, + (None, _) => true, + }) +} + +fn emit_diagnostics_for_change( + state: &mut State, + change: &LifecycleChange, +) -> Result<(), LspError> { + match change.analysis() { + AnalysisInvalidation::None => Ok(()), + AnalysisInvalidation::Sources(sources) => { + for file_id in sources { + event::emit_collect_diagnostics_id(state, *file_id)?; + } + Ok(()) + } + AnalysisInvalidation::Workspace => event::emit_collect_all_diagnostics(state), + } +} + +trait RequestExtension: BorrowMut> { + fn request_snapshot( + &mut self, + action: impl Fn(StateSnapshot, R::Params) -> Result + Send + Copy + 'static, + ) -> &mut Self { + 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) + }) + } + }); + self + } + + fn notification_ext( + &mut self, + 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 _ = action(state, parameters).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, + ) -> &mut Self + where + E: Send + 'static, + { + let this: &mut Router = self.borrow_mut(); + this.event::(move |state, event| { + let _ = action(state, event).inspect_err(|error| error.emit_trace()); + ControlFlow::Continue(()) + }); + self + } +} + +impl RequestExtension for Router {} + +pub(crate) async fn async_start(config: ServerConfig) -> Result<(), ServerError> { + let ServerConfig { configuration, name, version } = config; + let config = Arc::new(configuration); + let prim_directory = Arc::new(materialize_prim().map_err(ServerError::new)?); + let (server, _) = async_lsp::MainLoop::new_server(move |client| { + let client_socket = ClientSocket::clone(&client); + let mut router: Router = Router::new(State::new( + Arc::clone(&config), + client_socket, + String::clone(&name), + String::clone(&version), + Arc::clone(&prim_directory), + )); + + router + .request::(initialize) + .request::(shutdown) + .request_snapshot::(definition) + .request_snapshot::(hover) + .request_snapshot::(code_action) + .request_snapshot::(completion) + .request_snapshot::(resolve_completion_item) + .request_snapshot::(references) + .request_snapshot::(prepare_rename) + .request_snapshot::(rename) + .request_snapshot::(document_highlight) + .request_snapshot::(workspace_symbols) + .request_snapshot::(document_symbols) + .request_snapshot::(semantic_tokens) + .notification_ext::(initialized) + .notification_ext::(exit) + .notification_ext::(did_open) + .notification_ext::(did_save) + .notification_ext::(did_close) + .notification_ext::(did_change_configuration) + .notification_ext::(did_change) + .notification_ext::(did_change_watched_files) + .event_ext::(event::collect_diagnostics) + .event_ext::(event::finish_diagnostics) + .event_ext::(finish_workspace_configuration); + + ServiceBuilder::new() + .layer(LifecycleLayer::default()) + .layer(CatchUnwindLayer::default()) + .layer(ConcurrencyLayer::default()) + .layer(ClientProcessMonitorLayer::new(client)) + .service(router) + }); + + #[cfg(unix)] + let (stdin, stdout) = ( + async_lsp::stdio::PipeStdin::lock_tokio().map_err(ServerError::new)?, + async_lsp::stdio::PipeStdout::lock_tokio().map_err(ServerError::new)?, + ); + + #[cfg(not(unix))] + let (stdin, stdout) = ( + tokio_util::compat::TokioAsyncReadCompatExt::compat(tokio::io::stdin()), + tokio_util::compat::TokioAsyncWriteCompatExt::compat_write(tokio::io::stdout()), + ); + + server.run_buffered(stdin, stdout).await.map_err(ServerError::new) +} diff --git a/compiler-bin-v2/lsp/src/server/capabilities.rs b/compiler-bin-v2/lsp/src/server/capabilities.rs new file mode 100644 index 000000000..9f7a71b96 --- /dev/null +++ b/compiler-bin-v2/lsp/src/server/capabilities.rs @@ -0,0 +1,175 @@ +use analyzer::AnalyzerCapabilities; +use analyzer::position::PositionEncoding; +use lsp_types::{InitializeParams, PositionEncodingKind}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct ConfigurationCapabilities { + pub workspace_configuration: bool, + pub dynamic_registration: bool, +} + +pub fn negotiate_configuration_capabilities( + parameters: &InitializeParams, +) -> ConfigurationCapabilities { + let Some(workspace) = ¶meters.capabilities.workspace else { + return ConfigurationCapabilities::default(); + }; + let workspace_configuration = workspace.configuration == Some(true); + let dynamic_registration = workspace_configuration + && workspace + .did_change_configuration + .as_ref() + .is_some_and(|capability| capability.dynamic_registration == Some(true)); + ConfigurationCapabilities { workspace_configuration, dynamic_registration } +} + +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 = parameters + .capabilities + .text_document + .as_ref() + .and_then(|text_document| text_document.rename.as_ref()) + .is_some_and(|rename| rename.honors_change_annotations == Some(true)); + let change_annotations = workspace_edit.is_some_and(|workspace_edit| { + workspace_edit.document_changes == Some(true) + && workspace_edit.change_annotation_support.is_some() + && honors_rename_annotations + }); + + if change_annotations { + AnalyzerCapabilities::default().with_change_annotations() + } else { + AnalyzerCapabilities::default() + } +} + +pub fn negotiate_position_encoding(parameters: &InitializeParams) -> PositionEncoding { + let Some(encodings) = parameters + .capabilities + .general + .as_ref() + .and_then(|general| general.position_encodings.as_ref()) + else { + return PositionEncoding::Utf16; + }; + + if encodings.contains(&PositionEncodingKind::UTF8) { + PositionEncoding::Utf8 + } else if encodings.contains(&PositionEncodingKind::UTF16) { + PositionEncoding::Utf16 + } else if encodings.contains(&PositionEncodingKind::UTF32) { + PositionEncoding::Utf32 + } else { + PositionEncoding::Utf16 + } +} + +#[cfg(test)] +mod tests { + use lsp_types::{ + ClientCapabilities, DynamicRegistrationClientCapabilities, GeneralClientCapabilities, + WorkspaceClientCapabilities, + }; + + use super::*; + + fn initialize_parameters( + position_encodings: Option>, + ) -> InitializeParams { + InitializeParams { + capabilities: ClientCapabilities { + general: Some(GeneralClientCapabilities { + position_encodings, + ..GeneralClientCapabilities::default() + }), + ..ClientCapabilities::default() + }, + ..InitializeParams::default() + } + } + + #[test] + fn defaults_to_utf16_without_client_preference() { + let parameters = InitializeParams::default(); + + let encoding = negotiate_position_encoding(¶meters); + assert_eq!(encoding, PositionEncoding::Utf16); + } + + #[test] + fn prefers_utf8_when_available() { + let parameters = initialize_parameters(Some(vec![ + PositionEncodingKind::UTF32, + PositionEncodingKind::UTF16, + PositionEncodingKind::UTF8, + ])); + + let encoding = negotiate_position_encoding(¶meters); + assert_eq!(encoding, PositionEncoding::Utf8); + } + + #[test] + fn falls_back_to_utf16_before_utf32() { + let parameters = initialize_parameters(Some(vec![ + PositionEncodingKind::UTF32, + PositionEncodingKind::UTF16, + ])); + + let encoding = negotiate_position_encoding(¶meters); + assert_eq!(encoding, PositionEncoding::Utf16); + } + + #[test] + fn supports_utf32_when_it_is_the_only_known_option() { + let parameters = initialize_parameters(Some(vec![PositionEncodingKind::UTF32])); + + let encoding = negotiate_position_encoding(¶meters); + assert_eq!(encoding, PositionEncoding::Utf32); + } + + #[test] + fn workspace_configuration_and_registration_are_negotiated_independently() { + let parameters = InitializeParams { + capabilities: ClientCapabilities { + workspace: Some(WorkspaceClientCapabilities { + configuration: Some(true), + did_change_configuration: Some(DynamicRegistrationClientCapabilities { + dynamic_registration: Some(true), + }), + ..WorkspaceClientCapabilities::default() + }), + ..ClientCapabilities::default() + }, + ..InitializeParams::default() + }; + + assert_eq!( + negotiate_configuration_capabilities(¶meters), + ConfigurationCapabilities { workspace_configuration: true, dynamic_registration: true } + ); + + let parameters = InitializeParams { + capabilities: ClientCapabilities { + workspace: Some(WorkspaceClientCapabilities { + configuration: Some(false), + did_change_configuration: Some(DynamicRegistrationClientCapabilities { + dynamic_registration: Some(true), + }), + ..WorkspaceClientCapabilities::default() + }), + ..ClientCapabilities::default() + }, + ..InitializeParams::default() + }; + + assert_eq!( + negotiate_configuration_capabilities(¶meters), + ConfigurationCapabilities::default() + ); + } +} diff --git a/compiler-bin-v2/lsp/src/server/error.rs b/compiler-bin-v2/lsp/src/server/error.rs new file mode 100644 index 000000000..5dba9a5e9 --- /dev/null +++ b/compiler-bin-v2/lsp/src/server/error.rs @@ -0,0 +1,104 @@ +use std::path::PathBuf; +use std::{io, process, str}; + +use analyzer::AnalyzerError; +use async_lsp::ErrorCode; +use building::QueryError; +use lsp_types::Url; +use spago::LockfileGlobSetError; +use thiserror::Error; +use tokio::task; + +use crate::walk; + +#[derive(Error, Debug)] +pub enum LspError { + #[error("AnalyzerError: {0}")] + AnalyzerError(#[from] AnalyzerError), + #[error("QueryError: {0}")] + QueryError(#[from] QueryError), + #[error("Failed to parse file {0}")] + PathParseFail(PathBuf), + #[error("Expected a file URI, received {0}")] + InvalidFileUri(Url), + #[error("Expected a PureScript or JavaScript document URI, received {0}")] + UnsupportedDocumentUri(Url), + #[error("Invalid content change for document {0}")] + InvalidContentChange(Url), + #[error("UrlParseError: {0}")] + UrlParseError(#[from] url::ParseError), + #[error("Invalid or missing workspace root")] + MissingRoot, + #[error("SpagoError: {0}")] + SpagoLock(#[from] LockfileGlobSetError), + #[error("IoError: {0}")] + IoError(#[from] io::Error), + #[error("JoinError: {0}")] + JoinError(#[from] task::JoinError), + #[error("Utf8Error: {0}")] + Utf8Error(#[from] str::Utf8Error), + #[error("Source discovery command failed with {0}")] + SourceCommandFailed(process::ExitStatus), + #[error("GlobSetError: {0}")] + GlobSetError(#[from] globset::Error), + #[error("WalkError: {0}")] + WalkError(#[from] walk::Error), + #[error("async_lsp::Error: {0}")] + AsyncLspError(#[from] async_lsp::Error), +} + +impl LspError { + #[inline] + fn as_query_error(&self) -> Option<&QueryError> { + match self { + LspError::AnalyzerError(AnalyzerError::QueryError(query_error)) => Some(query_error), + LspError::QueryError(query_error) => Some(query_error), + _ => None, + } + } + + pub fn code(&self) -> ErrorCode { + if let Some(QueryError::Cancelled) = self.as_query_error() { + return ErrorCode::REQUEST_CANCELLED; + } + if matches!(self, LspError::AnalyzerError(AnalyzerError::RenameRejected(_))) { + return ErrorCode::INVALID_PARAMS; + } + ErrorCode::REQUEST_FAILED + } + + pub fn message(&self) -> &str { + if let Some(QueryError::Cancelled) = self.as_query_error() { + return "Request cancelled"; + } + if let LspError::AnalyzerError(AnalyzerError::RenameRejected(message)) = self { + return message; + } + "Request failed" + } + + pub fn emit_trace(&self) { + if let Some(QueryError::Cancelled) = self.as_query_error() { + tracing::warn!("{self}") + } else if matches!(self, LspError::AnalyzerError(AnalyzerError::RenameRejected(_))) { + tracing::warn!("{self}") + } else { + tracing::error!("{self}") + } + } +} + +pub trait AnalyzerResultExt { + /// Convenience method for handling an [`AnalyzerError::NonFatal`] + /// error, turning it into [`Result::Ok`] with the given item. + fn on_non_fatal(self, item: T) -> Result; +} + +impl AnalyzerResultExt for Result { + fn on_non_fatal(self, item: T) -> Result { + self.or_else(|error| match error { + AnalyzerError::NonFatal => Ok(item), + _ => Err(LspError::from(error)), + }) + } +} diff --git a/compiler-bin-v2/lsp/src/server/event.rs b/compiler-bin-v2/lsp/src/server/event.rs new file mode 100644 index 000000000..af012b3c6 --- /dev/null +++ b/compiler-bin-v2/lsp/src/server/event.rs @@ -0,0 +1,316 @@ +use std::collections::hash_map::Entry; + +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::server::error::LspError; +use crate::server::{State, StateSnapshot}; + +#[derive(Default)] +pub struct DiagnosticScheduler { + generations: FxHashMap, + jobs: FxHashMap, +} + +struct DiagnosticJob { + running: DiagnosticTicket, + queued: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct DiagnosticTicket { + file_id: FileId, + generation: u64, + version: Option, +} + +impl DiagnosticScheduler { + pub fn invalidate(&mut self, change: &LifecycleChange, files: &FileLifecycle) { + match change.analysis() { + AnalysisInvalidation::None => {} + AnalysisInvalidation::Sources(sources) => { + for file_id in sources { + self.invalidate_source(*file_id); + } + } + AnalysisInvalidation::Workspace => { + let source_ids = files.source_ids(); + let source_ids = source_ids.collect_vec(); + for file_id in source_ids { + self.invalidate_source(file_id); + } + } + } + for removed in change.removed_sources() { + self.invalidate_source(removed.file_id); + } + } + + fn invalidate_source(&mut self, file_id: FileId) { + let generation = self.generations.entry(file_id).or_default(); + *generation = generation + .checked_add(1) + .expect("invariant violated: diagnostic generation overflowed"); + if let Some(job) = self.jobs.get_mut(&file_id) { + job.queued = None; + } + } + + fn schedule(&mut self, file_id: FileId, version: Option) -> Option { + let generation = self.generations.get(&file_id).copied().unwrap_or_default(); + let ticket = DiagnosticTicket { file_id, generation, version }; + match self.jobs.entry(file_id) { + Entry::Vacant(entry) => { + entry.insert(DiagnosticJob { running: ticket, queued: None }); + Some(ticket) + } + Entry::Occupied(mut entry) => { + let job = entry.get_mut(); + if job.running != ticket && job.queued != Some(ticket) { + job.queued = Some(ticket); + } + None + } + } + } + + fn is_running(&self, ticket: DiagnosticTicket) -> bool { + self.jobs.get(&ticket.file_id).is_some_and(|job| job.running == ticket) + } + + fn is_current(&self, ticket: DiagnosticTicket) -> bool { + self.generations.get(&ticket.file_id).copied().unwrap_or_default() == ticket.generation + } + + fn complete(&mut self, ticket: DiagnosticTicket) -> Option { + let job = self.jobs.get_mut(&ticket.file_id)?; + if job.running != ticket { + return None; + } + let next = job.queued.take(); + if let Some(next) = next { + job.running = next; + } else { + self.jobs.remove(&ticket.file_id); + } + next + } +} + +pub fn emit_collect_diagnostics(state: &mut State, uri: Url) -> Result<(), LspError> { + let files = state.files.read(); + let uri = uri.as_str(); + + if let Some(file_id) = files.source_id(uri) { + state.client.emit(CollectDiagnostics(file_id))?; + } + + Ok(()) +} + +pub fn emit_collect_diagnostics_id(state: &mut State, file_id: FileId) -> Result<(), LspError> { + 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 editable_files = files + .source_ids() + .filter(|file_id| files.source_metadata(*file_id).copied().unwrap_or(false)); + for file_id in editable_files { + state.client.emit(CollectDiagnostics(file_id))?; + } + Ok(()) +} + +pub struct CollectDiagnostics(FileId); + +pub fn collect_diagnostics( + state: &mut State, + CollectDiagnostics(file_id): CollectDiagnostics, +) -> Result<(), LspError> { + let version = { + let files = state.files.read(); + if !files.contains_source(file_id) { + return Ok(()); + } + files.source_version(file_id) + }; + if let Some(ticket) = state.diagnostics.schedule(file_id, version) { + start_diagnostics(state, ticket); + } + Ok(()) +} + +fn start_diagnostics(state: &State, ticket: DiagnosticTicket) { + let worker = state.spawn(move |snapshot| { + let _span = tracing::info_span!("collect_diagnostics").entered(); + collect_diagnostics_core(snapshot, ticket) + }); + let client = ClientSocket::clone(&state.client); + task::spawn(async move { + let collected = await_diagnostics(worker).await; + let event = DiagnosticsFinished { ticket, collected }; + if let Err(error) = client.emit(event) { + LspError::from(error).emit_trace(); + } + }); +} + +fn collect_diagnostics_core( + snapshot: StateSnapshot, + ticket: DiagnosticTicket, +) -> Option { + let result = snapshot.with_analyzer_context(|context| { + analyzer::diagnostics::implementation(context, ticket.file_id) + }); + match result { + Ok(collected) => Some(collected), + Err(error) => { + LspError::from(error).emit_trace(); + None + } + } +} + +async fn await_diagnostics( + worker: task::JoinHandle>, +) -> Option { + match worker.await { + Ok(collected) => collected, + Err(error) => { + LspError::JoinError(error).emit_trace(); + None + } + } +} + +pub struct DiagnosticsFinished { + ticket: DiagnosticTicket, + collected: Option, +} + +pub fn finish_diagnostics( + state: &mut State, + DiagnosticsFinished { ticket, collected }: DiagnosticsFinished, +) -> Result<(), LspError> { + let running = state.diagnostics.is_running(ticket); + let current = running && state.diagnostics.is_current(ticket) && { + let files = state.files.read(); + files.contains_source(ticket.file_id) + && files.source_version(ticket.file_id) == ticket.version + }; + let next = state.diagnostics.complete(ticket); + + let publish_result = if current && let Some(collected) = collected { + state.client.publish_diagnostics(PublishDiagnosticsParams { + uri: collected.uri, + diagnostics: collected.diagnostics, + version: ticket.version, + }) + } else { + Ok(()) + }; + if let Some(next) = next { + start_diagnostics(state, next); + } + publish_result.map_err(LspError::from) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use building::QueryEngine; + use building::lifecycle::{ + DiskObservation, FileLifecycle, LifecycleEvent, SourceEvent, SourceUnitKey, + }; + use files::Files; + + use super::{DiagnosticScheduler, await_diagnostics}; + + fn file_id() -> files::FileId { + let mut files = Files::default(); + files.insert("file:///src/Main.purs", "module Main where\n") + } + + #[test] + fn coalesces_requests_to_the_latest_generation() { + let file_id = file_id(); + let mut scheduler = DiagnosticScheduler::default(); + let first = scheduler.schedule(file_id, Some(1)).unwrap(); + + scheduler.invalidate_source(file_id); + assert_eq!(scheduler.schedule(file_id, Some(2)), None); + let queued = scheduler.jobs[&file_id].queued.unwrap(); + assert_eq!(queued.version, Some(2)); + + assert_eq!(scheduler.complete(first), Some(queued)); + assert!(scheduler.is_running(queued)); + } + + #[test] + fn invalidation_discards_a_queued_stale_request() { + let file_id = file_id(); + let mut scheduler = DiagnosticScheduler::default(); + let first = scheduler.schedule(file_id, Some(1)).unwrap(); + scheduler.invalidate_source(file_id); + scheduler.schedule(file_id, Some(2)); + + scheduler.invalidate_source(file_id); + assert_eq!(scheduler.jobs[&file_id].queued, None); + assert_eq!(scheduler.complete(first), None); + assert!(!scheduler.is_current(first)); + } + + #[test] + fn workspace_change_invalidates_unrelated_running_diagnostics() { + let engine = QueryEngine::default(); + let mut lifecycle = FileLifecycle::default(); + let first_unit = SourceUnitKey::new("file:///src/Main.purs", "file:///src/Main.js"); + let event = LifecycleEvent::Source { + unit: first_unit, + event: SourceEvent::DiskObserved { + disk: DiskObservation::Found(Arc::from("module Main where\n")), + metadata: true, + }, + }; + lifecycle.apply(&engine, event); + let first_id = lifecycle.source_id("file:///src/Main.purs").unwrap(); + + let mut scheduler = DiagnosticScheduler::default(); + let ticket = scheduler.schedule(first_id, None).unwrap(); + let second_unit = SourceUnitKey::new("file:///src/Library.purs", "file:///src/Library.js"); + let event = LifecycleEvent::Source { + unit: second_unit, + event: SourceEvent::DiskObserved { + disk: DiskObservation::Found(Arc::from("module Library where\n")), + metadata: true, + }, + }; + let change = lifecycle.apply(&engine, event); + scheduler.invalidate(&change, &lifecycle); + + assert!(!scheduler.is_current(ticket)); + } + + #[tokio::test] + async fn failed_diagnostics_worker_returns_no_result() { + let worker = tokio::spawn(async { + panic!("diagnostics worker failed"); + #[allow(unreachable_code)] + None + }); + + assert!(await_diagnostics(worker).await.is_none()); + } +} diff --git a/compiler-bin-v2/lsp/src/server/extension.rs b/compiler-bin-v2/lsp/src/server/extension.rs new file mode 100644 index 000000000..f12a1bb69 --- /dev/null +++ b/compiler-bin-v2/lsp/src/server/extension.rs @@ -0,0 +1,20 @@ +use lsp_types::*; +use serde::{Deserialize, Serialize}; + +pub enum CustomInitialize {} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CustomInitializeParams { + #[serde(flatten)] + pub initialize_params: InitializeParams, + pub work_done_token: Option, +} + +impl request::Request for CustomInitialize { + type Params = CustomInitializeParams; + + type Result = InitializeResult; + + const METHOD: &'static str = request::Initialize::METHOD; +} diff --git a/compiler-bin-v2/lsp/src/server/tests.rs b/compiler-bin-v2/lsp/src/server/tests.rs new file mode 100644 index 000000000..19d480882 --- /dev/null +++ b/compiler-bin-v2/lsp/src/server/tests.rs @@ -0,0 +1,304 @@ +use std::fs; +use std::sync::Arc; + +use analyzer::position::PositionEncoding; +use async_lsp::ResponseError; +use async_lsp::router::Router; +use building::lifecycle::{ + ContentAuthority, DiskObservation, DocumentKind, ForeignEvent, LifecycleEvent, SourceEvent, + SourceUnitKey, +}; +use configuration::{Configuration, Diagnostics}; +use files::ForeignSourceKind; +use lsp_types::{ + DidCloseTextDocumentParams, Position, Range, TextDocumentContentChangeEvent, + TextDocumentIdentifier, Url, +}; +use tempfile::tempdir; + +use super::{ + State, apply_content_changes, apply_lifecycle_event, did_close, document_kind, + materialize_prim, observe_disk, source_unit_from_document_uri, source_unit_from_foreign_uri, + source_unit_from_source_uri, +}; + +fn test_config() -> Arc { + Arc::new(Configuration { + diagnostics: Diagnostics { on_open: false, on_save: false, on_change: false }, + ..Configuration::default() + }) +} + +fn assert_source_close_result( + source_uri: Url, + foreign_uri: Url, + source_authority: Option, +) { + let unit = source_unit_from_source_uri(&source_uri).unwrap(); + let config = test_config(); + let prim_directory = Arc::new(materialize_prim().unwrap()); + + let (_server, _) = async_lsp::MainLoop::new_server(move |client| { + let mut state = State::new( + Arc::clone(&config), + client, + "iris-lsp".to_string(), + "test".to_string(), + Arc::clone(&prim_directory), + ); + let event = LifecycleEvent::Source { + unit: SourceUnitKey::clone(&unit), + event: SourceEvent::Opened { + text: Arc::from("module Main where\n"), + version: 1, + metadata: true, + }, + }; + apply_lifecycle_event(&mut state, event); + let event = LifecycleEvent::Foreign { + unit: SourceUnitKey::clone(&unit), + kind: ForeignSourceKind::JavaScript, + event: ForeignEvent::DiskObserved { + disk: DiskObservation::Found(Arc::from("export const life = 42;\n")), + }, + }; + apply_lifecycle_event(&mut state, event); + + let parameters = DidCloseTextDocumentParams { + text_document: TextDocumentIdentifier { uri: Url::clone(&source_uri) }, + }; + did_close(&mut state, parameters).unwrap(); + + 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); + + Router::::new(state) + }); +} + +#[test] +fn source_and_foreign_uris_produce_the_same_unit_key() { + let directory = tempdir().unwrap(); + let source_path = directory.path().join("Source Files").join("Main.purs"); + let foreign_path = source_path.with_extension("js"); + let jsx_path = source_path.with_extension("jsx"); + let source_uri = Url::from_file_path(source_path).unwrap(); + let foreign_uri = Url::from_file_path(foreign_path).unwrap(); + let jsx_uri = Url::from_file_path(jsx_path).unwrap(); + + let from_source = source_unit_from_source_uri(&source_uri).unwrap(); + let from_foreign = source_unit_from_foreign_uri(&foreign_uri).unwrap(); + let from_jsx = source_unit_from_foreign_uri(&jsx_uri).unwrap(); + + assert_eq!(from_source, from_foreign); + assert_eq!(from_source, from_jsx); + assert_eq!(from_source.source(), source_uri.as_str()); + assert_eq!(from_source.foreign(), foreign_uri.as_str()); + assert_eq!(from_source.foreign_for(ForeignSourceKind::Jsx), jsx_uri.as_str()); +} + +#[test] +fn localhost_source_and_foreign_uris_keep_the_same_authority() { + let source_uri = + Url::parse("file://localhost/workspace/Source%20Files/Main.purs?view=1#selection").unwrap(); + let foreign_uri = + Url::parse("file://localhost/workspace/Source%20Files/Main.js?view=1#selection").unwrap(); + + let from_source = source_unit_from_source_uri(&source_uri).unwrap(); + let from_foreign = source_unit_from_foreign_uri(&foreign_uri).unwrap(); + + assert_eq!(from_source, from_foreign); + assert_eq!(from_source.source(), source_uri.as_str()); + assert_eq!(from_source.foreign(), foreign_uri.as_str()); +} + +#[test] +fn non_file_document_uris_are_rejected() { + let source_uri = Url::parse("untitled:Main.purs").unwrap(); + assert!(source_unit_from_source_uri(&source_uri).is_err()); +} + +#[test] +fn document_kind_is_bounded_to_source_and_foreign_extensions() { + let source_uri = Url::parse("file:///workspace/Main.purs").unwrap(); + let foreign_uri = Url::parse("file:///workspace/Main.js").unwrap(); + let jsx_uri = Url::parse("file:///workspace/Main.jsx").unwrap(); + let unsupported_uri = Url::parse("file:///workspace/Main.json").unwrap(); + + assert_eq!(document_kind(&source_uri), Some(DocumentKind::Source)); + assert_eq!( + document_kind(&foreign_uri), + Some(DocumentKind::Foreign(ForeignSourceKind::JavaScript)) + ); + assert_eq!(document_kind(&jsx_uri), Some(DocumentKind::Foreign(ForeignSourceKind::Jsx))); + assert_eq!(document_kind(&unsupported_uri), None); + assert!(source_unit_from_document_uri(&unsupported_uri).is_err()); +} + +#[test] +fn closing_a_deleted_source_also_removes_its_deleted_disk_foreign() { + let directory = tempdir().unwrap(); + let source_path = directory.path().join("Main.purs"); + let foreign_path = source_path.with_extension("js"); + let source_uri = Url::from_file_path(source_path).unwrap(); + let foreign_uri = Url::from_file_path(foreign_path).unwrap(); + assert_source_close_result(source_uri, foreign_uri, None); +} + +#[test] +fn failed_source_reload_still_removes_its_deleted_disk_foreign() { + let directory = tempdir().unwrap(); + let source_path = directory.path().join("Main.purs"); + let foreign_path = source_path.with_extension("js"); + fs::write(&source_path, [0xff]).unwrap(); + let source_uri = Url::from_file_path(source_path).unwrap(); + let foreign_uri = Url::from_file_path(foreign_path).unwrap(); + assert_source_close_result(source_uri, foreign_uri, Some(ContentAuthority::Retained)); +} + +#[test] +fn duplicate_source_close_does_not_reconcile_foreign() { + let directory = tempdir().unwrap(); + let source_path = directory.path().join("Main.purs"); + let foreign_path = source_path.with_extension("js"); + fs::write(&source_path, "module Main where\n").unwrap(); + fs::write(&foreign_path, "export const life = 42;\n").unwrap(); + let source_uri = Url::from_file_path(source_path).unwrap(); + 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 prim_directory = Arc::new(materialize_prim().unwrap()); + + let (_server, _) = async_lsp::MainLoop::new_server(move |client| { + let mut state = State::new( + Arc::clone(&config), + client, + "iris-lsp".to_string(), + "test".to_string(), + Arc::clone(&prim_directory), + ); + let event = LifecycleEvent::Source { + unit: SourceUnitKey::clone(&unit), + event: SourceEvent::Opened { + text: Arc::from("module Main where\n"), + version: 1, + metadata: true, + }, + }; + apply_lifecycle_event(&mut state, event); + let event = LifecycleEvent::Foreign { + unit: SourceUnitKey::clone(&unit), + kind: ForeignSourceKind::JavaScript, + event: ForeignEvent::DiskObserved { + disk: DiskObservation::Found(Arc::from("export const life = 42;\n")), + }, + }; + apply_lifecycle_event(&mut state, event); + + let parameters = DidCloseTextDocumentParams { + text_document: TextDocumentIdentifier { uri: Url::clone(&source_uri) }, + }; + 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 parameters = DidCloseTextDocumentParams { + text_document: TextDocumentIdentifier { uri: Url::clone(&source_uri) }, + }; + did_close(&mut state, parameters).unwrap(); + + 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_content(foreign_id).unwrap().as_ref(), + "export const life = 42;\n", + ); + drop(files); + + Router::::new(state) + }); +} + +#[test] +fn disk_observation_distinguishes_content_and_absence() { + let directory = tempdir().unwrap(); + let source_path = directory.path().join("Main.purs"); + let source_uri = Url::from_file_path(&source_path).unwrap(); + + fs::write(&source_path, "module Main where\n").unwrap(); + assert!(matches!( + observe_disk(&source_uri), + DiskObservation::Found(content) if content.as_ref() == "module Main where\n" + )); + + fs::remove_file(source_path).unwrap(); + assert_eq!(observe_disk(&source_uri), DiskObservation::NotFound); +} + +#[test] +fn incremental_content_changes_apply_sequentially() { + let uri = Url::parse("file:///workspace/Main.purs").unwrap(); + let changes = [ + TextDocumentContentChangeEvent { + range: Some(Range::new(Position::new(1, 0), Position::new(1, 4))), + range_length: Some(4), + text: "answer".to_string(), + }, + TextDocumentContentChangeEvent { + range: Some(Range::new(Position::new(1, 9), Position::new(1, 10))), + range_length: Some(1), + text: "42".to_string(), + }, + ]; + + let content = apply_content_changes( + &uri, + "module Main where\nlife = 0\n", + &changes, + PositionEncoding::Utf16, + ) + .unwrap(); + + assert_eq!(content.as_ref(), "module Main where\nanswer = 42\n"); +} + +#[test] +fn incremental_content_changes_use_negotiated_position_encoding() { + let uri = Url::parse("file:///workspace/Main.purs").unwrap(); + let changes = [TextDocumentContentChangeEvent { + range: Some(Range::new(Position::new(0, 3), Position::new(0, 4))), + range_length: Some(1), + text: "c".to_string(), + }]; + + let content = apply_content_changes(&uri, "a😀b", &changes, PositionEncoding::Utf16).unwrap(); + + assert_eq!(content.as_ref(), "a😀c"); +} + +#[test] +fn full_content_change_resets_incremental_change_base() { + let uri = Url::parse("file:///workspace/Main.purs").unwrap(); + let changes = [ + TextDocumentContentChangeEvent { + range: None, + range_length: None, + text: "life = 1".to_string(), + }, + TextDocumentContentChangeEvent { + range: Some(Range::new(Position::new(0, 7), Position::new(0, 8))), + range_length: Some(1), + text: "2".to_string(), + }, + ]; + + let content = + apply_content_changes(&uri, "discarded", &changes, PositionEncoding::Utf16).unwrap(); + + assert_eq!(content.as_ref(), "life = 2"); +} diff --git a/compiler-bin-v2/lsp/src/walk.rs b/compiler-bin-v2/lsp/src/walk.rs new file mode 100644 index 000000000..4d270dc7f --- /dev/null +++ b/compiler-bin-v2/lsp/src/walk.rs @@ -0,0 +1,215 @@ +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 files: Vec, +} + +#[derive(Debug, Error)] +pub enum Error { + #[error(transparent)] + GlobSetError(#[from] globset::Error), + #[error(transparent)] + WalkDirError(#[from] walkdir::Error), +} + +pub fn walk(root: &Path, paths: impl IntoIterator>) -> Result { + walk_filtered(root, paths, std::iter::empty::<&Path>()) +} + +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; + } + + for entry in WalkDir::new(root) { + let path = entry?.into_path(); + if globs.is_match(&path) && !excludes.is_match(&path) { + files_from_glob.insert(path); + } + } + } + + files.extend(files_from_glob); + + Ok(Walk { 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 mut files = files + .into_iter() + .map(|file| file.strip_prefix(root).unwrap().to_string_lossy().replace('\\', "/")) + .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 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(); + } +} From f161e2b8cff4e1c6270a6301409a1121fa24dca8 Mon Sep 17 00:00:00 2001 From: Justin Garcia Date: Sat, 12 Sep 2026 15:47:57 +0000 Subject: [PATCH 10/26] Run LSP scenarios against iris-v2 Amp-Thread-ID: https://ampcode.com/threads/T-01a09469-c4cf-723d-9923-3b8452bea9f2 Co-authored-by: Amp --- tests-e2e/tests/cli.rs | 42 ++ tests-e2e/tests/lsp.rs | 385 ++++++++++-------- .../snapshots/cli__v2_config_conflict.snap | 11 + .../cli__v2_config_file_missing.snap | 7 + .../snapshots/cli__v2_config_invalid.snap | 7 + .../tests/snapshots/cli__v2_help_build.snap | 20 + .../tests/snapshots/cli__v2_help_lsp.snap | 24 ++ .../tests/snapshots/cli__v2_help_root.snap | 19 + tests-e2e/tests/support.rs | 29 +- 9 files changed, 363 insertions(+), 181 deletions(-) create mode 100644 tests-e2e/tests/snapshots/cli__v2_config_conflict.snap create mode 100644 tests-e2e/tests/snapshots/cli__v2_config_file_missing.snap create mode 100644 tests-e2e/tests/snapshots/cli__v2_config_invalid.snap create mode 100644 tests-e2e/tests/snapshots/cli__v2_help_build.snap create mode 100644 tests-e2e/tests/snapshots/cli__v2_help_lsp.snap create mode 100644 tests-e2e/tests/snapshots/cli__v2_help_root.snap diff --git a/tests-e2e/tests/cli.rs b/tests-e2e/tests/cli.rs index fce6fddda..0386095ba 100644 --- a/tests-e2e/tests/cli.rs +++ b/tests-e2e/tests/cli.rs @@ -291,3 +291,45 @@ fn typescript_output_resolves_relative_to_the_working_directory() { assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); assert!(workspace.path().join("generated/docs-schema.ts").is_file()); } + +#[test] +fn v2_exposes_build_and_lsp_only() { + let workspace = TestWorkspace::empty(); + for (name, arguments) in [ + ("v2_help_root", &["--help"][..]), + ("v2_help_build", &["build", "--help"][..]), + ("v2_help_lsp", &["lsp", "--help"][..]), + ] { + let output = workspace.v2_command(arguments); + assert!(output.status.success(), "{name}"); + assert!(!output.stdout.is_empty(), "{name} did not write stdout"); + assert!(output.stderr.is_empty(), "{name} wrote stderr"); + snapshot_output(name, &output); + } + + for command in ["compile", "watch", "docs"] { + let output = workspace.v2_command(&[command]); + assert_eq!(output.status.code(), Some(2), "{command}"); + assert!(output.stdout.is_empty(), "{command} wrote stdout"); + } +} + +#[test] +fn v2_rejects_invalid_lsp_configuration_before_starting() { + let workspace = TestWorkspace::empty(); + let cases: &[(&str, &[&str])] = &[ + ("v2_config_invalid", &["lsp", "--config", "{"]), + ("v2_config_conflict", &["lsp", "--config", "{}", "--config-file", "missing.json"]), + ("v2_config_file_missing", &["lsp", "--config-file", "missing.json"]), + ]; + for (name, arguments) in cases { + let output = workspace.v2_command(arguments); + assert_eq!(output.status.code(), Some(2), "{name}"); + 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(name, &output); + }); + } +} diff --git a/tests-e2e/tests/lsp.rs b/tests-e2e/tests/lsp.rs index a2412a76d..45dadb294 100644 --- a/tests-e2e/tests/lsp.rs +++ b/tests-e2e/tests/lsp.rs @@ -27,7 +27,7 @@ use url::Url; #[path = "support.rs"] mod support; -use support::TestWorkspace; +use support::{IrisExecutable, TestWorkspace}; struct ClientState { configuration: Mutex>, @@ -50,12 +50,14 @@ struct LanguageServer { impl LanguageServer { fn start( workspace: &TestWorkspace, + executable: IrisExecutable, directory: &str, arguments: &[&str], root: &Path, ) -> LanguageServer { LanguageServer::start_with_capabilities( workspace, + executable, directory, arguments, root, @@ -66,6 +68,7 @@ impl LanguageServer { fn start_with_capabilities( workspace: &TestWorkspace, + executable: IrisExecutable, directory: &str, arguments: &[&str], root: &Path, @@ -73,7 +76,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), @@ -129,7 +132,7 @@ impl LanguageServer { }); let runtime = Builder::new_multi_thread().worker_threads(1).enable_all().build().unwrap(); - let command = workspace.command_builder(directory, &arguments); + let command = workspace.command_builder_for(executable, directory, &arguments); let mut command = tokio::process::Command::from(command); command .stdin(Stdio::piped()) @@ -397,47 +400,53 @@ fn assert_diagnostic_triggers_for( #[test] fn empty_configuration_preserves_spago_and_default_diagnostics() { - let workspace = TestWorkspace::empty(); - workspace.write( - "spago.lock", - r#"{"workspace":{"packages":{"application":{"path":"."}}},"packages":{}}"#, - ); - workspace.write("src/Library.purs", "module Library where\nfromSpago = 42\n"); - workspace.write("config/empty.json", "{}"); + for executable in IrisExecutable::ALL { + let workspace = TestWorkspace::empty(); + workspace.write( + "spago.lock", + r#"{"workspace":{"packages":{"application":{"path":"."}}},"packages":{}}"#, + ); + workspace.write("src/Library.purs", "module Library where\nfromSpago = 42\n"); + workspace.write("config/empty.json", "{}"); - let cases: &[&[&str]] = &[ - &["lsp"], - &["lsp", "--config", "null"], - &["lsp", "--config-file", "config/empty.json"], - &[ - "lsp", - "--config", - r#"{"sources":{"kind":"spago"},"diagnostics":{"onOpen":null,"onSave":null,"onChange":null}}"#, - ], - ]; - for arguments in cases { - let mut server = LanguageServer::start(&workspace, "", arguments, workspace.path()); - let symbols = server.request("workspace/symbol", json!({"query": "fromSpago"})); - assert_eq!(symbols.as_array().unwrap().len(), 1, "{symbols}"); - assert_eq!(symbols[0]["name"], "fromSpago"); - assert_diagnostic_triggers(&mut server, workspace.path(), true, true, false); - server.shutdown(); + let cases: &[&[&str]] = &[ + &["lsp"], + &["lsp", "--stdio"], + &["lsp", "--config", "null"], + &["lsp", "--config-file", "config/empty.json"], + &[ + "lsp", + "--config", + r#"{"sources":{"kind":"spago"},"diagnostics":{"onOpen":null,"onSave":null,"onChange":null}}"#, + ], + ]; + for arguments in cases { + let mut server = + LanguageServer::start(&workspace, executable, "", arguments, workspace.path()); + let symbols = server.request("workspace/symbol", json!({"query": "fromSpago"})); + assert_eq!(symbols.as_array().unwrap().len(), 1, "{symbols}"); + assert_eq!(symbols[0]["name"], "fromSpago"); + assert_diagnostic_triggers(&mut server, workspace.path(), true, true, false); + server.shutdown(); + } } } #[test] fn json_inputs_configure_source_commands_and_diagnostic_triggers() { - let workspace = TestWorkspace::empty(); - workspace.write("project/selected/Library.purs", "module Library where\nfromCommand = 42\n"); - workspace.write( - "launcher/source command.mjs", - r#" + for executable in IrisExecutable::ALL { + let workspace = TestWorkspace::empty(); + workspace + .write("project/selected/Library.purs", "module Library where\nfromCommand = 42\n"); + workspace.write( + "launcher/source command.mjs", + r#" import { writeFileSync } from "node:fs"; writeFileSync("arguments.json", JSON.stringify(process.argv.slice(2))); console.log("selected/*.purs"); "#, - ); - let configuration = json!({ + ); + let configuration = json!({ "sources": { "kind": "command", "program": "node", @@ -445,175 +454,193 @@ console.log("selected/*.purs"); }, "diagnostics": {"onOpen": false, "onSave": false, "onChange": true} }).to_string(); - let absolute_path = workspace.path().join("settings/server config.json"); - let root = workspace.path().join("project"); - let cases: &[&[&str]] = &[ - &["lsp", "--config", &configuration], - &["lsp", "--config-file", "../settings/server config.json"], - &["lsp", "--config-file", absolute_path.to_str().unwrap()], - ]; - for arguments in cases { - workspace.write("settings/server config.json", &configuration); - let mut server = LanguageServer::start(&workspace, "launcher", arguments, &root); - workspace.write("settings/server config.json", "invalid after startup"); - let symbols = server.request("workspace/symbol", json!({"query": "fromCommand"})); - assert_eq!(symbols.as_array().unwrap().len(), 1, "{symbols}"); - assert_eq!(symbols[0]["name"], "fromCommand"); - let expected_uri = Url::from_file_path(root.join("selected/Library.purs")).unwrap(); - assert_eq!(symbols[0]["location"]["uri"], expected_uri.as_str()); - let arguments: Value = - serde_json::from_str(&workspace.read("launcher/arguments.json")).unwrap(); - assert_eq!(arguments, json!(["", "path with spaces", "--flag", "λ", "$(not-a-shell)"])); - assert_diagnostic_triggers(&mut server, &root, false, false, true); - server.shutdown(); + let absolute_path = workspace.path().join("settings/server config.json"); + let root = workspace.path().join("project"); + let cases: &[&[&str]] = &[ + &["lsp", "--config", &configuration], + &["lsp", "--config-file", "../settings/server config.json"], + &["lsp", "--config-file", absolute_path.to_str().unwrap()], + ]; + for arguments in cases { + workspace.write("settings/server config.json", &configuration); + let mut server = + LanguageServer::start(&workspace, executable, "launcher", arguments, &root); + workspace.write("settings/server config.json", "invalid after startup"); + let symbols = server.request("workspace/symbol", json!({"query": "fromCommand"})); + assert_eq!(symbols.as_array().unwrap().len(), 1, "{symbols}"); + assert_eq!(symbols[0]["name"], "fromCommand"); + let expected_uri = Url::from_file_path(root.join("selected/Library.purs")).unwrap(); + assert_eq!(symbols[0]["location"]["uri"], expected_uri.as_str()); + let arguments: Value = + serde_json::from_str(&workspace.read("launcher/arguments.json")).unwrap(); + assert_eq!(arguments, json!(["", "path with spaces", "--flag", "λ", "$(not-a-shell)"])); + assert_diagnostic_triggers(&mut server, &root, false, false, true); + server.shutdown(); + } } } #[test] fn partial_diagnostic_configuration_preserves_omitted_triggers() { - let workspace = TestWorkspace::empty(); - workspace.write("spago.lock", r#"{"workspace":{"packages":{}},"packages":{}}"#); - let mut server = LanguageServer::start( - &workspace, - "", - &["lsp", "--config", r#"{"diagnostics":{"onOpen":false}}"#], - workspace.path(), - ); - assert_diagnostic_triggers(&mut server, workspace.path(), false, true, false); - server.shutdown(); + for executable in IrisExecutable::ALL { + let workspace = TestWorkspace::empty(); + workspace.write("spago.lock", r#"{"workspace":{"packages":{}},"packages":{}}"#); + let mut server = LanguageServer::start( + &workspace, + executable, + "", + &["lsp", "--config", r#"{"diagnostics":{"onOpen":false}}"#], + workspace.path(), + ); + assert_diagnostic_triggers(&mut server, workspace.path(), false, true, false); + server.shutdown(); + } } #[test] fn workspace_configuration_applies_initial_and_runtime_snapshots() { - let workspace = TestWorkspace::empty(); - workspace.write("project/startup/Library.purs", "module Library where\nfromStartup = 1\n"); - workspace.write("project/runtime/Library.purs", "module Library where\nfromRuntime = 2\n"); - workspace.write("launcher/startup.mjs", "console.log('../project/startup/*.purs');\n"); - workspace.write("launcher/runtime.mjs", "console.log('../project/runtime/*.purs');\n"); - let startup = r#"{"sources":{"kind":"command","program":"node","arguments":["startup.mjs"]}}"#; - let runtime = json!({ - "sources": {"kind": "command", "program": "node", "arguments": ["runtime.mjs"]}, - "diagnostics": {"onOpen": false, "onSave": false, "onChange": true} - }); - let root = workspace.path().join("project"); - let mut server = LanguageServer::start_with_capabilities( - &workspace, - "launcher", - &["lsp", "--config", startup], - &root, - json!({"workspace": {"configuration": true}}), - Some(runtime), - ); + for executable in IrisExecutable::ALL { + let workspace = TestWorkspace::empty(); + workspace.write("project/startup/Library.purs", "module Library where\nfromStartup = 1\n"); + workspace.write("project/runtime/Library.purs", "module Library where\nfromRuntime = 2\n"); + workspace.write("launcher/startup.mjs", "console.log('../project/startup/*.purs');\n"); + workspace.write("launcher/runtime.mjs", "console.log('../project/runtime/*.purs');\n"); + let startup = + r#"{"sources":{"kind":"command","program":"node","arguments":["startup.mjs"]}}"#; + let runtime = json!({ + "sources": {"kind": "command", "program": "node", "arguments": ["runtime.mjs"]}, + "diagnostics": {"onOpen": false, "onSave": false, "onChange": true} + }); + let root = workspace.path().join("project"); + let mut server = LanguageServer::start_with_capabilities( + &workspace, + executable, + "launcher", + &["lsp", "--config", startup], + &root, + json!({"workspace": {"configuration": true}}), + Some(runtime), + ); - server.wait_for_symbol("fromRuntime", true); - server.wait_for_symbol("fromStartup", false); - assert_diagnostic_triggers(&mut server, &root, false, false, true); - let runtime_uri = Url::from_file_path(root.join("runtime/Library.purs")).unwrap(); - server.notify( - "textDocument/didOpen", - json!({ - "textDocument": { - "uri": runtime_uri, - "languageId": "purescript", - "version": 1, - "text": "module Library where\nunsavedRuntime = 3\n" - } - }), - ); - server.wait_for_symbol("unsavedRuntime", true); + server.wait_for_symbol("fromRuntime", true); + server.wait_for_symbol("fromStartup", false); + assert_diagnostic_triggers(&mut server, &root, false, false, true); + let runtime_uri = Url::from_file_path(root.join("runtime/Library.purs")).unwrap(); + server.notify( + "textDocument/didOpen", + json!({ + "textDocument": { + "uri": runtime_uri, + "languageId": "purescript", + "version": 1, + "text": "module Library where\nunsavedRuntime = 3\n" + } + }), + ); + server.wait_for_symbol("unsavedRuntime", true); - server.set_configuration(json!({"diagnostics": {"onOpen": true}})); - server.wait_for_symbol("fromStartup", true); - server.wait_for_symbol("fromRuntime", false); - server.wait_for_symbol("unsavedRuntime", true); - server.notify("textDocument/didClose", json!({"textDocument": {"uri": runtime_uri}})); - server.wait_for_symbol("unsavedRuntime", false); - assert_diagnostic_triggers_for(&mut server, &root, "AfterUpdate.purs", true, true, false); - assert!(server.client.configuration_requests.load(Ordering::Relaxed) >= 2); - server.shutdown(); + server.set_configuration(json!({"diagnostics": {"onOpen": true}})); + server.wait_for_symbol("fromStartup", true); + server.wait_for_symbol("fromRuntime", false); + server.wait_for_symbol("unsavedRuntime", true); + server.notify("textDocument/didClose", json!({"textDocument": {"uri": runtime_uri}})); + server.wait_for_symbol("unsavedRuntime", false); + assert_diagnostic_triggers_for(&mut server, &root, "AfterUpdate.purs", true, true, false); + assert!(server.client.configuration_requests.load(Ordering::Relaxed) >= 2); + server.shutdown(); + } } #[test] fn invalid_runtime_configuration_preserves_the_previous_workspace() { - let workspace = TestWorkspace::empty(); - workspace.write( - "spago.lock", - r#"{"workspace":{"packages":{"application":{"path":"."}}},"packages":{}}"#, - ); - workspace.write("src/Library.purs", "module Library where\nstillLoaded = 42\n"); - workspace.write( - "slow failure.mjs", - r#" + for executable in IrisExecutable::ALL { + let workspace = TestWorkspace::empty(); + workspace.write( + "spago.lock", + r#"{"workspace":{"packages":{"application":{"path":"."}}},"packages":{}}"#, + ); + workspace.write("src/Library.purs", "module Library where\nstillLoaded = 42\n"); + workspace.write( + "slow failure.mjs", + r#" setTimeout(() => { process.stderr.write("slow failure\n"); process.exit(1); }, 1000); "#, - ); - let mut server = LanguageServer::start_with_capabilities( - &workspace, - "", - &["lsp"], - workspace.path(), - json!({ - "workspace": { - "configuration": true, - "didChangeConfiguration": {"dynamicRegistration": true} - } - }), - Some(json!({})), - ); - server.wait_for_symbol("stillLoaded", true); + ); + let mut server = LanguageServer::start_with_capabilities( + &workspace, + executable, + "", + &["lsp"], + workspace.path(), + json!({ + "workspace": { + "configuration": true, + "didChangeConfiguration": {"dynamicRegistration": true} + } + }), + Some(json!({})), + ); + server.wait_for_symbol("stillLoaded", true); - server.set_configuration(json!({"diagnostics": {"onOpen": "invalid"}})); - let message = server.wait_for_notification("window/showMessage"); - assert_eq!(message["params"]["type"], 1); - assert!( - message["params"]["message"] - .as_str() - .unwrap() - .contains("previous Iris settings remain active") - ); - server.wait_for_symbol("stillLoaded", true); + server.set_configuration(json!({"diagnostics": {"onOpen": "invalid"}})); + let message = server.wait_for_notification("window/showMessage"); + assert_eq!(message["params"]["type"], 1); + assert!( + message["params"]["message"] + .as_str() + .unwrap() + .contains("previous Iris settings remain active") + ); + server.wait_for_symbol("stillLoaded", true); - server.set_configuration(json!({ - "sources": { - "kind": "command", - "program": "node", - "arguments": ["slow failure.mjs"] - } - })); - let message = server.wait_for_notification("window/showMessage"); - assert!( - message["params"]["message"].as_str().unwrap().contains("Failed to apply Iris settings") - ); - server.wait_for_symbol("stillLoaded", true); - assert!( - server - .client - .registrations - .lock() - .unwrap() - .iter() - .any(|registration| registration.method == "workspace/didChangeConfiguration") - ); - server.shutdown(); + server.set_configuration(json!({ + "sources": { + "kind": "command", + "program": "node", + "arguments": ["slow failure.mjs"] + } + })); + let message = server.wait_for_notification("window/showMessage"); + assert!( + message["params"]["message"] + .as_str() + .unwrap() + .contains("Failed to apply Iris settings") + ); + server.wait_for_symbol("stillLoaded", true); + assert!( + server + .client + .registrations + .lock() + .unwrap() + .iter() + .any(|registration| registration.method == "workspace/didChangeConfiguration") + ); + server.shutdown(); + } } #[test] fn clients_without_workspace_configuration_keep_startup_settings() { - let workspace = TestWorkspace::empty(); - workspace.write( - "spago.lock", - r#"{"workspace":{"packages":{"application":{"path":"."}}},"packages":{}}"#, - ); - workspace.write("src/Library.purs", "module Library where\nstartupOnly = 42\n"); - let mut server = LanguageServer::start(&workspace, "", &["lsp"], workspace.path()); - server.notify( - "workspace/didChangeConfiguration", - json!({"settings": {"sources": {"kind": "command", "program": "missing"}}}), - ); - server.wait_for_symbol("startupOnly", true); - assert_eq!(server.client.configuration_requests.load(Ordering::Relaxed), 0); - server.shutdown(); + for executable in IrisExecutable::ALL { + let workspace = TestWorkspace::empty(); + workspace.write( + "spago.lock", + r#"{"workspace":{"packages":{"application":{"path":"."}}},"packages":{}}"#, + ); + workspace.write("src/Library.purs", "module Library where\nstartupOnly = 42\n"); + let mut server = + LanguageServer::start(&workspace, executable, "", &["lsp"], workspace.path()); + server.notify( + "workspace/didChangeConfiguration", + json!({"settings": {"sources": {"kind": "command", "program": "missing"}}}), + ); + server.wait_for_symbol("startupOnly", true); + assert_eq!(server.client.configuration_requests.load(Ordering::Relaxed), 0); + server.shutdown(); + } } diff --git a/tests-e2e/tests/snapshots/cli__v2_config_conflict.snap b/tests-e2e/tests/snapshots/cli__v2_config_conflict.snap new file mode 100644 index 000000000..bd0a68351 --- /dev/null +++ b/tests-e2e/tests/snapshots/cli__v2_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-v2 lsp [FLAGS] + +For more information, try '--help'. diff --git a/tests-e2e/tests/snapshots/cli__v2_config_file_missing.snap b/tests-e2e/tests/snapshots/cli__v2_config_file_missing.snap new file mode 100644 index 000000000..7f8d7770e --- /dev/null +++ b/tests-e2e/tests/snapshots/cli__v2_config_file_missing.snap @@ -0,0 +1,7 @@ +--- +source: tests-e2e/tests/cli.rs +--- +status: 2 +--- stdout +--- stderr +error: failed to read configuration file missing.json: [FILE NOT FOUND] diff --git a/tests-e2e/tests/snapshots/cli__v2_config_invalid.snap b/tests-e2e/tests/snapshots/cli__v2_config_invalid.snap new file mode 100644 index 000000000..56e35b9f4 --- /dev/null +++ b/tests-e2e/tests/snapshots/cli__v2_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__v2_help_build.snap b/tests-e2e/tests/snapshots/cli__v2_help_build.snap new file mode 100644 index 000000000..daff971d9 --- /dev/null +++ b/tests-e2e/tests/snapshots/cli__v2_help_build.snap @@ -0,0 +1,20 @@ +--- +source: tests-e2e/tests/cli.rs +--- +status: 0 +--- stdout +Build a Spago workspace or package. + +Usage: iris-v2 build [FLAGS] + +Flags: + -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__v2_help_lsp.snap b/tests-e2e/tests/snapshots/cli__v2_help_lsp.snap new file mode 100644 index 000000000..da8196af7 --- /dev/null +++ b/tests-e2e/tests/snapshots/cli__v2_help_lsp.snap @@ -0,0 +1,24 @@ +--- +source: tests-e2e/tests/cli.rs +--- +status: 0 +--- stdout +Run the language server over standard input and output. + +Usage: iris-v2 lsp [FLAGS] + +Flags: + --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__v2_help_root.snap b/tests-e2e/tests/snapshots/cli__v2_help_root.snap new file mode 100644 index 000000000..b2a498615 --- /dev/null +++ b/tests-e2e/tests/snapshots/cli__v2_help_root.snap @@ -0,0 +1,19 @@ +--- +source: tests-e2e/tests/cli.rs +--- +status: 0 +--- stdout +iris-v2 0.1.0 +Experimental PureScript compiler command-line interface. + +Usage: iris-v2 + +Commands: + build Build a Spago workspace or package. + lsp Run the language server over standard input and output. + help Print this message or the help of the given subcommand(s) + +Flags: + -h, --help Print help + -V, --version Print version +--- stderr diff --git a/tests-e2e/tests/support.rs b/tests-e2e/tests/support.rs index 50624b056..d239baaf3 100644 --- a/tests-e2e/tests/support.rs +++ b/tests-e2e/tests/support.rs @@ -4,10 +4,22 @@ use std::fs; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Output, Stdio}; +use itertools::Itertools; + pub struct TestWorkspace { temporary: tempfile::TempDir, } +#[derive(Clone, Copy, Debug)] +pub enum IrisExecutable { + V1, + V2, +} + +impl IrisExecutable { + pub const ALL: [IrisExecutable; 2] = [IrisExecutable::V1, IrisExecutable::V2]; +} + impl TestWorkspace { pub fn empty() -> TestWorkspace { TestWorkspace { temporary: tempfile::tempdir().unwrap() } @@ -77,7 +89,20 @@ impl TestWorkspace { } pub fn command_builder(&self, directory: &str, arguments: &[&str]) -> Command { - self.command_builder_with(env!("CARGO_BIN_EXE_iris-e2e"), directory, arguments) + self.command_builder_for(IrisExecutable::V1, directory, arguments) + } + + pub fn command_builder_for( + &self, + executable: IrisExecutable, + directory: &str, + arguments: &[&str], + ) -> Command { + let executable = match executable { + IrisExecutable::V1 => env!("CARGO_BIN_EXE_iris-e2e"), + IrisExecutable::V2 => env!("CARGO_BIN_EXE_iris-v2-e2e"), + }; + self.command_builder_with(executable, directory, arguments) } fn command_builder_with( @@ -114,7 +139,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); } From 010774ee6ad23084cafec9c55328d6970bea0ee3 Mon Sep 17 00:00:00 2001 From: Justin Garcia Date: Sat, 12 Sep 2026 16:05:17 +0000 Subject: [PATCH 11/26] Add package management to iris-v2 Amp-Thread-ID: https://ampcode.com/threads/T-01a09469-c4cf-723d-9923-3b8452bea9f2 Co-authored-by: Amp --- Cargo.lock | 13 ++ Cargo.toml | 1 + compiler-bin-v2/iris/Cargo.toml | 1 + compiler-bin-v2/iris/src/cli.rs | 45 ++++ compiler-bin-v2/iris/src/lib.rs | 14 ++ compiler-bin-v2/package-manager/Cargo.toml | 16 ++ .../package-manager/bundled/project/Main.purs | 10 + .../bundled/project/Test.Main.purs | 11 + .../package-manager/bundled/project/gitignore | 2 + compiler-bin-v2/package-manager/src/lib.rs | 139 ++++++++++++ .../package-manager/src/workspace.rs | 206 ++++++++++++++++++ 11 files changed, 458 insertions(+) create mode 100644 compiler-bin-v2/package-manager/Cargo.toml create mode 100644 compiler-bin-v2/package-manager/bundled/project/Main.purs create mode 100644 compiler-bin-v2/package-manager/bundled/project/Test.Main.purs create mode 100644 compiler-bin-v2/package-manager/bundled/project/gitignore create mode 100644 compiler-bin-v2/package-manager/src/lib.rs create mode 100644 compiler-bin-v2/package-manager/src/workspace.rs diff --git a/Cargo.lock b/Cargo.lock index 2394a367e..c74f8a11b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1793,6 +1793,18 @@ dependencies = [ "walkdir", ] +[[package]] +name = "iris-package-manager" +version = "0.1.0" +dependencies = [ + "ignore", + "itertools 0.15.0", + "serde", + "serde_yml", + "spago", + "thiserror", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -3073,6 +3085,7 @@ dependencies = [ "configuration", "iris-build", "iris-lsp", + "iris-package-manager", "itertools 0.15.0", "serde_json", "thiserror", diff --git a/Cargo.toml b/Cargo.toml index 7f990ceb1..954f83f31 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "compiler-bin-v2/build", "compiler-bin-v2/iris", "compiler-bin-v2/lsp", + "compiler-bin-v2/package-manager", "compiler-bin-v2/progress", "compiler-backend/*", "compiler-core/*", diff --git a/compiler-bin-v2/iris/Cargo.toml b/compiler-bin-v2/iris/Cargo.toml index c19a830bb..ae2c12369 100644 --- a/compiler-bin-v2/iris/Cargo.toml +++ b/compiler-bin-v2/iris/Cargo.toml @@ -19,6 +19,7 @@ path = "src/bin/iris.rs" configuration = { version = "0.1.0", path = "../../compiler-lsp/configuration" } iris-build = { version = "0.1.0", path = "../build" } iris-lsp = { version = "0.1.0", path = "../lsp" } +iris-package-manager = { version = "0.1.0", path = "../package-manager" } itertools = "0.15.0" serde_json = "1.0.151" thiserror = "2.0.20" diff --git a/compiler-bin-v2/iris/src/cli.rs b/compiler-bin-v2/iris/src/cli.rs index 9d71649f9..99e62038a 100644 --- a/compiler-bin-v2/iris/src/cli.rs +++ b/compiler-bin-v2/iris/src/cli.rs @@ -5,6 +5,7 @@ use std::{env, fs}; use configuration::{Configuration, ConfigurationSettings}; use iris_build::BuildConfig; +use iris_package_manager::{AddConfig, NewConfig}; use itertools::Itertools; use thiserror::Error; use tracing::level_filters::LevelFilter; @@ -27,6 +28,10 @@ pub struct Program { #[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), /// Run the language server over standard input and output. @@ -38,6 +43,46 @@ pub struct LspConfig { pub logging: LoggingFilters, } +#[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 { diff --git a/compiler-bin-v2/iris/src/lib.rs b/compiler-bin-v2/iris/src/lib.rs index 7328c345f..d0d004b5b 100644 --- a/compiler-bin-v2/iris/src/lib.rs +++ b/compiler-bin-v2/iris/src/lib.rs @@ -7,6 +7,20 @@ 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_manager::create(options.into_config()) { + Ok(()) => 0, + Err(error) => { + eprintln!("{error}"); + 1 + } + }, + cli::Command::Add(options) => match iris_package_manager::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) => { diff --git a/compiler-bin-v2/package-manager/Cargo.toml b/compiler-bin-v2/package-manager/Cargo.toml new file mode 100644 index 000000000..ef61276be --- /dev/null +++ b/compiler-bin-v2/package-manager/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "iris-package-manager" +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-v2/package-manager/bundled/project/Main.purs b/compiler-bin-v2/package-manager/bundled/project/Main.purs new file mode 100644 index 000000000..5c18dca55 --- /dev/null +++ b/compiler-bin-v2/package-manager/bundled/project/Main.purs @@ -0,0 +1,10 @@ +module Main where + +import Prelude + +import Effect (Effect) +import Effect.Console (log) + +main :: Effect Unit +main = do + log "🍝" diff --git a/compiler-bin-v2/package-manager/bundled/project/Test.Main.purs b/compiler-bin-v2/package-manager/bundled/project/Test.Main.purs new file mode 100644 index 000000000..3590d4dd2 --- /dev/null +++ b/compiler-bin-v2/package-manager/bundled/project/Test.Main.purs @@ -0,0 +1,11 @@ +module Test.Main where + +import Prelude + +import Effect (Effect) +import Effect.Class.Console (log) + +main :: Effect Unit +main = do + log "🍕" + log "You should add some tests." diff --git a/compiler-bin-v2/package-manager/bundled/project/gitignore b/compiler-bin-v2/package-manager/bundled/project/gitignore new file mode 100644 index 000000000..a4314d5a6 --- /dev/null +++ b/compiler-bin-v2/package-manager/bundled/project/gitignore @@ -0,0 +1,2 @@ +.spago/ +output/ diff --git a/compiler-bin-v2/package-manager/src/lib.rs b/compiler-bin-v2/package-manager/src/lib.rs new file mode 100644 index 000000000..5b6e547ae --- /dev/null +++ b/compiler-bin-v2/package-manager/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-v2/package-manager/src/workspace.rs b/compiler-bin-v2/package-manager/src/workspace.rs new file mode 100644 index 000000000..a11ad1885 --- /dev/null +++ b/compiler-bin-v2/package-manager/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 }) +} From e087e660af6b2e5c673ade7b633e422a56ed7d05 Mon Sep 17 00:00:00 2001 From: Justin Garcia Date: Sat, 12 Sep 2026 16:05:26 +0000 Subject: [PATCH 12/26] Run package management scenarios against iris-v2 Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-01a09469-c4cf-723d-9923-3b8452bea9f2 --- tests-e2e/tests/cli.rs | 9 ++- tests-e2e/tests/package_manager/add.rs | 44 ++++++------ tests-e2e/tests/package_manager/new.rs | 67 ++++++++++++------- .../cli__v2_add_requires_dependencies.snap | 12 ++++ .../tests/snapshots/cli__v2_help_add.snap | 17 +++++ .../tests/snapshots/cli__v2_help_new.snap | 13 ++++ .../tests/snapshots/cli__v2_help_root.snap | 2 + tests-e2e/tests/support.rs | 23 +++++-- 8 files changed, 135 insertions(+), 52 deletions(-) create mode 100644 tests-e2e/tests/snapshots/cli__v2_add_requires_dependencies.snap create mode 100644 tests-e2e/tests/snapshots/cli__v2_help_add.snap create mode 100644 tests-e2e/tests/snapshots/cli__v2_help_new.snap diff --git a/tests-e2e/tests/cli.rs b/tests-e2e/tests/cli.rs index 0386095ba..ec4710d09 100644 --- a/tests-e2e/tests/cli.rs +++ b/tests-e2e/tests/cli.rs @@ -293,10 +293,12 @@ fn typescript_output_resolves_relative_to_the_working_directory() { } #[test] -fn v2_exposes_build_and_lsp_only() { +fn v2_exposes_ported_commands_only() { let workspace = TestWorkspace::empty(); for (name, arguments) in [ ("v2_help_root", &["--help"][..]), + ("v2_help_new", &["new", "--help"][..]), + ("v2_help_add", &["add", "--help"][..]), ("v2_help_build", &["build", "--help"][..]), ("v2_help_lsp", &["lsp", "--help"][..]), ] { @@ -312,6 +314,11 @@ fn v2_exposes_build_and_lsp_only() { assert_eq!(output.status.code(), Some(2), "{command}"); assert!(output.stdout.is_empty(), "{command} wrote stdout"); } + + let output = workspace.v2_command(&["add"]); + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty()); + snapshot_output("v2_add_requires_dependencies", &output); } #[test] diff --git a/tests-e2e/tests/package_manager/add.rs b/tests-e2e/tests/package_manager/add.rs index 43c80b2e4..60432f4d4 100644 --- a/tests-e2e/tests/package_manager/add.rs +++ b/tests-e2e/tests/package_manager/add.rs @@ -1,37 +1,43 @@ -use super::support::{TestWorkspace, assert_success}; +use super::support::{IrisExecutable, TestWorkspace, assert_success}; #[test] fn adds_a_workspace_dependency_with_real_spago() { - let workspace = TestWorkspace::empty(); - workspace.write( - "spago.yaml", - r#"workspace: {} + let mut manifests = vec![]; + for executable in IrisExecutable::ALL { + let workspace = TestWorkspace::empty(); + workspace.write( + "spago.yaml", + r#"workspace: {} "#, - ); - workspace.write( - "packages/application/spago.yaml", - r#"package: + ); + workspace.write( + "packages/application/spago.yaml", + r#"package: name: application dependencies: [] "#, - ); - workspace.write( - "packages/library/spago.yaml", - r#"package: + ); + workspace.write( + "packages/library/spago.yaml", + r#"package: name: library dependencies: [] "#, - ); + ); - let output = workspace.command(&["add", "--package", "application", "library"]); - assert_success(&output); + let output = + workspace.command_for(executable, &["add", "--package", "application", "library"]); + assert_success(&output); + manifests.push(workspace.read("packages/application/spago.yaml")); + assert!(workspace.path().join("spago.lock").is_file()); + workspace.assert_spago_calls("", &[&["fetch", "-p", "application", "library"]]); + } + assert!(manifests.windows(2).all(|pair| pair[0] == pair[1])); - insta::assert_snapshot!(workspace.read("packages/application/spago.yaml"), @r#" + insta::assert_snapshot!(manifests[0], @r#" package: name: application 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/new.rs b/tests-e2e/tests/package_manager/new.rs index e9cca6c6c..e5a567b2d 100644 --- a/tests-e2e/tests/package_manager/new.rs +++ b/tests-e2e/tests/package_manager/new.rs @@ -1,13 +1,18 @@ -use super::support::{TestWorkspace, assert_success}; +use super::support::{IrisExecutable, TestWorkspace, assert_success}; #[test] fn creates_a_spago_project_without_running_spago() { - let workspace = TestWorkspace::empty(); - let output = workspace.command(&["new", "--name", "example"]); - assert_success(&output); - workspace.assert_spago_calls("", &[]); + let mut summaries = vec![]; + for executable in IrisExecutable::ALL { + let workspace = TestWorkspace::empty(); + let output = workspace.command_for(executable, &["new", "--name", "example"]); + assert_success(&output); + workspace.assert_spago_calls("", &[]); + summaries.push(workspace.summary()); + } + assert!(summaries.windows(2).all(|pair| pair[0] == pair[1])); - insta::assert_snapshot!(workspace.summary(), @r#" + insta::assert_snapshot!(summaries[0], @r#" --- .gitignore .spago/ output/ @@ -51,38 +56,48 @@ fn creates_a_spago_project_without_running_spago() { #[test] fn does_not_overwrite_existing_project_files() { - let workspace = TestWorkspace::empty(); - workspace.write("src/Main.purs", "original"); + for executable in IrisExecutable::ALL { + let workspace = TestWorkspace::empty(); + workspace.write("src/Main.purs", "original"); - let output = workspace.command(&["new", "--name", "example"]); - assert!(!output.status.success()); - assert_eq!(workspace.read("src/Main.purs"), "original"); - assert!(!workspace.path().join("spago.yaml").exists()); + let output = workspace.command_for(executable, &["new", "--name", "example"]); + assert!(!output.status.success()); + assert_eq!(workspace.read("src/Main.purs"), "original"); + assert!(!workspace.path().join("spago.yaml").exists()); + } } #[test] fn does_not_create_a_partial_project_when_a_source_directory_is_a_file() { - let workspace = TestWorkspace::empty(); - workspace.write("src", "original"); + for executable in IrisExecutable::ALL { + let workspace = TestWorkspace::empty(); + workspace.write("src", "original"); - let output = workspace.command(&["new", "--name", "example"]); - assert!(!output.status.success()); - assert_eq!(workspace.read("src"), "original"); - assert!(!workspace.path().join("spago.yaml").exists()); + let output = workspace.command_for(executable, &["new", "--name", "example"]); + assert!(!output.status.success()); + assert_eq!(workspace.read("src"), "original"); + assert!(!workspace.path().join("spago.yaml").exists()); + } } #[test] fn does_not_create_a_nested_workspace() { - let workspace = TestWorkspace::empty(); - workspace.write( - "spago.yaml", - r#"workspace: {} + for executable in IrisExecutable::ALL { + let workspace = TestWorkspace::empty(); + workspace.write( + "spago.yaml", + r#"workspace: {} package: name: root "#, - ); + ); - let output = workspace.command_in("packages/application", &["new", "--name", "application"]); - assert!(!output.status.success()); - assert!(!workspace.path().join("packages/application/spago.yaml").exists()); + let output = workspace.command_in_for( + executable, + "packages/application", + &["new", "--name", "application"], + ); + assert!(!output.status.success()); + assert!(!workspace.path().join("packages/application/spago.yaml").exists()); + } } diff --git a/tests-e2e/tests/snapshots/cli__v2_add_requires_dependencies.snap b/tests-e2e/tests/snapshots/cli__v2_add_requires_dependencies.snap new file mode 100644 index 000000000..5c6f0ae92 --- /dev/null +++ b/tests-e2e/tests/snapshots/cli__v2_add_requires_dependencies.snap @@ -0,0 +1,12 @@ +--- +source: tests-e2e/tests/cli.rs +--- +status: 2 +--- stdout +--- stderr +error: the following required arguments were not provided: + … + +Usage: iris-v2 add [-p --package ] [--test] … + +For more information, try '--help'. diff --git a/tests-e2e/tests/snapshots/cli__v2_help_add.snap b/tests-e2e/tests/snapshots/cli__v2_help_add.snap new file mode 100644 index 000000000..9429a1559 --- /dev/null +++ b/tests-e2e/tests/snapshots/cli__v2_help_add.snap @@ -0,0 +1,17 @@ +--- +source: tests-e2e/tests/cli.rs +--- +status: 0 +--- stdout +Add dependencies to a Spago package. + +Usage: iris-v2 add [-p --package ] [--test] … + +Arguments: + … Packages to add. + +Flags: + -p, --package Workspace package whose dependencies should change. + --test Add packages as test dependencies. + -h, --help Print help +--- stderr diff --git a/tests-e2e/tests/snapshots/cli__v2_help_new.snap b/tests-e2e/tests/snapshots/cli__v2_help_new.snap new file mode 100644 index 000000000..d6b722b39 --- /dev/null +++ b/tests-e2e/tests/snapshots/cli__v2_help_new.snap @@ -0,0 +1,13 @@ +--- +source: tests-e2e/tests/cli.rs +--- +status: 0 +--- stdout +Create a Spago project in the current directory. + +Usage: iris-v2 new [--name ] + +Flags: + --name Package name. Defaults to the current directory name. + -h, --help Print help +--- stderr diff --git a/tests-e2e/tests/snapshots/cli__v2_help_root.snap b/tests-e2e/tests/snapshots/cli__v2_help_root.snap index b2a498615..54af2cb77 100644 --- a/tests-e2e/tests/snapshots/cli__v2_help_root.snap +++ b/tests-e2e/tests/snapshots/cli__v2_help_root.snap @@ -9,8 +9,10 @@ Experimental PureScript compiler command-line interface. Usage: iris-v2 Commands: + 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. help Print this message or the help of the given subcommand(s) Flags: diff --git a/tests-e2e/tests/support.rs b/tests-e2e/tests/support.rs index d239baaf3..a83df84cc 100644 --- a/tests-e2e/tests/support.rs +++ b/tests-e2e/tests/support.rs @@ -58,21 +58,32 @@ impl TestWorkspace { } pub fn command(&self, arguments: &[&str]) -> Output { - self.command_in("", arguments) + self.command_for(IrisExecutable::V1, arguments) } pub fn command_in(&self, directory: &str, arguments: &[&str]) -> Output { - self.command_builder(directory, arguments).output().unwrap() + self.command_in_for(IrisExecutable::V1, directory, arguments) + } + + pub fn command_for(&self, executable: IrisExecutable, arguments: &[&str]) -> Output { + self.command_in_for(executable, "", arguments) + } + + pub fn command_in_for( + &self, + executable: IrisExecutable, + directory: &str, + arguments: &[&str], + ) -> Output { + self.command_builder_for(executable, directory, arguments).output().unwrap() } pub fn v2_command(&self, arguments: &[&str]) -> Output { - self.v2_command_in("", arguments) + self.command_for(IrisExecutable::V2, arguments) } pub fn v2_command_in(&self, directory: &str, arguments: &[&str]) -> Output { - self.command_builder_with(env!("CARGO_BIN_EXE_iris-v2-e2e"), directory, arguments) - .output() - .unwrap() + self.command_in_for(IrisExecutable::V2, directory, arguments) } pub fn spawn(&self, arguments: &[&str]) -> Child { From 7d10f9aee1131ee32dc4aad12109f7002ceb927e Mon Sep 17 00:00:00 2001 From: Justin Garcia Date: Sat, 12 Sep 2026 16:12:22 +0000 Subject: [PATCH 13/26] Normalize iris-v2 crate names Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-01a09469-c4cf-723d-9923-3b8452bea9f2 --- Cargo.lock | 58 +++++++++---------- Cargo.toml | 10 ++-- .../{build => iris-build}/Cargo.toml | 2 +- .../{build => iris-build}/src/compilation.rs | 0 .../{build => iris-build}/src/compile.rs | 0 .../{build => iris-build}/src/events.rs | 2 +- .../{build => iris-build}/src/executor.rs | 0 .../{build => iris-build}/src/lib.rs | 0 .../{build => iris-build}/src/plan.rs | 0 .../{build => iris-build}/src/project.rs | 2 +- .../{build => iris-build}/src/walk.rs | 0 .../{build => iris-build}/src/workspace.rs | 0 compiler-bin-v2/{iris => iris-cli}/Cargo.toml | 8 +-- compiler-bin-v2/{iris => iris-cli}/build.rs | 0 compiler-bin-v2/iris-cli/src/bin/iris.rs | 3 + compiler-bin-v2/{iris => iris-cli}/src/cli.rs | 2 +- compiler-bin-v2/{iris => iris-cli}/src/lib.rs | 4 +- .../{iris => iris-cli}/src/logging.rs | 2 +- compiler-bin-v2/{lsp => iris-lsp}/Cargo.toml | 0 compiler-bin-v2/{lsp => iris-lsp}/src/lib.rs | 0 .../{lsp => iris-lsp}/src/server.rs | 0 .../src/server/capabilities.rs | 0 .../{lsp => iris-lsp}/src/server/error.rs | 0 .../{lsp => iris-lsp}/src/server/event.rs | 0 .../{lsp => iris-lsp}/src/server/extension.rs | 0 .../{lsp => iris-lsp}/src/server/tests.rs | 0 compiler-bin-v2/{lsp => iris-lsp}/src/walk.rs | 0 .../Cargo.toml | 2 +- .../bundled/project/Main.purs | 0 .../bundled/project/Test.Main.purs | 0 .../bundled/project/gitignore | 0 .../src/lib.rs | 0 .../src/workspace.rs | 0 .../{progress => iris-progress}/Cargo.toml | 2 +- .../{progress => iris-progress}/src/lib.rs | 0 compiler-bin-v2/iris/src/bin/iris.rs | 3 - tests-e2e/Cargo.toml | 2 +- tests-e2e/src/bin/iris-v2-e2e.rs | 2 +- 38 files changed, 52 insertions(+), 52 deletions(-) rename compiler-bin-v2/{build => iris-build}/Cargo.toml (94%) rename compiler-bin-v2/{build => iris-build}/src/compilation.rs (100%) rename compiler-bin-v2/{build => iris-build}/src/compile.rs (100%) rename compiler-bin-v2/{build => iris-build}/src/events.rs (96%) rename compiler-bin-v2/{build => iris-build}/src/executor.rs (100%) rename compiler-bin-v2/{build => iris-build}/src/lib.rs (100%) rename compiler-bin-v2/{build => iris-build}/src/plan.rs (100%) rename compiler-bin-v2/{build => iris-build}/src/project.rs (98%) rename compiler-bin-v2/{build => iris-build}/src/walk.rs (100%) rename compiler-bin-v2/{build => iris-build}/src/workspace.rs (100%) rename compiler-bin-v2/{iris => iris-cli}/Cargo.toml (77%) rename compiler-bin-v2/{iris => iris-cli}/build.rs (100%) create mode 100644 compiler-bin-v2/iris-cli/src/bin/iris.rs rename compiler-bin-v2/{iris => iris-cli}/src/cli.rs (99%) rename compiler-bin-v2/{iris => iris-cli}/src/lib.rs (89%) rename compiler-bin-v2/{iris => iris-cli}/src/logging.rs (96%) rename compiler-bin-v2/{lsp => iris-lsp}/Cargo.toml (100%) rename compiler-bin-v2/{lsp => iris-lsp}/src/lib.rs (100%) rename compiler-bin-v2/{lsp => iris-lsp}/src/server.rs (100%) rename compiler-bin-v2/{lsp => iris-lsp}/src/server/capabilities.rs (100%) rename compiler-bin-v2/{lsp => iris-lsp}/src/server/error.rs (100%) rename compiler-bin-v2/{lsp => iris-lsp}/src/server/event.rs (100%) rename compiler-bin-v2/{lsp => iris-lsp}/src/server/extension.rs (100%) rename compiler-bin-v2/{lsp => iris-lsp}/src/server/tests.rs (100%) rename compiler-bin-v2/{lsp => iris-lsp}/src/walk.rs (100%) rename compiler-bin-v2/{package-manager => iris-package}/Cargo.toml (93%) rename compiler-bin-v2/{package-manager => iris-package}/bundled/project/Main.purs (100%) rename compiler-bin-v2/{package-manager => iris-package}/bundled/project/Test.Main.purs (100%) rename compiler-bin-v2/{package-manager => iris-package}/bundled/project/gitignore (100%) rename compiler-bin-v2/{package-manager => iris-package}/src/lib.rs (100%) rename compiler-bin-v2/{package-manager => iris-package}/src/workspace.rs (100%) rename compiler-bin-v2/{progress => iris-progress}/Cargo.toml (93%) rename compiler-bin-v2/{progress => iris-progress}/src/lib.rs (100%) delete mode 100644 compiler-bin-v2/iris/src/bin/iris.rs diff --git a/Cargo.lock b/Cargo.lock index c74f8a11b..4b9610644 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -559,16 +559,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "compiler-progress" -version = "0.1.0" -dependencies = [ - "console", - "itertools 0.15.0", - "ratatui", - "terminal-colorsaurus", -] - [[package]] name = "compiler-scripts" version = "0.1.0" @@ -1742,12 +1732,12 @@ name = "iris-build" version = "0.1.0" dependencies = [ "building", - "compiler-progress", "diagnostics", "dunce", "files", "globset", "ignore", + "iris-progress", "itertools 0.15.0", "javascript", "line-index", @@ -1763,6 +1753,22 @@ dependencies = [ "walkdir", ] +[[package]] +name = "iris-cli" +version = "0.1.0" +dependencies = [ + "configuration", + "iris-build", + "iris-lsp", + "iris-package", + "itertools 0.15.0", + "serde_json", + "thiserror", + "tracing", + "tracing-subscriber", + "usage-rs", +] + [[package]] name = "iris-lsp" version = "0.1.0" @@ -1794,7 +1800,7 @@ dependencies = [ ] [[package]] -name = "iris-package-manager" +name = "iris-package" version = "0.1.0" dependencies = [ "ignore", @@ -1805,6 +1811,16 @@ dependencies = [ "thiserror", ] +[[package]] +name = "iris-progress" +version = "0.1.0" +dependencies = [ + "console", + "itertools 0.15.0", + "ratatui", + "terminal-colorsaurus", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -3078,22 +3094,6 @@ dependencies = [ "walkdir", ] -[[package]] -name = "purescript-iris-v2" -version = "0.1.0" -dependencies = [ - "configuration", - "iris-build", - "iris-lsp", - "iris-package-manager", - "itertools 0.15.0", - "serde_json", - "thiserror", - "tracing", - "tracing-subscriber", - "usage-rs", -] - [[package]] name = "quinn" version = "0.11.11" @@ -4186,10 +4186,10 @@ version = "0.1.0" dependencies = [ "async-lsp", "insta", + "iris-cli", "itertools 0.15.0", "lsp-types", "purescript-iris", - "purescript-iris-v2", "regex", "serde_json", "tempfile", diff --git a/Cargo.toml b/Cargo.toml index 954f83f31..330567bc6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,11 +1,11 @@ [workspace] members = [ "compiler-bin", - "compiler-bin-v2/build", - "compiler-bin-v2/iris", - "compiler-bin-v2/lsp", - "compiler-bin-v2/package-manager", - "compiler-bin-v2/progress", + "compiler-bin-v2/iris-build", + "compiler-bin-v2/iris-cli", + "compiler-bin-v2/iris-lsp", + "compiler-bin-v2/iris-package", + "compiler-bin-v2/iris-progress", "compiler-backend/*", "compiler-core/*", "compiler-frontend/*", diff --git a/compiler-bin-v2/build/Cargo.toml b/compiler-bin-v2/iris-build/Cargo.toml similarity index 94% rename from compiler-bin-v2/build/Cargo.toml rename to compiler-bin-v2/iris-build/Cargo.toml index 75f33d7ff..135a1a01e 100644 --- a/compiler-bin-v2/build/Cargo.toml +++ b/compiler-bin-v2/iris-build/Cargo.toml @@ -9,7 +9,7 @@ repository = "https://github.com/purefunctor/purescript-iris" [dependencies] building = { version = "0.1.0", path = "../../compiler-core/building" } -compiler-progress = { version = "0.1.0", path = "../progress" } +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" } diff --git a/compiler-bin-v2/build/src/compilation.rs b/compiler-bin-v2/iris-build/src/compilation.rs similarity index 100% rename from compiler-bin-v2/build/src/compilation.rs rename to compiler-bin-v2/iris-build/src/compilation.rs diff --git a/compiler-bin-v2/build/src/compile.rs b/compiler-bin-v2/iris-build/src/compile.rs similarity index 100% rename from compiler-bin-v2/build/src/compile.rs rename to compiler-bin-v2/iris-build/src/compile.rs diff --git a/compiler-bin-v2/build/src/events.rs b/compiler-bin-v2/iris-build/src/events.rs similarity index 96% rename from compiler-bin-v2/build/src/events.rs rename to compiler-bin-v2/iris-build/src/events.rs index dde9f6ce1..dc49b26b6 100644 --- a/compiler-bin-v2/build/src/events.rs +++ b/compiler-bin-v2/iris-build/src/events.rs @@ -2,7 +2,7 @@ use std::time::Duration; -use compiler_progress::{ProgressEvent, ProgressOutcome, ProgressReporter}; +use iris_progress::{ProgressEvent, ProgressOutcome, ProgressReporter}; use std::sync::Mutex; diff --git a/compiler-bin-v2/build/src/executor.rs b/compiler-bin-v2/iris-build/src/executor.rs similarity index 100% rename from compiler-bin-v2/build/src/executor.rs rename to compiler-bin-v2/iris-build/src/executor.rs diff --git a/compiler-bin-v2/build/src/lib.rs b/compiler-bin-v2/iris-build/src/lib.rs similarity index 100% rename from compiler-bin-v2/build/src/lib.rs rename to compiler-bin-v2/iris-build/src/lib.rs diff --git a/compiler-bin-v2/build/src/plan.rs b/compiler-bin-v2/iris-build/src/plan.rs similarity index 100% rename from compiler-bin-v2/build/src/plan.rs rename to compiler-bin-v2/iris-build/src/plan.rs diff --git a/compiler-bin-v2/build/src/project.rs b/compiler-bin-v2/iris-build/src/project.rs similarity index 98% rename from compiler-bin-v2/build/src/project.rs rename to compiler-bin-v2/iris-build/src/project.rs index 68c91e01d..9282bb5a0 100644 --- a/compiler-bin-v2/build/src/project.rs +++ b/compiler-bin-v2/iris-build/src/project.rs @@ -1,7 +1,7 @@ use std::path::PathBuf; use std::{env, io}; -use compiler_progress::ProgressRuntime; +use iris_progress::ProgressRuntime; use itertools::Itertools; use thiserror::Error; diff --git a/compiler-bin-v2/build/src/walk.rs b/compiler-bin-v2/iris-build/src/walk.rs similarity index 100% rename from compiler-bin-v2/build/src/walk.rs rename to compiler-bin-v2/iris-build/src/walk.rs diff --git a/compiler-bin-v2/build/src/workspace.rs b/compiler-bin-v2/iris-build/src/workspace.rs similarity index 100% rename from compiler-bin-v2/build/src/workspace.rs rename to compiler-bin-v2/iris-build/src/workspace.rs diff --git a/compiler-bin-v2/iris/Cargo.toml b/compiler-bin-v2/iris-cli/Cargo.toml similarity index 77% rename from compiler-bin-v2/iris/Cargo.toml rename to compiler-bin-v2/iris-cli/Cargo.toml index ae2c12369..f547e880f 100644 --- a/compiler-bin-v2/iris/Cargo.toml +++ b/compiler-bin-v2/iris-cli/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "purescript-iris-v2" +name = "iris-cli" version = "0.1.0" edition = "2024" authors = ["Justin Garcia "] @@ -17,9 +17,9 @@ path = "src/bin/iris.rs" [dependencies] configuration = { version = "0.1.0", path = "../../compiler-lsp/configuration" } -iris-build = { version = "0.1.0", path = "../build" } -iris-lsp = { version = "0.1.0", path = "../lsp" } -iris-package-manager = { version = "0.1.0", path = "../package-manager" } +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" } itertools = "0.15.0" serde_json = "1.0.151" thiserror = "2.0.20" diff --git a/compiler-bin-v2/iris/build.rs b/compiler-bin-v2/iris-cli/build.rs similarity index 100% rename from compiler-bin-v2/iris/build.rs rename to compiler-bin-v2/iris-cli/build.rs diff --git a/compiler-bin-v2/iris-cli/src/bin/iris.rs b/compiler-bin-v2/iris-cli/src/bin/iris.rs new file mode 100644 index 000000000..4662be38d --- /dev/null +++ b/compiler-bin-v2/iris-cli/src/bin/iris.rs @@ -0,0 +1,3 @@ +fn main() { + std::process::exit(iris_cli::run()); +} diff --git a/compiler-bin-v2/iris/src/cli.rs b/compiler-bin-v2/iris-cli/src/cli.rs similarity index 99% rename from compiler-bin-v2/iris/src/cli.rs rename to compiler-bin-v2/iris-cli/src/cli.rs index 99e62038a..6feeea2cb 100644 --- a/compiler-bin-v2/iris/src/cli.rs +++ b/compiler-bin-v2/iris-cli/src/cli.rs @@ -5,7 +5,7 @@ use std::{env, fs}; use configuration::{Configuration, ConfigurationSettings}; use iris_build::BuildConfig; -use iris_package_manager::{AddConfig, NewConfig}; +use iris_package::{AddConfig, NewConfig}; use itertools::Itertools; use thiserror::Error; use tracing::level_filters::LevelFilter; diff --git a/compiler-bin-v2/iris/src/lib.rs b/compiler-bin-v2/iris-cli/src/lib.rs similarity index 89% rename from compiler-bin-v2/iris/src/lib.rs rename to compiler-bin-v2/iris-cli/src/lib.rs index d0d004b5b..3e679d83d 100644 --- a/compiler-bin-v2/iris/src/lib.rs +++ b/compiler-bin-v2/iris-cli/src/lib.rs @@ -7,14 +7,14 @@ 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_manager::create(options.into_config()) { + 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_manager::add(options.into_config()) { + cli::Command::Add(options) => match iris_package::add(options.into_config()) { Ok(()) => 0, Err(error) => { eprintln!("{error}"); diff --git a/compiler-bin-v2/iris/src/logging.rs b/compiler-bin-v2/iris-cli/src/logging.rs similarity index 96% rename from compiler-bin-v2/iris/src/logging.rs rename to compiler-bin-v2/iris-cli/src/logging.rs index 3fd17f68f..7a1b6399b 100644 --- a/compiler-bin-v2/iris/src/logging.rs +++ b/compiler-bin-v2/iris-cli/src/logging.rs @@ -37,7 +37,7 @@ where } pub fn start(filters: LoggingFilters) -> io::Result<()> { - let path = std::env::temp_dir().join("purescript-iris-v2.log"); + let path = std::env::temp_dir().join("iris-v2.log"); let file = fs::OpenOptions::new().create(true).append(true).open(path)?; let output_filter = filter::Targets::new() diff --git a/compiler-bin-v2/lsp/Cargo.toml b/compiler-bin-v2/iris-lsp/Cargo.toml similarity index 100% rename from compiler-bin-v2/lsp/Cargo.toml rename to compiler-bin-v2/iris-lsp/Cargo.toml diff --git a/compiler-bin-v2/lsp/src/lib.rs b/compiler-bin-v2/iris-lsp/src/lib.rs similarity index 100% rename from compiler-bin-v2/lsp/src/lib.rs rename to compiler-bin-v2/iris-lsp/src/lib.rs diff --git a/compiler-bin-v2/lsp/src/server.rs b/compiler-bin-v2/iris-lsp/src/server.rs similarity index 100% rename from compiler-bin-v2/lsp/src/server.rs rename to compiler-bin-v2/iris-lsp/src/server.rs diff --git a/compiler-bin-v2/lsp/src/server/capabilities.rs b/compiler-bin-v2/iris-lsp/src/server/capabilities.rs similarity index 100% rename from compiler-bin-v2/lsp/src/server/capabilities.rs rename to compiler-bin-v2/iris-lsp/src/server/capabilities.rs diff --git a/compiler-bin-v2/lsp/src/server/error.rs b/compiler-bin-v2/iris-lsp/src/server/error.rs similarity index 100% rename from compiler-bin-v2/lsp/src/server/error.rs rename to compiler-bin-v2/iris-lsp/src/server/error.rs diff --git a/compiler-bin-v2/lsp/src/server/event.rs b/compiler-bin-v2/iris-lsp/src/server/event.rs similarity index 100% rename from compiler-bin-v2/lsp/src/server/event.rs rename to compiler-bin-v2/iris-lsp/src/server/event.rs diff --git a/compiler-bin-v2/lsp/src/server/extension.rs b/compiler-bin-v2/iris-lsp/src/server/extension.rs similarity index 100% rename from compiler-bin-v2/lsp/src/server/extension.rs rename to compiler-bin-v2/iris-lsp/src/server/extension.rs diff --git a/compiler-bin-v2/lsp/src/server/tests.rs b/compiler-bin-v2/iris-lsp/src/server/tests.rs similarity index 100% rename from compiler-bin-v2/lsp/src/server/tests.rs rename to compiler-bin-v2/iris-lsp/src/server/tests.rs diff --git a/compiler-bin-v2/lsp/src/walk.rs b/compiler-bin-v2/iris-lsp/src/walk.rs similarity index 100% rename from compiler-bin-v2/lsp/src/walk.rs rename to compiler-bin-v2/iris-lsp/src/walk.rs diff --git a/compiler-bin-v2/package-manager/Cargo.toml b/compiler-bin-v2/iris-package/Cargo.toml similarity index 93% rename from compiler-bin-v2/package-manager/Cargo.toml rename to compiler-bin-v2/iris-package/Cargo.toml index ef61276be..7d243ab87 100644 --- a/compiler-bin-v2/package-manager/Cargo.toml +++ b/compiler-bin-v2/iris-package/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "iris-package-manager" +name = "iris-package" version = "0.1.0" edition = "2024" authors = ["Justin Garcia "] diff --git a/compiler-bin-v2/package-manager/bundled/project/Main.purs b/compiler-bin-v2/iris-package/bundled/project/Main.purs similarity index 100% rename from compiler-bin-v2/package-manager/bundled/project/Main.purs rename to compiler-bin-v2/iris-package/bundled/project/Main.purs diff --git a/compiler-bin-v2/package-manager/bundled/project/Test.Main.purs b/compiler-bin-v2/iris-package/bundled/project/Test.Main.purs similarity index 100% rename from compiler-bin-v2/package-manager/bundled/project/Test.Main.purs rename to compiler-bin-v2/iris-package/bundled/project/Test.Main.purs diff --git a/compiler-bin-v2/package-manager/bundled/project/gitignore b/compiler-bin-v2/iris-package/bundled/project/gitignore similarity index 100% rename from compiler-bin-v2/package-manager/bundled/project/gitignore rename to compiler-bin-v2/iris-package/bundled/project/gitignore diff --git a/compiler-bin-v2/package-manager/src/lib.rs b/compiler-bin-v2/iris-package/src/lib.rs similarity index 100% rename from compiler-bin-v2/package-manager/src/lib.rs rename to compiler-bin-v2/iris-package/src/lib.rs diff --git a/compiler-bin-v2/package-manager/src/workspace.rs b/compiler-bin-v2/iris-package/src/workspace.rs similarity index 100% rename from compiler-bin-v2/package-manager/src/workspace.rs rename to compiler-bin-v2/iris-package/src/workspace.rs diff --git a/compiler-bin-v2/progress/Cargo.toml b/compiler-bin-v2/iris-progress/Cargo.toml similarity index 93% rename from compiler-bin-v2/progress/Cargo.toml rename to compiler-bin-v2/iris-progress/Cargo.toml index 726575a0d..5ec90fa27 100644 --- a/compiler-bin-v2/progress/Cargo.toml +++ b/compiler-bin-v2/iris-progress/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "compiler-progress" +name = "iris-progress" version = "0.1.0" edition = "2024" authors = ["Justin Garcia "] diff --git a/compiler-bin-v2/progress/src/lib.rs b/compiler-bin-v2/iris-progress/src/lib.rs similarity index 100% rename from compiler-bin-v2/progress/src/lib.rs rename to compiler-bin-v2/iris-progress/src/lib.rs diff --git a/compiler-bin-v2/iris/src/bin/iris.rs b/compiler-bin-v2/iris/src/bin/iris.rs deleted file mode 100644 index 1bb959bd4..000000000 --- a/compiler-bin-v2/iris/src/bin/iris.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - std::process::exit(purescript_iris_v2::run()); -} diff --git a/tests-e2e/Cargo.toml b/tests-e2e/Cargo.toml index 58a004782..fe236482e 100644 --- a/tests-e2e/Cargo.toml +++ b/tests-e2e/Cargo.toml @@ -7,7 +7,7 @@ autotests = false [dependencies] purescript-iris = { path = "../compiler-bin" } -purescript-iris-v2 = { path = "../compiler-bin-v2/iris" } +iris-cli = { path = "../compiler-bin-v2/iris-cli" } [dev-dependencies] async-lsp = { version = "0.2.4", features = ["tokio"] } diff --git a/tests-e2e/src/bin/iris-v2-e2e.rs b/tests-e2e/src/bin/iris-v2-e2e.rs index 1bb959bd4..4662be38d 100644 --- a/tests-e2e/src/bin/iris-v2-e2e.rs +++ b/tests-e2e/src/bin/iris-v2-e2e.rs @@ -1,3 +1,3 @@ fn main() { - std::process::exit(purescript_iris_v2::run()); + std::process::exit(iris_cli::run()); } From 80c20c4efb79370fc8d3aedae809c9a858d066a4 Mon Sep 17 00:00:00 2001 From: Justin Garcia Date: Sat, 12 Sep 2026 18:47:10 +0000 Subject: [PATCH 14/26] Add persistent build sessions Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-01a09469-c4cf-723d-9923-3b8452bea9f2 --- compiler-bin-v2/iris-build/src/compilation.rs | 24 +- compiler-bin-v2/iris-build/src/compile.rs | 84 ++++- compiler-bin-v2/iris-build/src/lib.rs | 8 +- compiler-bin-v2/iris-build/src/project.rs | 77 ++++- compiler-bin-v2/iris-build/src/session.rs | 308 ++++++++++++++++++ compiler-bin-v2/iris-build/src/walk.rs | 24 +- 6 files changed, 492 insertions(+), 33 deletions(-) create mode 100644 compiler-bin-v2/iris-build/src/session.rs diff --git a/compiler-bin-v2/iris-build/src/compilation.rs b/compiler-bin-v2/iris-build/src/compilation.rs index 4b303cce7..853cff78e 100644 --- a/compiler-bin-v2/iris-build/src/compilation.rs +++ b/compiler-bin-v2/iris-build/src/compilation.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use building::{ DiskObservation, FileLifecycle, ForeignEvent, LifecycleChange, LifecycleEvent, QueryEngine, - SourceEvent, SourceUnitKey, + QueryError, SourceEvent, SourceUnitKey, }; use files::{FileId, ForeignSourceKind}; use itertools::Itertools; @@ -79,6 +79,18 @@ impl CompilationState { sources.collect_vec() } + 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() } @@ -95,6 +107,16 @@ impl CompilationState { .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)] diff --git a/compiler-bin-v2/iris-build/src/compile.rs b/compiler-bin-v2/iris-build/src/compile.rs index 69766a61f..a482ffcf9 100644 --- a/compiler-bin-v2/iris-build/src/compile.rs +++ b/compiler-bin-v2/iris-build/src/compile.rs @@ -46,6 +46,16 @@ pub(crate) enum CompileError { Walk(#[from] walk::Error), } +pub(crate) struct RebuildResult { + pub outcome: BuildOutcome, + pub outputs: BTreeSet, +} + +struct ModuleWrite { + outputs: Vec, + result: Result<(), CompileError>, +} + pub(crate) fn build(config: BuildConfig<'_>) -> Result<(), CompileError> { let BuildConfig { root, output, source_globs, packages, color, diagnostics, resilient, events } = config; @@ -88,7 +98,7 @@ pub(crate) fn build(config: BuildConfig<'_>) -> Result<(), CompileError> { let (diagnostic_collections, has_errors) = collect_diagnostics(&compilation, &all_sources)?; if !has_errors || resilient { let modules = collect_modules(&compilation, &all_sources)?; - write_modules(&compilation, &modules, &output)?; + 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 }); @@ -101,6 +111,30 @@ pub(crate) fn build(config: BuildConfig<'_>) -> Result<(), CompileError> { Ok(()) } +pub(crate) fn rebuild( + compilation: &CompilationState, + root: &Path, + output: &Path, + color: bool, + diagnostics: bool, + owned_outputs: &mut BTreeSet, +) -> Result { + let sources = compilation.input_sources(); + query_package(compilation, &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 }) +} + fn load_source(compilation: &mut CompilationState, path: &Path) -> Result { let source_url = Url::from_file_path(path).map_err(|_| CompileError::InvalidPath(path.to_path_buf()))?; @@ -217,35 +251,59 @@ fn write_modules( compilation: &CompilationState, modules: &[Arc], output: &Path, -) -> Result<(), CompileError> { + owned_outputs: &mut BTreeSet, +) -> Result, CompileError> { + let mut outputs = BTreeSet::new(); if modules.iter().any(|module| module.requires_runtime()) { fs::create_dir_all(output)?; - write_if_changed( - &output.join(javascript::runtime_filename()), - javascript::runtime_source().as_bytes(), - )?; + 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); } - modules.par_iter().try_for_each(|module| write_module(compilation, module, output)) + Ok(outputs) } fn write_module( compilation: &CompilationState, module: &javascript::Module, output: &Path, -) -> Result<(), CompileError> { +) -> ModuleWrite { let output_path = output.join(module.filename()); - fs::create_dir_all( + let result = fs::create_dir_all( output_path.parent().expect("invariant violated: module filename has no parent"), - )?; - write_if_changed(&output_path, module.source().as_bytes())?; + ) + .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"); - write_if_changed(&output_path, foreign.as_bytes())?; + if let Err(error) = write_if_changed(&output_path, foreign.as_bytes()) { + return ModuleWrite { outputs, result: Err(error.into()) }; + } + outputs.push(output_path); } - Ok(()) + ModuleWrite { outputs, result: Ok(()) } } fn write_if_changed(path: &Path, content: &[u8]) -> io::Result<()> { diff --git a/compiler-bin-v2/iris-build/src/lib.rs b/compiler-bin-v2/iris-build/src/lib.rs index 121bd01ef..542199602 100644 --- a/compiler-bin-v2/iris-build/src/lib.rs +++ b/compiler-bin-v2/iris-build/src/lib.rs @@ -5,7 +5,13 @@ pub mod plan; mod compilation; mod compile; mod project; +mod session; mod walk; mod workspace; -pub use project::{BuildConfig, BuildError, build}; +pub use project::{ + BuildConfig, BuildError, PreparedProject, ProjectConfig, ProjectError, build, prepare_project, +}; +pub use session::{ + BuildSession, BuildSessionConfig, InputChange, InputChanges, RebuildOutcome, SessionError, +}; diff --git a/compiler-bin-v2/iris-build/src/project.rs b/compiler-bin-v2/iris-build/src/project.rs index 9282bb5a0..11ad45176 100644 --- a/compiler-bin-v2/iris-build/src/project.rs +++ b/compiler-bin-v2/iris-build/src/project.rs @@ -3,6 +3,7 @@ use std::{env, io}; use iris_progress::ProgressRuntime; use itertools::Itertools; +use path_absolutize::Absolutize; use thiserror::Error; use super::compile::{self, CompileError}; @@ -19,8 +20,14 @@ pub struct BuildConfig { pub diagnostics: bool, } +pub struct ProjectConfig { + pub package: Option, + pub output: Option, + pub quiet: bool, +} + #[derive(Debug, Error)] -enum ProjectError { +enum ProjectFailure { #[error(transparent)] Compile(#[from] CompileError), #[error(transparent)] @@ -31,15 +38,33 @@ enum ProjectError { 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 }, } #[derive(Debug, Error)] #[error(transparent)] -pub struct BuildError(ProjectError); +pub struct BuildError(ProjectFailure); impl BuildError { pub fn diagnostics_were_suppressed(&self) -> bool { - matches!(self.0, ProjectError::Compile(CompileError::Diagnostics { reported: false })) + matches!(self.0, ProjectFailure::Compile(CompileError::Diagnostics { reported: false })) + } +} + +#[derive(Debug, Error)] +#[error(transparent)] +pub struct ProjectError(ProjectFailure); + +pub struct PreparedProject { + pub(crate) root: PathBuf, + pub(crate) output: PathBuf, + pub(crate) source_globs: Vec, +} + +impl PreparedProject { + pub fn root_directory(&self) -> &std::path::Path { + &self.root } } @@ -47,13 +72,17 @@ pub fn build(config: BuildConfig) -> Result<(), BuildError> { build_project(config).map_err(BuildError) } -fn build_project(config: BuildConfig) -> Result<(), ProjectError> { - let current_directory = env::current_dir().map_err(ProjectError::CurrentDirectory)?; - let workspace = Workspace::discover(¤t_directory, config.package.as_deref())?; - let spago = spago::SpagoCommand::new(¤t_directory)?; - spago.fetch(workspace.selected.as_deref(), !config.quiet)?; - let source_globs = spago.source_globs(workspace.selected.as_deref(), !config.quiet)?; - let package_sources = spago::source_files_by_package(&workspace.root)?; +pub fn prepare_project(config: ProjectConfig) -> Result { + prepare_project_inner(config).map_err(ProjectError) +} + +fn build_project(config: BuildConfig) -> Result<(), ProjectFailure> { + let project = prepare_project_inner(ProjectConfig { + package: config.package, + output: config.output, + quiet: config.quiet, + })?; + let package_sources = spago::source_files_by_package(&project.root)?; let progress = ProgressRuntime::start(!config.quiet, config.color); let events = ProgressEventSink::new(progress.reporter()); @@ -67,12 +96,10 @@ fn build_project(config: BuildConfig) -> Result<(), ProjectError> { } }); let packages = packages.collect_vec(); - let output = config.output.unwrap_or_else(|| workspace.root.join("output")); - compile::build(compile::BuildConfig { - root: workspace.root, - output, - source_globs, + root: project.root, + output: project.output, + source_globs: project.source_globs, packages, color: config.color, diagnostics: config.diagnostics, @@ -81,3 +108,23 @@ fn build_project(config: BuildConfig) -> Result<(), ProjectError> { })?; Ok(()) } + +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 spago = spago::SpagoCommand::new(¤t_directory)?; + spago.fetch(workspace.selected.as_deref(), !config.quiet)?; + let source_globs = spago.source_globs(workspace.selected.as_deref(), !config.quiet)?; + let output = if let Some(output) = config.output { + output + .absolutize() + .map_err(|error| ProjectFailure::NormalizeOutput { + path: PathBuf::clone(&output), + error, + })? + .into_owned() + } else { + workspace.root.join("output") + }; + Ok(PreparedProject { root: workspace.root, output, source_globs }) +} diff --git a/compiler-bin-v2/iris-build/src/session.rs b/compiler-bin-v2/iris-build/src/session.rs new file mode 100644 index 000000000..fe26310ce --- /dev/null +++ b/compiler-bin-v2/iris-build/src/session.rs @@ -0,0 +1,308 @@ +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}; +use super::events::BuildOutcome; +use super::project::PreparedProject; +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, + color: bool, + diagnostics: bool, +} + +impl BuildSession { + pub fn new( + project: PreparedProject, + config: BuildSessionConfig, + ) -> Result { + BuildSession::create(project, config).map_err(SessionError) + } + + fn create( + project: PreparedProject, + config: BuildSessionConfig, + ) -> Result { + let walked = walk::walk_filtered(&project.root, &project.source_globs, [&project.output])?; + let source_roots = walked.roots.into_iter().collect_vec(); + Ok(BuildSession { + root: project.root, + output: project.output, + inputs: project.source_globs, + source_roots, + source_globs: walked.globs, + source_paths: BTreeSet::new(), + generated_outputs: BTreeSet::new(), + compilation: CompilationState::new(), + 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 synchronize_paths(&mut self, paths: &[PathBuf]) -> Result { + self.synchronize(paths).map(SessionChange::into_public).map_err(SessionError) + } + + pub fn rescan(&mut self) -> Result { + self.rescan_inputs().map(SessionChange::into_public).map_err(SessionError) + } + + 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 self.compilation.input_sources().is_empty() { + 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, + )?; + match result.outcome { + BuildOutcome::Succeeded => { + self.reconcile_outputs(result.outputs)?; + Ok(RebuildOutcome::Succeeded) + } + BuildOutcome::Diagnostics => Ok(RebuildOutcome::Diagnostics), + } + } + + 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-v2/iris-build/src/walk.rs b/compiler-bin-v2/iris-build/src/walk.rs index b86afb11e..9868a7703 100644 --- a/compiler-bin-v2/iris-build/src/walk.rs +++ b/compiler-bin-v2/iris-build/src/walk.rs @@ -9,6 +9,8 @@ use thiserror::Error; use walkdir::WalkDir; pub struct Walk { + pub roots: BTreeSet, + pub globs: GlobSet, pub files: Vec, } @@ -57,9 +59,11 @@ pub fn walk_filtered( continue; } - for entry in WalkDir::new(root) { + 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) && !excludes.is_match(&path) { + if globs.is_match(&path) { files_from_glob.insert(path); } } @@ -67,7 +71,7 @@ pub fn walk_filtered( files.extend(files_from_glob); - Ok(Walk { files }) + Ok(Walk { roots, globs, files }) } fn build_excludes( @@ -154,6 +158,20 @@ mod tests { 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"); From cfab94ae10793d45bf2472d94ea4b78be77609c6 Mon Sep 17 00:00:00 2001 From: Justin Garcia Date: Sat, 12 Sep 2026 18:47:16 +0000 Subject: [PATCH 15/26] Add reusable watch progress summaries Amp-Thread-ID: https://ampcode.com/threads/T-01a09469-c4cf-723d-9923-3b8452bea9f2 Co-authored-by: Amp --- .../iris-progress/examples/watch.rs | 50 ++++ compiler-bin-v2/iris-progress/src/lib.rs | 213 ++++++++++++++++++ 2 files changed, 263 insertions(+) create mode 100644 compiler-bin-v2/iris-progress/examples/watch.rs diff --git a/compiler-bin-v2/iris-progress/examples/watch.rs b/compiler-bin-v2/iris-progress/examples/watch.rs new file mode 100644 index 000000000..fd3e62c04 --- /dev/null +++ b/compiler-bin-v2/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-v2/iris-progress/src/lib.rs b/compiler-bin-v2/iris-progress/src/lib.rs index eeb719017..056ec388a 100644 --- a/compiler-bin-v2/iris-progress/src/lib.rs +++ b/compiler-bin-v2/iris-progress/src/lib.rs @@ -19,6 +19,7 @@ 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>; @@ -29,6 +30,90 @@ pub enum ProgressOutcome { 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, @@ -592,6 +677,134 @@ mod tests { 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, From 60f696af2d6bb6f1eb78863edbc910841d91bff4 Mon Sep 17 00:00:00 2001 From: Justin Garcia Date: Sat, 12 Sep 2026 18:47:23 +0000 Subject: [PATCH 16/26] Add watch mode to iris-v2 Amp-Thread-ID: https://ampcode.com/threads/T-01a09469-c4cf-723d-9923-3b8452bea9f2 Co-authored-by: Amp --- Cargo.lock | 14 ++ Cargo.toml | 1 + compiler-bin-v2/iris-cli/Cargo.toml | 1 + compiler-bin-v2/iris-cli/src/cli.rs | 50 +++++- compiler-bin-v2/iris-cli/src/lib.rs | 15 ++ compiler-bin-v2/iris-watch/Cargo.toml | 17 ++ compiler-bin-v2/iris-watch/src/lib.rs | 247 ++++++++++++++++++++++++++ 7 files changed, 344 insertions(+), 1 deletion(-) create mode 100644 compiler-bin-v2/iris-watch/Cargo.toml create mode 100644 compiler-bin-v2/iris-watch/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 4b9610644..573f6eef9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1761,6 +1761,7 @@ dependencies = [ "iris-build", "iris-lsp", "iris-package", + "iris-watch", "itertools 0.15.0", "serde_json", "thiserror", @@ -1821,6 +1822,19 @@ dependencies = [ "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" diff --git a/Cargo.toml b/Cargo.toml index 330567bc6..cdc4f37c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "compiler-bin-v2/iris-lsp", "compiler-bin-v2/iris-package", "compiler-bin-v2/iris-progress", + "compiler-bin-v2/iris-watch", "compiler-backend/*", "compiler-core/*", "compiler-frontend/*", diff --git a/compiler-bin-v2/iris-cli/Cargo.toml b/compiler-bin-v2/iris-cli/Cargo.toml index f547e880f..d62a98965 100644 --- a/compiler-bin-v2/iris-cli/Cargo.toml +++ b/compiler-bin-v2/iris-cli/Cargo.toml @@ -20,6 +20,7 @@ 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" diff --git a/compiler-bin-v2/iris-cli/src/cli.rs b/compiler-bin-v2/iris-cli/src/cli.rs index 6feeea2cb..4ecd84191 100644 --- a/compiler-bin-v2/iris-cli/src/cli.rs +++ b/compiler-bin-v2/iris-cli/src/cli.rs @@ -4,7 +4,7 @@ use std::path::PathBuf; use std::{env, fs}; use configuration::{Configuration, ConfigurationSettings}; -use iris_build::BuildConfig; +use iris_build::{BuildConfig, ProjectConfig}; use iris_package::{AddConfig, NewConfig}; use itertools::Itertools; use thiserror::Error; @@ -34,6 +34,8 @@ pub enum Command { 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), } @@ -43,6 +45,11 @@ pub struct LspConfig { 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 { @@ -131,6 +138,47 @@ impl BuildOptions { } } +#[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 { diff --git a/compiler-bin-v2/iris-cli/src/lib.rs b/compiler-bin-v2/iris-cli/src/lib.rs index 3e679d83d..e7311d7ae 100644 --- a/compiler-bin-v2/iris-cli/src/lib.rs +++ b/compiler-bin-v2/iris-cli/src/lib.rs @@ -30,6 +30,21 @@ pub fn run() -> i32 { 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, diff --git a/compiler-bin-v2/iris-watch/Cargo.toml b/compiler-bin-v2/iris-watch/Cargo.toml new file mode 100644 index 000000000..d77a8b835 --- /dev/null +++ b/compiler-bin-v2/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-v2/iris-watch/src/lib.rs b/compiler-bin-v2/iris-watch/src/lib.rs new file mode 100644 index 000000000..fdcfb7c1b --- /dev/null +++ b/compiler-bin-v2/iris-watch/src/lib.rs @@ -0,0 +1,247 @@ +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, RebuildOutcome, + SessionError, +}; +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), +} + +#[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 mut session = BuildSession::new( + project, + BuildSessionConfig { color: config.color, diagnostics: config.diagnostics }, + )?; + let (sender, receiver) = mpsc::channel(); + let mut watcher = RecommendedWatcher::new(sender, notify::Config::default())?; + for root in session.source_roots() { + let root = persistent_watch_root(root); + watcher.watch(&root, RecursiveMode::Recursive)?; + } + + let changes = session.rescan()?; + report_warnings(&changes); + let mut pending_inputs = changes.inputs; + let mut pending_rebuild = + !rebuild_and_report(&mut session, &config, &root, &pending_inputs, true); + + 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); + } +} + +fn rebuild_and_report( + session: &mut BuildSession, + config: &WatchConfig, + root: &Path, + inputs: &[InputChange], + initial: bool, +) -> bool { + let started = Instant::now(); + 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() + } +} From a235919a51e0e9f2b294420fef5343d3d55f783f Mon Sep 17 00:00:00 2001 From: Justin Garcia Date: Sat, 12 Sep 2026 18:47:31 +0000 Subject: [PATCH 17/26] Test iris-v2 watch mode Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-01a09469-c4cf-723d-9923-3b8452bea9f2 --- tests-e2e/tests/cli.rs | 3 +- ...ger__watch__watch_diagnostic_recovery.snap | 20 + ...nager__watch__watch_single_package_v1.snap | 4 + ...nager__watch__watch_single_package_v2.snap | 5 + ...ge_manager__watch__watch_workspace_v1.snap | 4 + ...ge_manager__watch__watch_workspace_v2.snap | 5 + tests-e2e/tests/package_manager/watch.rs | 446 +++++++++++++++--- .../tests/snapshots/cli__v2_help_root.snap | 1 + .../tests/snapshots/cli__v2_help_watch.snap | 19 + tests-e2e/tests/support.rs | 15 +- 10 files changed, 448 insertions(+), 74 deletions(-) create mode 100644 tests-e2e/tests/package_manager/snapshots/package_manager__watch__watch_diagnostic_recovery.snap create mode 100644 tests-e2e/tests/package_manager/snapshots/package_manager__watch__watch_single_package_v1.snap create mode 100644 tests-e2e/tests/package_manager/snapshots/package_manager__watch__watch_single_package_v2.snap create mode 100644 tests-e2e/tests/package_manager/snapshots/package_manager__watch__watch_workspace_v1.snap create mode 100644 tests-e2e/tests/package_manager/snapshots/package_manager__watch__watch_workspace_v2.snap create mode 100644 tests-e2e/tests/snapshots/cli__v2_help_watch.snap diff --git a/tests-e2e/tests/cli.rs b/tests-e2e/tests/cli.rs index ec4710d09..0f21e3317 100644 --- a/tests-e2e/tests/cli.rs +++ b/tests-e2e/tests/cli.rs @@ -300,6 +300,7 @@ fn v2_exposes_ported_commands_only() { ("v2_help_new", &["new", "--help"][..]), ("v2_help_add", &["add", "--help"][..]), ("v2_help_build", &["build", "--help"][..]), + ("v2_help_watch", &["watch", "--help"][..]), ("v2_help_lsp", &["lsp", "--help"][..]), ] { let output = workspace.v2_command(arguments); @@ -309,7 +310,7 @@ fn v2_exposes_ported_commands_only() { snapshot_output(name, &output); } - for command in ["compile", "watch", "docs"] { + for command in ["compile", "docs"] { let output = workspace.v2_command(&[command]); assert_eq!(output.status.code(), Some(2), "{command}"); assert!(output.stdout.is_empty(), "{command} wrote stdout"); 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_v1.snap b/tests-e2e/tests/package_manager/snapshots/package_manager__watch__watch_single_package_v1.snap new file mode 100644 index 000000000..6aff3b6c8 --- /dev/null +++ b/tests-e2e/tests/package_manager/snapshots/package_manager__watch__watch_single_package_v1.snap @@ -0,0 +1,4 @@ +--- +source: tests-e2e/tests/package_manager/watch.rs +--- +[TIME] 1 file changed: Main diff --git a/tests-e2e/tests/package_manager/snapshots/package_manager__watch__watch_single_package_v2.snap b/tests-e2e/tests/package_manager/snapshots/package_manager__watch__watch_single_package_v2.snap new file mode 100644 index 000000000..7721f8c7c --- /dev/null +++ b/tests-e2e/tests/package_manager/snapshots/package_manager__watch__watch_single_package_v2.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_v1.snap b/tests-e2e/tests/package_manager/snapshots/package_manager__watch__watch_workspace_v1.snap new file mode 100644 index 000000000..9ca00dc31 --- /dev/null +++ b/tests-e2e/tests/package_manager/snapshots/package_manager__watch__watch_workspace_v1.snap @@ -0,0 +1,4 @@ +--- +source: tests-e2e/tests/package_manager/watch.rs +--- +[TIME] 2 files changed: Application, Library diff --git a/tests-e2e/tests/package_manager/snapshots/package_manager__watch__watch_workspace_v2.snap b/tests-e2e/tests/package_manager/snapshots/package_manager__watch__watch_workspace_v2.snap new file mode 100644 index 000000000..f4446471d --- /dev/null +++ b/tests-e2e/tests/package_manager/snapshots/package_manager__watch__watch_workspace_v2.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/watch.rs b/tests-e2e/tests/package_manager/watch.rs index 07991e54c..95b39b6cc 100644 --- a/tests-e2e/tests/package_manager/watch.rs +++ b/tests-e2e/tests/package_manager/watch.rs @@ -1,107 +1,302 @@ -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; -use super::support::TestWorkspace; +use super::support::{IrisExecutable, TestWorkspace}; #[test] fn watches_a_single_package_with_real_spago() { - let workspace = TestWorkspace::empty(); - workspace.write( - "spago.yaml", - r#"workspace: {} + for executable in IrisExecutable::ALL { + 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( + "src/Main.purs", + r#"module Main where value = 42 "#, - ); + ); - let child = 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 - "#); - - workspace.assert_spago_calls( - "", - &[&["fetch", "-p", "application"], &["sources", "--json", "-p", "application"]], - ); + let mut watch = WatchProcess::new(workspace.spawn_for(executable, &["watch"])); + let output = workspace.path().join("output/Main/index.js"); + let expected = match executable { + IrisExecutable::V1 => "1 file changed: Main", + IrisExecutable::V2 => "Loaded 1 input: Main", + }; + watch.wait_for("initial compilation", |stdout, _| { + stdout.contains(expected) && output.is_file() + }); + watch.stop(); + let snapshot = format!("watch_single_package_{executable:?}").to_lowercase(); + insta::with_settings!({omit_expression => true}, { + insta::assert_snapshot!(snapshot, normalized_watch_log(&watch.stdout())); + }); + + workspace.assert_spago_calls( + "", + &[&["fetch", "-p", "application"], &["sources", "--json", "-p", "application"]], + ); + } } #[test] fn watches_the_whole_workspace_from_a_root_package_subdirectory() { - let workspace = TestWorkspace::empty(); - workspace.write( - "spago.yaml", - r#"workspace: {} + for executable in IrisExecutable::ALL { + 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: + ); + 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"); + + let mut watch = WatchProcess::new(workspace.spawn_in_for(executable, "src", &["watch"])); + let outputs = [ + workspace.path().join("output/Application/index.js"), + workspace.path().join("output/Library/index.js"), + ]; + let expected = match executable { + IrisExecutable::V1 => "2 files changed: Application, Library", + IrisExecutable::V2 => "Loaded 2 inputs: Application, Library", + }; + watch.wait_for("initial workspace compilation", |stdout, _| { + stdout.contains(expected) && outputs.iter().all(|output| output.is_file()) + }); + watch.stop(); + let snapshot = format!("watch_workspace_{executable:?}").to_lowercase(); + insta::with_settings!({omit_expression => true}, { + insta::assert_snapshot!(snapshot, normalized_watch_log(&watch.stdout())); + }); + + workspace.assert_spago_calls("src", &[&["fetch"], &["sources", "--json"]]); + } +} + +#[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_for(IrisExecutable::V2, &["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()); + }); +} + +#[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_for(IrisExecutable::V2, &["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_for(IrisExecutable::V2, &["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_for(IrisExecutable::V2, &["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_for(IrisExecutable::V2, &["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_for( + IrisExecutable::V2, + "src", + &["watch", "--package", "application", "--output", "generated"], ); - workspace.write("packages/library/src/Library.purs", "module Library where\n"); - - let child = 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 - "#); - - workspace.assert_spago_calls("src", &[&["fetch"], &["sources", "--json"]]); + 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() + }); } -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 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_for(IrisExecutable::V2, &["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 normalized_watch_log(output: &Output) -> String { - let stdout = String::from_utf8_lossy(&output.stdout); +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 +312,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__v2_help_root.snap b/tests-e2e/tests/snapshots/cli__v2_help_root.snap index 54af2cb77..26adfaf68 100644 --- a/tests-e2e/tests/snapshots/cli__v2_help_root.snap +++ b/tests-e2e/tests/snapshots/cli__v2_help_root.snap @@ -13,6 +13,7 @@ Commands: 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. + 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: diff --git a/tests-e2e/tests/snapshots/cli__v2_help_watch.snap b/tests-e2e/tests/snapshots/cli__v2_help_watch.snap new file mode 100644 index 000000000..f29c42807 --- /dev/null +++ b/tests-e2e/tests/snapshots/cli__v2_help_watch.snap @@ -0,0 +1,19 @@ +--- +source: tests-e2e/tests/cli.rs +--- +status: 0 +--- stdout +Build a Spago workspace or package and rebuild when inputs change. + +Usage: iris-v2 watch [FLAGS] + +Flags: + -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/support.rs b/tests-e2e/tests/support.rs index a83df84cc..a70989181 100644 --- a/tests-e2e/tests/support.rs +++ b/tests-e2e/tests/support.rs @@ -91,7 +91,20 @@ impl TestWorkspace { } pub fn spawn_in(&self, directory: &str, arguments: &[&str]) -> Child { - self.command_builder(directory, arguments) + self.spawn_in_for(IrisExecutable::V1, directory, arguments) + } + + pub fn spawn_for(&self, executable: IrisExecutable, arguments: &[&str]) -> Child { + self.spawn_in_for(executable, "", arguments) + } + + pub fn spawn_in_for( + &self, + executable: IrisExecutable, + directory: &str, + arguments: &[&str], + ) -> Child { + self.command_builder_for(executable, directory, arguments) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) From 071e250b1f7bbc12b7dda84820183aefeca146aa Mon Sep 17 00:00:00 2001 From: Justin Garcia Date: Sat, 12 Sep 2026 19:27:12 +0000 Subject: [PATCH 18/26] Promote the rearchitected Iris CLI Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-01a09469-c4cf-723d-9923-3b8452bea9f2 --- .agents/skills/cutting-releases/SKILL.md | 14 +- .github/workflows/canary.yml | 4 +- .github/workflows/release.yml | 2 +- Cargo.lock | 57 - Cargo.toml | 8 +- README.md | 2 +- THIRDPARTY.toml | 1871 +++++++++++++++-- compiler-bin/Cargo.toml | 75 - compiler-bin/build.rs | 22 - compiler-bin/bundled/project/Main.purs | 10 - compiler-bin/bundled/project/Test.Main.purs | 11 - compiler-bin/bundled/project/gitignore | 2 - compiler-bin/bundled/project/runner.mjs | 8 - .../iris-build/Cargo.toml | 0 .../iris-build/src/compilation.rs | 0 .../iris-build/src/compile.rs | 0 .../iris-build/src/events.rs | 0 .../iris-build/src/executor.rs | 0 .../iris-build/src/lib.rs | 0 .../iris-build/src/plan.rs | 0 .../iris-build/src/project.rs | 0 .../iris-build/src/session.rs | 0 .../iris-build/src/walk.rs | 0 .../iris-build/src/workspace.rs | 0 .../iris-cli/Cargo.toml | 4 +- .../iris-cli/build.rs | 0 .../iris-cli/src/bin/iris.rs | 0 .../iris-cli/src/cli.rs | 6 +- .../iris-cli/src/lib.rs | 0 .../iris-cli/src/logging.rs | 2 +- .../iris-lsp/Cargo.toml | 0 .../iris-lsp/src/lib.rs | 0 .../iris-lsp/src/server.rs | 0 .../iris-lsp/src/server/capabilities.rs | 0 .../iris-lsp/src/server/error.rs | 0 .../iris-lsp/src/server/event.rs | 0 .../iris-lsp/src/server/extension.rs | 0 .../iris-lsp/src/server/tests.rs | 0 .../iris-lsp/src/walk.rs | 0 .../iris-package/Cargo.toml | 0 .../iris-package/bundled/project/Main.purs | 0 .../bundled/project/Test.Main.purs | 0 .../iris-package/bundled/project/gitignore | 0 .../iris-package/src/lib.rs | 0 .../iris-package/src/workspace.rs | 0 .../iris-progress/Cargo.toml | 0 .../iris-progress/examples/watch.rs | 0 .../iris-progress/src/lib.rs | 0 .../iris-watch/Cargo.toml | 0 .../iris-watch/src/lib.rs | 0 compiler-bin/src/bin/iris.rs | 6 - compiler-bin/src/cli.rs | 634 ------ compiler-bin/src/cli/diagnostic.rs | 205 -- compiler-bin/src/compilation.rs | 195 -- compiler-bin/src/compile.rs | 1003 --------- compiler-bin/src/docs.rs | 740 ------- compiler-bin/src/docs/error.rs | 32 - compiler-bin/src/docs/location.rs | 76 - compiler-bin/src/lib.rs | 141 -- compiler-bin/src/logging.rs | 72 - compiler-bin/src/lsp.rs | 1311 ------------ compiler-bin/src/lsp/capabilities.rs | 170 -- compiler-bin/src/lsp/error.rs | 104 - compiler-bin/src/lsp/event.rs | 315 --- compiler-bin/src/lsp/extension.rs | 20 - compiler-bin/src/lsp/tests.rs | 289 --- compiler-bin/src/package.rs | 155 -- compiler-bin/src/progress.rs | 921 -------- compiler-bin/src/project.rs | 347 --- compiler-bin/src/walk.rs | 216 -- compiler-bin/src/watch.rs | 570 ----- compiler-bin/src/workspace.rs | 372 ---- justfile | 6 +- tests-e2e/Cargo.toml | 7 +- tests-e2e/src/bin/iris-e2e.rs | 2 +- tests-e2e/src/bin/iris-v2-e2e.rs | 3 - tests-e2e/tests/cli.rs | 291 +-- tests-e2e/tests/lsp.rs | 392 ++-- tests-e2e/tests/package_manager.rs | 6 - tests-e2e/tests/package_manager/add.rs | 44 +- tests-e2e/tests/package_manager/build.rs | 259 +-- tests-e2e/tests/package_manager/build_v2.rs | 116 - tests-e2e/tests/package_manager/new.rs | 67 +- tests-e2e/tests/package_manager/run.rs | 39 - ...r__build__backend_failure_diagnostics.snap | 21 - ...silient_initializer_cycle_diagnostics.snap | 22 - ...__build__resilient_source_diagnostics.snap | 26 - ..._strict_initializer_cycle_diagnostics.snap | 22 - ...build__suppressed_warning_diagnostics.snap | 6 - ...d_v2__v2_suppressed_error_diagnostics.snap | 6 - ...manager__watch__watch_single_package.snap} | 0 ...nager__watch__watch_single_package_v1.snap | 4 - ...kage_manager__watch__watch_workspace.snap} | 0 ...ge_manager__watch__watch_workspace_v1.snap | 4 - tests-e2e/tests/package_manager/test.rs | 56 - tests-e2e/tests/package_manager/watch.rs | 130 +- .../cli__add_requires_dependencies.snap | 19 +- .../cli__compile_package_requires_value.snap | 16 - ...li__compile_requires_input_or_package.snap | 19 - ...cli__config_blank_source_program_file.snap | 14 - ...i__config_blank_source_program_inline.snap | 14 - ...onflict.snap => cli__config_conflict.snap} | 2 +- .../cli__config_conflicts_with_file.snap | 18 - ...cli__config_duplicate_diagnostic_file.snap | 14 - ...i__config_duplicate_diagnostic_inline.snap | 14 - .../cli__config_duplicate_program_file.snap | 14 - .../cli__config_duplicate_program_inline.snap | 14 - .../cli__config_empty_json_file.snap | 14 - .../cli__config_empty_json_inline.snap | 14 - ...cli__config_empty_source_program_file.snap | 14 - ...i__config_empty_source_program_inline.snap | 14 - ...i__config_escaped_diagnostic_key_file.snap | 14 - ..._config_escaped_diagnostic_key_inline.snap | 14 - ...i__config_file_conflicts_with_literal.snap | 18 - .../cli__config_file_invalid_utf8.snap | 14 - .../snapshots/cli__config_file_missing.snap | 9 +- .../cli__config_file_requires_value.snap | 16 - ..._invalid.snap => cli__config_invalid.snap} | 0 ..._config_invalid_source_arguments_file.snap | 14 - ...onfig_invalid_source_arguments_inline.snap | 14 - .../cli__config_malformed_json_file.snap | 14 - .../cli__config_malformed_json_inline.snap | 14 - ...i__config_missing_source_program_file.snap | 14 - ..._config_missing_source_program_inline.snap | 14 - .../cli__config_multiline_eof_file.snap | 14 - .../cli__config_multiline_eof_inline.snap | 14 - ...nfig_multiline_invalid_arguments_file.snap | 14 - ...ig_multiline_invalid_arguments_inline.snap | 14 - ...ltiline_program_before_arguments_file.snap | 14 - ...iline_program_before_arguments_inline.snap | 14 - ...ig_multiline_program_before_kind_file.snap | 14 - ..._multiline_program_before_kind_inline.snap | 14 - ...ig_multiline_unicode_unknown_key_file.snap | 14 - ..._multiline_unicode_unknown_key_inline.snap | 14 - ...li__config_null_source_arguments_file.snap | 14 - ...__config_null_source_arguments_inline.snap | 14 - .../snapshots/cli__config_requires_value.snap | 16 - .../cli__config_trailing_json_file.snap | 14 - .../cli__config_trailing_json_inline.snap | 14 - ...cli__config_unicode_syntax_error_file.snap | 14 - ...i__config_unicode_syntax_error_inline.snap | 14 - .../cli__config_unknown_diagnostic_file.snap | 14 - ...cli__config_unknown_diagnostic_inline.snap | 14 - .../cli__config_unknown_setting_file.snap | 14 - .../cli__config_unknown_setting_inline.snap | 14 - .../cli__config_unknown_source_kind_file.snap | 14 - ...li__config_unknown_source_kind_inline.snap | 14 - ...__config_wrong_diagnostic_object_file.snap | 14 - ...config_wrong_diagnostic_object_inline.snap | 14 - ...li__config_wrong_diagnostic_type_file.snap | 14 - ...__config_wrong_diagnostic_type_inline.snap | 14 - .../cli__config_wrong_top_level_file.snap | 14 - .../cli__config_wrong_top_level_inline.snap | 14 - .../cli__docs_package_requires_value.snap | 16 - .../cli__docs_project_requires_value.snap | 16 - ...cli__docs_requires_package_or_project.snap | 19 - .../cli__duplicate_build_scalar.snap | 18 - .../cli__duplicate_compile_scalar.snap | 18 - .../snapshots/cli__duplicate_config_file.snap | 18 - .../snapshots/cli__duplicate_docs_scalar.snap | 18 - .../snapshots/cli__duplicate_lsp_scalar.snap | 18 - .../cli__duplicate_typescript_scalar.snap | 18 - .../tests/snapshots/cli__help_build.snap | 24 +- .../tests/snapshots/cli__help_compile.snap | 32 - tests-e2e/tests/snapshots/cli__help_docs.snap | 30 - .../snapshots/cli__help_docs_typescript.snap | 14 - tests-e2e/tests/snapshots/cli__help_lsp.snap | 28 +- .../tests/snapshots/cli__help_lsp_short.snap | 21 - tests-e2e/tests/snapshots/cli__help_root.snap | 31 +- tests-e2e/tests/snapshots/cli__help_run.snap | 28 - tests-e2e/tests/snapshots/cli__help_test.snap | 28 - .../tests/snapshots/cli__help_watch.snap | 21 +- .../tests/snapshots/cli__invalid_choice.snap | 19 - .../snapshots/cli__invalid_choice_equals.snap | 17 - .../cli__invalid_choice_escaped.snap | 20 - .../cli__log_file_requires_subcommand.snap | 40 - .../cli__removed_diagnostics_on_change.snap | 18 - .../cli__removed_diagnostics_on_open.snap | 18 - .../cli__removed_diagnostics_on_save.snap | 18 - .../cli__removed_source_command.snap | 18 - .../snapshots/cli__root_checking_log.snap | 18 - .../tests/snapshots/cli__root_config.snap | 18 - .../snapshots/cli__root_config_file.snap | 20 - .../tests/snapshots/cli__root_lsp_log.snap | 18 - .../tests/snapshots/cli__root_query_log.snap | 18 - .../cli__root_requires_subcommand.snap | 40 - .../tests/snapshots/cli__root_stdio.snap | 18 - .../snapshots/cli__root_stdio_before_lsp.snap | 18 - .../cli__run_requires_separator.snap | 18 - .../cli__test_requires_separator.snap | 18 - .../snapshots/cli__unicode_unknown_flag.snap | 18 - .../snapshots/cli__unknown_build_flag.snap | 18 - .../snapshots/cli__unknown_compile_flag.snap | 18 - .../snapshots/cli__unknown_docs_flag.snap | 18 - .../snapshots/cli__unknown_root_flag.snap | 18 - .../cli__unknown_typescript_flag.snap | 18 - .../cli__v2_add_requires_dependencies.snap | 12 - .../cli__v2_config_file_missing.snap | 7 - .../tests/snapshots/cli__v2_help_add.snap | 17 - .../tests/snapshots/cli__v2_help_build.snap | 20 - .../tests/snapshots/cli__v2_help_lsp.snap | 24 - .../tests/snapshots/cli__v2_help_new.snap | 13 - .../tests/snapshots/cli__v2_help_root.snap | 22 - .../tests/snapshots/cli__v2_help_watch.snap | 19 - tests-e2e/tests/support.rs | 74 +- 205 files changed, 2150 insertions(+), 11266 deletions(-) delete mode 100644 compiler-bin/Cargo.toml delete mode 100644 compiler-bin/build.rs delete mode 100644 compiler-bin/bundled/project/Main.purs delete mode 100644 compiler-bin/bundled/project/Test.Main.purs delete mode 100644 compiler-bin/bundled/project/gitignore delete mode 100644 compiler-bin/bundled/project/runner.mjs rename {compiler-bin-v2 => compiler-bin}/iris-build/Cargo.toml (100%) rename {compiler-bin-v2 => compiler-bin}/iris-build/src/compilation.rs (100%) rename {compiler-bin-v2 => compiler-bin}/iris-build/src/compile.rs (100%) rename {compiler-bin-v2 => compiler-bin}/iris-build/src/events.rs (100%) rename {compiler-bin-v2 => compiler-bin}/iris-build/src/executor.rs (100%) rename {compiler-bin-v2 => compiler-bin}/iris-build/src/lib.rs (100%) rename {compiler-bin-v2 => compiler-bin}/iris-build/src/plan.rs (100%) rename {compiler-bin-v2 => compiler-bin}/iris-build/src/project.rs (100%) rename {compiler-bin-v2 => compiler-bin}/iris-build/src/session.rs (100%) rename {compiler-bin-v2 => compiler-bin}/iris-build/src/walk.rs (100%) rename {compiler-bin-v2 => compiler-bin}/iris-build/src/workspace.rs (100%) rename {compiler-bin-v2 => compiler-bin}/iris-cli/Cargo.toml (90%) rename {compiler-bin-v2 => compiler-bin}/iris-cli/build.rs (100%) rename {compiler-bin-v2 => compiler-bin}/iris-cli/src/bin/iris.rs (100%) rename {compiler-bin-v2 => compiler-bin}/iris-cli/src/cli.rs (98%) rename {compiler-bin-v2 => compiler-bin}/iris-cli/src/lib.rs (100%) rename {compiler-bin-v2 => compiler-bin}/iris-cli/src/logging.rs (97%) rename {compiler-bin-v2 => compiler-bin}/iris-lsp/Cargo.toml (100%) rename {compiler-bin-v2 => compiler-bin}/iris-lsp/src/lib.rs (100%) rename {compiler-bin-v2 => compiler-bin}/iris-lsp/src/server.rs (100%) rename {compiler-bin-v2 => compiler-bin}/iris-lsp/src/server/capabilities.rs (100%) rename {compiler-bin-v2 => compiler-bin}/iris-lsp/src/server/error.rs (100%) rename {compiler-bin-v2 => compiler-bin}/iris-lsp/src/server/event.rs (100%) rename {compiler-bin-v2 => compiler-bin}/iris-lsp/src/server/extension.rs (100%) rename {compiler-bin-v2 => compiler-bin}/iris-lsp/src/server/tests.rs (100%) rename {compiler-bin-v2 => compiler-bin}/iris-lsp/src/walk.rs (100%) rename {compiler-bin-v2 => compiler-bin}/iris-package/Cargo.toml (100%) rename {compiler-bin-v2 => compiler-bin}/iris-package/bundled/project/Main.purs (100%) rename {compiler-bin-v2 => compiler-bin}/iris-package/bundled/project/Test.Main.purs (100%) rename {compiler-bin-v2 => compiler-bin}/iris-package/bundled/project/gitignore (100%) rename {compiler-bin-v2 => compiler-bin}/iris-package/src/lib.rs (100%) rename {compiler-bin-v2 => compiler-bin}/iris-package/src/workspace.rs (100%) rename {compiler-bin-v2 => compiler-bin}/iris-progress/Cargo.toml (100%) rename {compiler-bin-v2 => compiler-bin}/iris-progress/examples/watch.rs (100%) rename {compiler-bin-v2 => compiler-bin}/iris-progress/src/lib.rs (100%) rename {compiler-bin-v2 => compiler-bin}/iris-watch/Cargo.toml (100%) rename {compiler-bin-v2 => compiler-bin}/iris-watch/src/lib.rs (100%) delete mode 100644 compiler-bin/src/bin/iris.rs delete mode 100644 compiler-bin/src/cli.rs delete mode 100644 compiler-bin/src/cli/diagnostic.rs delete mode 100644 compiler-bin/src/compilation.rs delete mode 100644 compiler-bin/src/compile.rs delete mode 100644 compiler-bin/src/docs.rs delete mode 100644 compiler-bin/src/docs/error.rs delete mode 100644 compiler-bin/src/docs/location.rs delete mode 100644 compiler-bin/src/lib.rs delete mode 100644 compiler-bin/src/logging.rs delete mode 100644 compiler-bin/src/lsp.rs delete mode 100644 compiler-bin/src/lsp/capabilities.rs delete mode 100644 compiler-bin/src/lsp/error.rs delete mode 100644 compiler-bin/src/lsp/event.rs delete mode 100644 compiler-bin/src/lsp/extension.rs delete mode 100644 compiler-bin/src/lsp/tests.rs delete mode 100644 compiler-bin/src/package.rs delete mode 100644 compiler-bin/src/progress.rs delete mode 100644 compiler-bin/src/project.rs delete mode 100644 compiler-bin/src/walk.rs delete mode 100644 compiler-bin/src/watch.rs delete mode 100644 compiler-bin/src/workspace.rs delete mode 100644 tests-e2e/src/bin/iris-v2-e2e.rs delete mode 100644 tests-e2e/tests/package_manager/build_v2.rs delete mode 100644 tests-e2e/tests/package_manager/run.rs delete mode 100644 tests-e2e/tests/package_manager/snapshots/package_manager__build__backend_failure_diagnostics.snap delete mode 100644 tests-e2e/tests/package_manager/snapshots/package_manager__build__resilient_initializer_cycle_diagnostics.snap delete mode 100644 tests-e2e/tests/package_manager/snapshots/package_manager__build__resilient_source_diagnostics.snap delete mode 100644 tests-e2e/tests/package_manager/snapshots/package_manager__build__strict_initializer_cycle_diagnostics.snap delete mode 100644 tests-e2e/tests/package_manager/snapshots/package_manager__build__suppressed_warning_diagnostics.snap delete mode 100644 tests-e2e/tests/package_manager/snapshots/package_manager__build_v2__v2_suppressed_error_diagnostics.snap rename tests-e2e/tests/package_manager/snapshots/{package_manager__watch__watch_single_package_v2.snap => package_manager__watch__watch_single_package.snap} (100%) delete mode 100644 tests-e2e/tests/package_manager/snapshots/package_manager__watch__watch_single_package_v1.snap rename tests-e2e/tests/package_manager/snapshots/{package_manager__watch__watch_workspace_v2.snap => package_manager__watch__watch_workspace.snap} (100%) delete mode 100644 tests-e2e/tests/package_manager/snapshots/package_manager__watch__watch_workspace_v1.snap delete mode 100644 tests-e2e/tests/package_manager/test.rs delete mode 100644 tests-e2e/tests/snapshots/cli__compile_package_requires_value.snap delete mode 100644 tests-e2e/tests/snapshots/cli__compile_requires_input_or_package.snap delete mode 100644 tests-e2e/tests/snapshots/cli__config_blank_source_program_file.snap delete mode 100644 tests-e2e/tests/snapshots/cli__config_blank_source_program_inline.snap rename tests-e2e/tests/snapshots/{cli__v2_config_conflict.snap => cli__config_conflict.snap} (86%) delete mode 100644 tests-e2e/tests/snapshots/cli__config_conflicts_with_file.snap delete mode 100644 tests-e2e/tests/snapshots/cli__config_duplicate_diagnostic_file.snap delete mode 100644 tests-e2e/tests/snapshots/cli__config_duplicate_diagnostic_inline.snap delete mode 100644 tests-e2e/tests/snapshots/cli__config_duplicate_program_file.snap delete mode 100644 tests-e2e/tests/snapshots/cli__config_duplicate_program_inline.snap delete mode 100644 tests-e2e/tests/snapshots/cli__config_empty_json_file.snap delete mode 100644 tests-e2e/tests/snapshots/cli__config_empty_json_inline.snap delete mode 100644 tests-e2e/tests/snapshots/cli__config_empty_source_program_file.snap delete mode 100644 tests-e2e/tests/snapshots/cli__config_empty_source_program_inline.snap delete mode 100644 tests-e2e/tests/snapshots/cli__config_escaped_diagnostic_key_file.snap delete mode 100644 tests-e2e/tests/snapshots/cli__config_escaped_diagnostic_key_inline.snap delete mode 100644 tests-e2e/tests/snapshots/cli__config_file_conflicts_with_literal.snap delete mode 100644 tests-e2e/tests/snapshots/cli__config_file_invalid_utf8.snap delete mode 100644 tests-e2e/tests/snapshots/cli__config_file_requires_value.snap rename tests-e2e/tests/snapshots/{cli__v2_config_invalid.snap => cli__config_invalid.snap} (100%) delete mode 100644 tests-e2e/tests/snapshots/cli__config_invalid_source_arguments_file.snap delete mode 100644 tests-e2e/tests/snapshots/cli__config_invalid_source_arguments_inline.snap delete mode 100644 tests-e2e/tests/snapshots/cli__config_malformed_json_file.snap delete mode 100644 tests-e2e/tests/snapshots/cli__config_malformed_json_inline.snap delete mode 100644 tests-e2e/tests/snapshots/cli__config_missing_source_program_file.snap delete mode 100644 tests-e2e/tests/snapshots/cli__config_missing_source_program_inline.snap delete mode 100644 tests-e2e/tests/snapshots/cli__config_multiline_eof_file.snap delete mode 100644 tests-e2e/tests/snapshots/cli__config_multiline_eof_inline.snap delete mode 100644 tests-e2e/tests/snapshots/cli__config_multiline_invalid_arguments_file.snap delete mode 100644 tests-e2e/tests/snapshots/cli__config_multiline_invalid_arguments_inline.snap delete mode 100644 tests-e2e/tests/snapshots/cli__config_multiline_program_before_arguments_file.snap delete mode 100644 tests-e2e/tests/snapshots/cli__config_multiline_program_before_arguments_inline.snap delete mode 100644 tests-e2e/tests/snapshots/cli__config_multiline_program_before_kind_file.snap delete mode 100644 tests-e2e/tests/snapshots/cli__config_multiline_program_before_kind_inline.snap delete mode 100644 tests-e2e/tests/snapshots/cli__config_multiline_unicode_unknown_key_file.snap delete mode 100644 tests-e2e/tests/snapshots/cli__config_multiline_unicode_unknown_key_inline.snap delete mode 100644 tests-e2e/tests/snapshots/cli__config_null_source_arguments_file.snap delete mode 100644 tests-e2e/tests/snapshots/cli__config_null_source_arguments_inline.snap delete mode 100644 tests-e2e/tests/snapshots/cli__config_requires_value.snap delete mode 100644 tests-e2e/tests/snapshots/cli__config_trailing_json_file.snap delete mode 100644 tests-e2e/tests/snapshots/cli__config_trailing_json_inline.snap delete mode 100644 tests-e2e/tests/snapshots/cli__config_unicode_syntax_error_file.snap delete mode 100644 tests-e2e/tests/snapshots/cli__config_unicode_syntax_error_inline.snap delete mode 100644 tests-e2e/tests/snapshots/cli__config_unknown_diagnostic_file.snap delete mode 100644 tests-e2e/tests/snapshots/cli__config_unknown_diagnostic_inline.snap delete mode 100644 tests-e2e/tests/snapshots/cli__config_unknown_setting_file.snap delete mode 100644 tests-e2e/tests/snapshots/cli__config_unknown_setting_inline.snap delete mode 100644 tests-e2e/tests/snapshots/cli__config_unknown_source_kind_file.snap delete mode 100644 tests-e2e/tests/snapshots/cli__config_unknown_source_kind_inline.snap delete mode 100644 tests-e2e/tests/snapshots/cli__config_wrong_diagnostic_object_file.snap delete mode 100644 tests-e2e/tests/snapshots/cli__config_wrong_diagnostic_object_inline.snap delete mode 100644 tests-e2e/tests/snapshots/cli__config_wrong_diagnostic_type_file.snap delete mode 100644 tests-e2e/tests/snapshots/cli__config_wrong_diagnostic_type_inline.snap delete mode 100644 tests-e2e/tests/snapshots/cli__config_wrong_top_level_file.snap delete mode 100644 tests-e2e/tests/snapshots/cli__config_wrong_top_level_inline.snap delete mode 100644 tests-e2e/tests/snapshots/cli__docs_package_requires_value.snap delete mode 100644 tests-e2e/tests/snapshots/cli__docs_project_requires_value.snap delete mode 100644 tests-e2e/tests/snapshots/cli__docs_requires_package_or_project.snap delete mode 100644 tests-e2e/tests/snapshots/cli__duplicate_build_scalar.snap delete mode 100644 tests-e2e/tests/snapshots/cli__duplicate_compile_scalar.snap delete mode 100644 tests-e2e/tests/snapshots/cli__duplicate_config_file.snap delete mode 100644 tests-e2e/tests/snapshots/cli__duplicate_docs_scalar.snap delete mode 100644 tests-e2e/tests/snapshots/cli__duplicate_lsp_scalar.snap delete mode 100644 tests-e2e/tests/snapshots/cli__duplicate_typescript_scalar.snap delete mode 100644 tests-e2e/tests/snapshots/cli__help_compile.snap delete mode 100644 tests-e2e/tests/snapshots/cli__help_docs.snap delete mode 100644 tests-e2e/tests/snapshots/cli__help_docs_typescript.snap delete mode 100644 tests-e2e/tests/snapshots/cli__help_lsp_short.snap delete mode 100644 tests-e2e/tests/snapshots/cli__help_run.snap delete mode 100644 tests-e2e/tests/snapshots/cli__help_test.snap delete mode 100644 tests-e2e/tests/snapshots/cli__invalid_choice.snap delete mode 100644 tests-e2e/tests/snapshots/cli__invalid_choice_equals.snap delete mode 100644 tests-e2e/tests/snapshots/cli__invalid_choice_escaped.snap delete mode 100644 tests-e2e/tests/snapshots/cli__log_file_requires_subcommand.snap delete mode 100644 tests-e2e/tests/snapshots/cli__removed_diagnostics_on_change.snap delete mode 100644 tests-e2e/tests/snapshots/cli__removed_diagnostics_on_open.snap delete mode 100644 tests-e2e/tests/snapshots/cli__removed_diagnostics_on_save.snap delete mode 100644 tests-e2e/tests/snapshots/cli__removed_source_command.snap delete mode 100644 tests-e2e/tests/snapshots/cli__root_checking_log.snap delete mode 100644 tests-e2e/tests/snapshots/cli__root_config.snap delete mode 100644 tests-e2e/tests/snapshots/cli__root_config_file.snap delete mode 100644 tests-e2e/tests/snapshots/cli__root_lsp_log.snap delete mode 100644 tests-e2e/tests/snapshots/cli__root_query_log.snap delete mode 100644 tests-e2e/tests/snapshots/cli__root_requires_subcommand.snap delete mode 100644 tests-e2e/tests/snapshots/cli__root_stdio.snap delete mode 100644 tests-e2e/tests/snapshots/cli__root_stdio_before_lsp.snap delete mode 100644 tests-e2e/tests/snapshots/cli__run_requires_separator.snap delete mode 100644 tests-e2e/tests/snapshots/cli__test_requires_separator.snap delete mode 100644 tests-e2e/tests/snapshots/cli__unicode_unknown_flag.snap delete mode 100644 tests-e2e/tests/snapshots/cli__unknown_build_flag.snap delete mode 100644 tests-e2e/tests/snapshots/cli__unknown_compile_flag.snap delete mode 100644 tests-e2e/tests/snapshots/cli__unknown_docs_flag.snap delete mode 100644 tests-e2e/tests/snapshots/cli__unknown_root_flag.snap delete mode 100644 tests-e2e/tests/snapshots/cli__unknown_typescript_flag.snap delete mode 100644 tests-e2e/tests/snapshots/cli__v2_add_requires_dependencies.snap delete mode 100644 tests-e2e/tests/snapshots/cli__v2_config_file_missing.snap delete mode 100644 tests-e2e/tests/snapshots/cli__v2_help_add.snap delete mode 100644 tests-e2e/tests/snapshots/cli__v2_help_build.snap delete mode 100644 tests-e2e/tests/snapshots/cli__v2_help_lsp.snap delete mode 100644 tests-e2e/tests/snapshots/cli__v2_help_new.snap delete mode 100644 tests-e2e/tests/snapshots/cli__v2_help_root.snap delete mode 100644 tests-e2e/tests/snapshots/cli__v2_help_watch.snap 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 573f6eef9..4a7d446e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3053,61 +3053,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", - "petgraph", - "prim-constants", - "radix_trie", - "ratatui", - "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" @@ -4203,7 +4148,6 @@ dependencies = [ "iris-cli", "itertools 0.15.0", "lsp-types", - "purescript-iris", "regex", "serde_json", "tempfile", @@ -4374,7 +4318,6 @@ dependencies = [ "bytes", "libc", "mio", - "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", diff --git a/Cargo.toml b/Cargo.toml index cdc4f37c3..4c695e26d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,6 @@ [workspace] members = [ - "compiler-bin", - "compiler-bin-v2/iris-build", - "compiler-bin-v2/iris-cli", - "compiler-bin-v2/iris-lsp", - "compiler-bin-v2/iris-package", - "compiler-bin-v2/iris-progress", - "compiler-bin-v2/iris-watch", + "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 2fb6961c1..000000000 --- a/compiler-bin/Cargo.toml +++ /dev/null @@ -1,75 +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" -petgraph = "0.8.3" -prim-constants = { version = "0.1.0", path = "../compiler-core/prim-constants" } -radix_trie = "0.3.0" -ratatui = { version = "0.30.2", default-features = false, features = ["crossterm"] } -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/build.rs b/compiler-bin/build.rs deleted file mode 100644 index 8fe5a5ae8..000000000 --- a/compiler-bin/build.rs +++ /dev/null @@ -1,22 +0,0 @@ -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 version = match env::var("IRIS_BUILD_REVISION") { - Ok(revision) => { - assert!( - (7..=64).contains(&revision.len()) - && revision.bytes().all(|byte| byte.is_ascii_hexdigit()), - "IRIS_BUILD_REVISION must contain 7 to 64 hexadecimal characters" - ); - format!("{package_version}-dev.{}", revision.to_ascii_lowercase()) - } - Err(env::VarError::NotPresent) => package_version, - Err(env::VarError::NotUnicode(_)) => panic!("IRIS_BUILD_REVISION must be Unicode"), - }; - - println!("cargo::rustc-env=IRIS_VERSION={version}"); -} diff --git a/compiler-bin/bundled/project/Main.purs b/compiler-bin/bundled/project/Main.purs deleted file mode 100644 index 5c18dca55..000000000 --- a/compiler-bin/bundled/project/Main.purs +++ /dev/null @@ -1,10 +0,0 @@ -module Main where - -import Prelude - -import Effect (Effect) -import Effect.Console (log) - -main :: Effect Unit -main = do - log "🍝" diff --git a/compiler-bin/bundled/project/Test.Main.purs b/compiler-bin/bundled/project/Test.Main.purs deleted file mode 100644 index 3590d4dd2..000000000 --- a/compiler-bin/bundled/project/Test.Main.purs +++ /dev/null @@ -1,11 +0,0 @@ -module Test.Main where - -import Prelude - -import Effect (Effect) -import Effect.Class.Console (log) - -main :: Effect Unit -main = do - log "🍕" - log "You should add some tests." diff --git a/compiler-bin/bundled/project/gitignore b/compiler-bin/bundled/project/gitignore deleted file mode 100644 index a4314d5a6..000000000 --- a/compiler-bin/bundled/project/gitignore +++ /dev/null @@ -1,2 +0,0 @@ -.spago/ -output/ diff --git a/compiler-bin/bundled/project/runner.mjs b/compiler-bin/bundled/project/runner.mjs deleted file mode 100644 index c42cd6dde..000000000 --- a/compiler-bin/bundled/project/runner.mjs +++ /dev/null @@ -1,8 +0,0 @@ -if (process.argv[2] === "--") { - process.argv.splice(2, 1); -} -const target = await import(process.argv[1]); -if (typeof target.main !== "function") { - throw new Error(`Module does not export a main function: ${process.argv[1]}`); -} -await target.main(); diff --git a/compiler-bin-v2/iris-build/Cargo.toml b/compiler-bin/iris-build/Cargo.toml similarity index 100% rename from compiler-bin-v2/iris-build/Cargo.toml rename to compiler-bin/iris-build/Cargo.toml diff --git a/compiler-bin-v2/iris-build/src/compilation.rs b/compiler-bin/iris-build/src/compilation.rs similarity index 100% rename from compiler-bin-v2/iris-build/src/compilation.rs rename to compiler-bin/iris-build/src/compilation.rs diff --git a/compiler-bin-v2/iris-build/src/compile.rs b/compiler-bin/iris-build/src/compile.rs similarity index 100% rename from compiler-bin-v2/iris-build/src/compile.rs rename to compiler-bin/iris-build/src/compile.rs diff --git a/compiler-bin-v2/iris-build/src/events.rs b/compiler-bin/iris-build/src/events.rs similarity index 100% rename from compiler-bin-v2/iris-build/src/events.rs rename to compiler-bin/iris-build/src/events.rs diff --git a/compiler-bin-v2/iris-build/src/executor.rs b/compiler-bin/iris-build/src/executor.rs similarity index 100% rename from compiler-bin-v2/iris-build/src/executor.rs rename to compiler-bin/iris-build/src/executor.rs diff --git a/compiler-bin-v2/iris-build/src/lib.rs b/compiler-bin/iris-build/src/lib.rs similarity index 100% rename from compiler-bin-v2/iris-build/src/lib.rs rename to compiler-bin/iris-build/src/lib.rs diff --git a/compiler-bin-v2/iris-build/src/plan.rs b/compiler-bin/iris-build/src/plan.rs similarity index 100% rename from compiler-bin-v2/iris-build/src/plan.rs rename to compiler-bin/iris-build/src/plan.rs diff --git a/compiler-bin-v2/iris-build/src/project.rs b/compiler-bin/iris-build/src/project.rs similarity index 100% rename from compiler-bin-v2/iris-build/src/project.rs rename to compiler-bin/iris-build/src/project.rs diff --git a/compiler-bin-v2/iris-build/src/session.rs b/compiler-bin/iris-build/src/session.rs similarity index 100% rename from compiler-bin-v2/iris-build/src/session.rs rename to compiler-bin/iris-build/src/session.rs diff --git a/compiler-bin-v2/iris-build/src/walk.rs b/compiler-bin/iris-build/src/walk.rs similarity index 100% rename from compiler-bin-v2/iris-build/src/walk.rs rename to compiler-bin/iris-build/src/walk.rs diff --git a/compiler-bin-v2/iris-build/src/workspace.rs b/compiler-bin/iris-build/src/workspace.rs similarity index 100% rename from compiler-bin-v2/iris-build/src/workspace.rs rename to compiler-bin/iris-build/src/workspace.rs diff --git a/compiler-bin-v2/iris-cli/Cargo.toml b/compiler-bin/iris-cli/Cargo.toml similarity index 90% rename from compiler-bin-v2/iris-cli/Cargo.toml rename to compiler-bin/iris-cli/Cargo.toml index d62a98965..90268b402 100644 --- a/compiler-bin-v2/iris-cli/Cargo.toml +++ b/compiler-bin/iris-cli/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2024" authors = ["Justin Garcia "] license = "BSD-3-Clause" -description = "Experimental PureScript compiler command-line interface." +description = "PureScript compiler command-line interface." readme = "../../README.md" homepage = "https://github.com/purefunctor/purescript-iris" repository = "https://github.com/purefunctor/purescript-iris" @@ -12,7 +12,7 @@ keywords = ["purescript", "compiler"] categories = ["compilers"] [[bin]] -name = "iris-v2" +name = "iris" path = "src/bin/iris.rs" [dependencies] diff --git a/compiler-bin-v2/iris-cli/build.rs b/compiler-bin/iris-cli/build.rs similarity index 100% rename from compiler-bin-v2/iris-cli/build.rs rename to compiler-bin/iris-cli/build.rs diff --git a/compiler-bin-v2/iris-cli/src/bin/iris.rs b/compiler-bin/iris-cli/src/bin/iris.rs similarity index 100% rename from compiler-bin-v2/iris-cli/src/bin/iris.rs rename to compiler-bin/iris-cli/src/bin/iris.rs diff --git a/compiler-bin-v2/iris-cli/src/cli.rs b/compiler-bin/iris-cli/src/cli.rs similarity index 98% rename from compiler-bin-v2/iris-cli/src/cli.rs rename to compiler-bin/iris-cli/src/cli.rs index 4ecd84191..d7fb36670 100644 --- a/compiler-bin-v2/iris-cli/src/cli.rs +++ b/compiler-bin/iris-cli/src/cli.rs @@ -15,7 +15,7 @@ use crate::logging::LoggingFilters; #[derive(Debug, usage::Cli)] #[usage( - bin = "iris-v2", + bin = "iris", about = env!("CARGO_PKG_DESCRIPTION"), version = crate::VERSION, unknown_flags = "error", @@ -298,7 +298,7 @@ impl Program { std::process::exit(0); } Err(usage::Error::Version { .. }) => { - println!("iris-v2 {}", crate::VERSION); + println!("iris {}", crate::VERSION); std::process::exit(0); } Err(error) => { @@ -320,7 +320,7 @@ mod tests { #[test] fn maps_build_options_to_configuration() { - let arguments = ["iris-v2", "build", "--quiet", "--no-diagnostics", "--resilient"]; + 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 { diff --git a/compiler-bin-v2/iris-cli/src/lib.rs b/compiler-bin/iris-cli/src/lib.rs similarity index 100% rename from compiler-bin-v2/iris-cli/src/lib.rs rename to compiler-bin/iris-cli/src/lib.rs diff --git a/compiler-bin-v2/iris-cli/src/logging.rs b/compiler-bin/iris-cli/src/logging.rs similarity index 97% rename from compiler-bin-v2/iris-cli/src/logging.rs rename to compiler-bin/iris-cli/src/logging.rs index 7a1b6399b..df2d8d18f 100644 --- a/compiler-bin-v2/iris-cli/src/logging.rs +++ b/compiler-bin/iris-cli/src/logging.rs @@ -37,7 +37,7 @@ where } pub fn start(filters: LoggingFilters) -> io::Result<()> { - let path = std::env::temp_dir().join("iris-v2.log"); + 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() diff --git a/compiler-bin-v2/iris-lsp/Cargo.toml b/compiler-bin/iris-lsp/Cargo.toml similarity index 100% rename from compiler-bin-v2/iris-lsp/Cargo.toml rename to compiler-bin/iris-lsp/Cargo.toml diff --git a/compiler-bin-v2/iris-lsp/src/lib.rs b/compiler-bin/iris-lsp/src/lib.rs similarity index 100% rename from compiler-bin-v2/iris-lsp/src/lib.rs rename to compiler-bin/iris-lsp/src/lib.rs diff --git a/compiler-bin-v2/iris-lsp/src/server.rs b/compiler-bin/iris-lsp/src/server.rs similarity index 100% rename from compiler-bin-v2/iris-lsp/src/server.rs rename to compiler-bin/iris-lsp/src/server.rs diff --git a/compiler-bin-v2/iris-lsp/src/server/capabilities.rs b/compiler-bin/iris-lsp/src/server/capabilities.rs similarity index 100% rename from compiler-bin-v2/iris-lsp/src/server/capabilities.rs rename to compiler-bin/iris-lsp/src/server/capabilities.rs diff --git a/compiler-bin-v2/iris-lsp/src/server/error.rs b/compiler-bin/iris-lsp/src/server/error.rs similarity index 100% rename from compiler-bin-v2/iris-lsp/src/server/error.rs rename to compiler-bin/iris-lsp/src/server/error.rs diff --git a/compiler-bin-v2/iris-lsp/src/server/event.rs b/compiler-bin/iris-lsp/src/server/event.rs similarity index 100% rename from compiler-bin-v2/iris-lsp/src/server/event.rs rename to compiler-bin/iris-lsp/src/server/event.rs diff --git a/compiler-bin-v2/iris-lsp/src/server/extension.rs b/compiler-bin/iris-lsp/src/server/extension.rs similarity index 100% rename from compiler-bin-v2/iris-lsp/src/server/extension.rs rename to compiler-bin/iris-lsp/src/server/extension.rs diff --git a/compiler-bin-v2/iris-lsp/src/server/tests.rs b/compiler-bin/iris-lsp/src/server/tests.rs similarity index 100% rename from compiler-bin-v2/iris-lsp/src/server/tests.rs rename to compiler-bin/iris-lsp/src/server/tests.rs diff --git a/compiler-bin-v2/iris-lsp/src/walk.rs b/compiler-bin/iris-lsp/src/walk.rs similarity index 100% rename from compiler-bin-v2/iris-lsp/src/walk.rs rename to compiler-bin/iris-lsp/src/walk.rs diff --git a/compiler-bin-v2/iris-package/Cargo.toml b/compiler-bin/iris-package/Cargo.toml similarity index 100% rename from compiler-bin-v2/iris-package/Cargo.toml rename to compiler-bin/iris-package/Cargo.toml diff --git a/compiler-bin-v2/iris-package/bundled/project/Main.purs b/compiler-bin/iris-package/bundled/project/Main.purs similarity index 100% rename from compiler-bin-v2/iris-package/bundled/project/Main.purs rename to compiler-bin/iris-package/bundled/project/Main.purs diff --git a/compiler-bin-v2/iris-package/bundled/project/Test.Main.purs b/compiler-bin/iris-package/bundled/project/Test.Main.purs similarity index 100% rename from compiler-bin-v2/iris-package/bundled/project/Test.Main.purs rename to compiler-bin/iris-package/bundled/project/Test.Main.purs diff --git a/compiler-bin-v2/iris-package/bundled/project/gitignore b/compiler-bin/iris-package/bundled/project/gitignore similarity index 100% rename from compiler-bin-v2/iris-package/bundled/project/gitignore rename to compiler-bin/iris-package/bundled/project/gitignore diff --git a/compiler-bin-v2/iris-package/src/lib.rs b/compiler-bin/iris-package/src/lib.rs similarity index 100% rename from compiler-bin-v2/iris-package/src/lib.rs rename to compiler-bin/iris-package/src/lib.rs diff --git a/compiler-bin-v2/iris-package/src/workspace.rs b/compiler-bin/iris-package/src/workspace.rs similarity index 100% rename from compiler-bin-v2/iris-package/src/workspace.rs rename to compiler-bin/iris-package/src/workspace.rs diff --git a/compiler-bin-v2/iris-progress/Cargo.toml b/compiler-bin/iris-progress/Cargo.toml similarity index 100% rename from compiler-bin-v2/iris-progress/Cargo.toml rename to compiler-bin/iris-progress/Cargo.toml diff --git a/compiler-bin-v2/iris-progress/examples/watch.rs b/compiler-bin/iris-progress/examples/watch.rs similarity index 100% rename from compiler-bin-v2/iris-progress/examples/watch.rs rename to compiler-bin/iris-progress/examples/watch.rs diff --git a/compiler-bin-v2/iris-progress/src/lib.rs b/compiler-bin/iris-progress/src/lib.rs similarity index 100% rename from compiler-bin-v2/iris-progress/src/lib.rs rename to compiler-bin/iris-progress/src/lib.rs diff --git a/compiler-bin-v2/iris-watch/Cargo.toml b/compiler-bin/iris-watch/Cargo.toml similarity index 100% rename from compiler-bin-v2/iris-watch/Cargo.toml rename to compiler-bin/iris-watch/Cargo.toml diff --git a/compiler-bin-v2/iris-watch/src/lib.rs b/compiler-bin/iris-watch/src/lib.rs similarity index 100% rename from compiler-bin-v2/iris-watch/src/lib.rs rename to compiler-bin/iris-watch/src/lib.rs 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 6333e79f0..000000000 --- a/compiler-bin/src/cli.rs +++ /dev/null @@ -1,634 +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, - - /// Suppress compiler warnings and errors without hiding build progress. - #[usage(long)] - pub no_diagnostics: 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", "--no-diagnostics"]); - - assert!(options.resilient); - assert!(options.no_diagnostics); - assert!(!build(&[]).resilient); - assert!(!build(&[]).no_diagnostics); - } - - #[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 09382550c..000000000 --- a/compiler-bin/src/compile.rs +++ /dev/null @@ -1,1003 +0,0 @@ -use std::collections::{BTreeSet, HashMap, HashSet}; -use std::io::IsTerminal; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; -use std::sync::{Arc, Mutex}; -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 itertools::Itertools; -use petgraph::algo::tarjan_scc; -use petgraph::prelude::DiGraphMap; -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 diagnostics: bool, - pub resilience: Resilience, -} - -pub(crate) struct PackageInput { - pub name: String, - pub sources: Vec, - pub dependencies: Vec, -} - -struct PackageJob { - name: String, - source_ids: Vec, - dependencies: Vec, -} - -struct PackageGroup { - packages: Vec, - dependencies: Vec, - dependents: Vec, -} - -#[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 { reported: bool }, - #[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("source {path} belongs to both Spago packages {first_package} and {second_package}")] - ConflictingPackageSource { path: PathBuf, first_package: String, second_package: String }, - #[error("Spago selected source {0}, but the lockfile does not assign it to a package")] - UnownedPackageSource(PathBuf), - #[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, - diagnostics: true, - resilience: Resilience::Strict, - }; - compile_source_paths(&build_config, source_paths, started, preparation_progress) -} - -pub(crate) fn compile_package_inputs( - root: &Path, - output: &Path, - inputs: &[PathBuf], - packages: Vec, - quiet: bool, - color: ColorChoice, - diagnostics: bool, - resilience: Resilience, -) -> Result<(), CompileError> { - let started = Instant::now(); - let color = use_color(color); - let package_progress = progress::packages(!quiet, color); - let prepared = (|| { - let walked = walk::walk(root, inputs)?; - let source_paths = walked.files.into_iter().collect::>(); - let packages = select_package_sources(&source_paths, packages)?; - Ok::<_, CompileError>((source_paths, packages)) - })(); - let (source_paths, packages) = match prepared { - Ok(prepared) => prepared, - Err(error) => { - package_progress.clear(); - return Err(error); - } - }; - - let build_config = BuildConfig { - output, - current_directory: root, - color, - progress: !quiet, - diagnostics, - resilience, - }; - compile_package_source_paths(&build_config, source_paths, packages, started, &package_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 { reported: true }); - } - - if config.progress { - progress::report_completion(started.elapsed()); - } - - Ok(()) -} - -fn select_package_sources( - source_paths: &BTreeSet, - packages: Vec, -) -> Result, CompileError> { - let mut owners: HashMap = HashMap::new(); - for package in &packages { - for source in &package.sources { - let source = dunce::canonicalize(source)?; - if let Some(first_package) = owners.get(&source) { - if first_package != &package.name { - return Err(CompileError::ConflictingPackageSource { - path: source, - first_package: String::clone(first_package), - second_package: String::clone(&package.name), - }); - } - } else { - owners.insert(source, String::clone(&package.name)); - } - } - } - - let mut selected: HashMap> = HashMap::new(); - for source in source_paths { - let canonical = dunce::canonicalize(source)?; - let Some(owner) = owners.get(&canonical) else { - return Err(CompileError::UnownedPackageSource(source.to_path_buf())); - }; - selected.entry(String::clone(owner)).or_default().push(source.to_path_buf()); - } - - let retained = selected.keys().map(String::as_str).collect::>(); - let packages_by_name = - packages.iter().map(|package| (package.name.as_str(), package)).collect::>(); - let contracted_dependencies = - packages.iter().filter(|package| retained.contains(&*package.name)).map(|package| { - let mut dependencies = vec![]; - let mut visited = HashSet::new(); - let mut pending = package.dependencies.iter().rev().map(String::as_str).collect_vec(); - while let Some(dependency) = pending.pop() { - if !visited.insert(dependency) { - continue; - } - if retained.contains(dependency) { - dependencies.push(dependency.to_owned()); - } else if let Some(package) = packages_by_name.get(dependency) { - pending.extend(package.dependencies.iter().rev().map(String::as_str)); - } - } - (String::clone(&package.name), dependencies) - }); - let contracted_dependencies = contracted_dependencies.collect::>(); - let packages = packages.into_iter().filter_map(|package| { - let sources = selected.remove(&package.name)?; - let dependencies = contracted_dependencies[&package.name].clone(); - Some(PackageInput { sources, dependencies, ..package }) - }); - Ok(packages.collect_vec()) -} - -fn compile_package_source_paths( - config: &BuildConfig<'_>, - source_paths: BTreeSet, - packages: Vec, - started: Instant, - package_progress: &progress::PackageProgress, -) -> Result<(), CompileError> { - let compilation = (|| { - let mut compilation = CompilationState::new(); - let mut source_ids = HashMap::new(); - for path in source_paths { - let file_id = load_source(&mut compilation, &path)?; - source_ids.insert(path, file_id); - } - let jobs = package_jobs(packages, &source_ids); - - if jobs.is_empty() { - return Err(io::Error::new(io::ErrorKind::NotFound, "no input files found").into()); - } - - package_progress.begin_compilation(jobs.len()); - schedule_package_jobs(&jobs, package_progress, &|package| { - query_package(&compilation, &package.source_ids) - })?; - Ok::<_, CompileError>(compilation) - })(); - let compilation = match compilation { - Ok(compilation) => compilation, - Err(error) => { - package_progress.clear(); - return Err(error); - } - }; - - let finalization_config = BuildConfig { - output: config.output, - current_directory: config.current_directory, - color: config.color, - progress: false, - diagnostics: config.diagnostics, - resilience: config.resilience, - }; - package_progress.finish(started.elapsed()); - let source_ids = compilation.input_source_ids(); - if matches!( - finalize_build(&compilation, &source_ids, &finalization_config)?, - BuildOutcome::Diagnostics - ) { - return Err(CompileError::Diagnostics { reported: config.diagnostics }); - } - Ok(()) -} - -fn package_jobs( - packages: Vec, - source_ids: &HashMap, -) -> Vec { - let package_indices = packages - .iter() - .enumerate() - .map(|(index, package)| (String::clone(&package.name), index)) - .collect::>(); - let jobs = packages.into_iter().map(|package| PackageJob { - name: package.name, - source_ids: package.sources.iter().map(|source| source_ids[source]).collect_vec(), - dependencies: package - .dependencies - .iter() - .filter_map(|dependency| package_indices.get(dependency).copied()) - .collect_vec(), - }); - jobs.collect_vec() -} - -struct PackageScheduler<'a, F> { - jobs: &'a [PackageJob], - groups: Vec, - remaining: Vec, - package_progress: &'a progress::PackageProgress, - execute: &'a F, - failed: AtomicBool, - error: Mutex>, -} - -impl<'a, F> PackageScheduler<'a, F> -where - F: Fn(&PackageJob) -> Result<(), CompileError> + Sync, -{ - fn spawn<'scope>(&'scope self, scope: &rayon::Scope<'scope>, group_index: usize) { - scope.spawn(move |scope| { - let results = self.groups[group_index].packages.par_iter().map(|&package_index| { - let package = &self.jobs[package_index]; - let started = Instant::now(); - let result = (self.execute)(package); - if result.is_ok() { - self.package_progress.complete(&package.name, started.elapsed()); - } - result - }); - let result = results.collect::, _>>(); - 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 &self.groups[group_index].dependents { - if self.remaining[dependent].fetch_sub(1, Ordering::AcqRel) == 1 { - self.spawn(scope, dependent); - } - } - }); - } -} - -fn schedule_package_jobs( - jobs: &[PackageJob], - package_progress: &progress::PackageProgress, - execute: &F, -) -> Result<(), CompileError> -where - F: Fn(&PackageJob) -> Result<(), CompileError> + Sync, -{ - let groups = package_groups(jobs); - let remaining = groups.iter().map(|group| AtomicUsize::new(group.dependencies.len())); - let remaining = remaining.collect_vec(); - let scheduler = PackageScheduler { - jobs, - groups, - remaining, - package_progress, - execute, - failed: AtomicBool::new(false), - error: Mutex::new(None), - }; - - rayon::scope(|scope| { - for (group_index, group) in scheduler.groups.iter().enumerate() { - if group.dependencies.is_empty() { - scheduler.spawn(scope, group_index); - } - } - }); - - scheduler - .error - .into_inner() - .expect("invariant violated: package build error is not poisoned") - .map_or(Ok(()), Err) -} - -fn query_package( - compilation: &CompilationState, - source_ids: &[FileId], -) -> Result<(), CompileError> { - let queried = source_ids.par_iter().map(|&file_id| { - analyse_source(compilation, file_id)?; - elaborate_source(compilation, file_id)?; - generate_source(compilation, file_id) - }); - queried.collect::, _>>()?; - Ok(()) -} - -fn analyse_source(compilation: &CompilationState, file_id: FileId) -> Result<(), CompileError> { - let engine = compilation.snapshot(); - engine.stabilized(file_id)?; - engine.indexed(file_id)?; - engine.resolved(file_id)?; - engine.lowered(file_id)?; - Ok(()) -} - -fn elaborate_source(compilation: &CompilationState, file_id: FileId) -> Result<(), CompileError> { - let engine = compilation.snapshot(); - engine.checked(file_id)?; - engine.foreign_validation(file_id)?; - Ok(()) -} - -fn generate_source(compilation: &CompilationState, file_id: FileId) -> Result<(), CompileError> { - let engine = compilation.snapshot(); - let _ = engine.javascript(file_id)?; - Ok(()) -} - -fn package_groups(jobs: &[PackageJob]) -> Vec { - let mut graph = DiGraphMap::::default(); - for package in 0..jobs.len() { - graph.add_node(package); - } - for (package, job) in jobs.iter().enumerate() { - for &dependency in &job.dependencies { - graph.add_edge(package, dependency, ()); - } - } - - let mut group_packages = tarjan_scc(&graph); - for packages in &mut group_packages { - packages.sort_unstable(); - } - group_packages.sort_unstable_by_key(|packages| packages[0]); - let mut package_to_group = vec![usize::MAX; jobs.len()]; - for (group, packages) in group_packages.iter().enumerate() { - for &package in packages { - package_to_group[package] = group; - } - } - - let dependencies = group_packages.iter().enumerate().map(|(group, packages)| { - let mut dependencies = packages - .iter() - .flat_map(|&package| &jobs[package].dependencies) - .map(|&package| package_to_group[package]) - .filter(|&dependency| dependency != group) - .collect_vec(); - dependencies.sort_unstable(); - dependencies.dedup(); - dependencies - }); - let dependencies = dependencies.collect_vec(); - let mut dependents = vec![vec![]; group_packages.len()]; - for (group, dependencies) in dependencies.iter().enumerate() { - for &dependency in dependencies { - dependents[dependency].push(group); - } - } - - let groups = group_packages.into_iter().zip(dependencies).zip(dependents).map( - |((packages, dependencies), dependents)| PackageGroup { - packages, - dependencies, - dependents, - }, - ); - groups.collect_vec() -} - -pub(crate) fn build( - compilation: &CompilationState, - config: &BuildConfig<'_>, -) -> Result { - let source_ids = compilation.input_source_ids(); - query_sources(compilation, &source_ids, config.progress)?; - finalize_build(compilation, &source_ids, config) -} - -fn finalize_build( - compilation: &CompilationState, - source_ids: &[FileId], - config: &BuildConfig<'_>, -) -> Result { - 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 { - 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)?; - let change = compilation - .observe_source(SourceUnitKey::clone(&unit), DiskObservation::Found(content.into())); - 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) -} - -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 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 config.diagnostics && has_diagnostics { - if config.progress && io::stderr().is_terminal() { - eprint!("\n\n"); - } else if !config.progress { - eprintln!(); - } - } - if config.diagnostics { - 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 query_sources( - compilation: &CompilationState, - source_ids: &[FileId], - show_progress: bool, -) -> Result<(), CompileError> { - let progress = MultiProgress::new(); - progress.set_move_cursor(true); - let analysing_progress = progress::phase(&progress, source_ids.len(), "Analyse", show_progress); - let checking_progress = - progress::phase(&progress, source_ids.len(), "Elaborate", show_progress); - let generating_progress = - progress::phase(&progress, source_ids.len(), "Generate", show_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); - } - analyse_source(compilation, 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)| { - if let Some(module_name) = module_name { - progress::set_message(&checking_progress, module_name); - } - elaborate_source(compilation, 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)| { - if let Some(module_name) = module_name { - progress::set_message(&generating_progress, module_name); - } - generate_source(compilation, file_id)?; - generating_progress.inc(1); - Ok::<_, CompileError>(()) - }); - generated.collect::, _>>()?; - progress::finish(&generating_progress); - Ok(()) -} - -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), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::sync::{Condvar, mpsc}; - use std::thread; - use std::time::Duration; - use tempfile::tempdir; - - fn package(name: &str, dependencies: Vec) -> PackageJob { - PackageJob { name: name.to_owned(), source_ids: vec![], dependencies } - } - - #[test] - fn package_source_ownership_must_be_unique() { - let directory = tempdir().unwrap(); - let source = directory.path().join("Shared.purs"); - fs::write(&source, "module Shared where\n").unwrap(); - let source_paths = BTreeSet::from([PathBuf::clone(&source)]); - let packages = vec![ - PackageInput { - name: "first".to_owned(), - sources: vec![PathBuf::clone(&source)], - dependencies: vec![], - }, - PackageInput { name: "second".to_owned(), sources: vec![source], dependencies: vec![] }, - ]; - - let result = select_package_sources(&source_paths, packages); - - assert!(matches!( - result, - Err(CompileError::ConflictingPackageSource { - first_package, - second_package, - .. - }) if first_package == "first" && second_package == "second" - )); - } - - #[test] - fn source_empty_packages_preserve_dependency_reachability() { - let directory = tempdir().unwrap(); - let library = directory.path().join("Library.purs"); - let application = directory.path().join("Application.purs"); - fs::write(&library, "module Library where\n").unwrap(); - fs::write(&application, "module Application where\n").unwrap(); - let source_paths = BTreeSet::from([PathBuf::clone(&library), PathBuf::clone(&application)]); - let packages = vec![ - PackageInput { - name: "library".to_owned(), - sources: vec![library], - dependencies: vec![], - }, - PackageInput { - name: "metadata-only".to_owned(), - sources: vec![], - dependencies: vec!["library".to_owned()], - }, - PackageInput { - name: "application".to_owned(), - sources: vec![application], - dependencies: vec!["metadata-only".to_owned()], - }, - ]; - - let selected = select_package_sources(&source_paths, packages).unwrap(); - let dependencies = selected - .into_iter() - .map(|package| (package.name, package.dependencies)) - .collect::>(); - - assert_eq!(dependencies["application"], ["library"]); - assert!(dependencies["library"].is_empty()); - } - - #[test] - fn package_groups_preserve_dependency_edges_and_independent_roots() { - let jobs = [ - package("root-a", vec![]), - package("root-b", vec![]), - package("middle", vec![0]), - package("leaf", vec![1, 2]), - ]; - - let groups = package_groups(&jobs); - - assert_eq!(groups.len(), 4); - assert!(groups[0].dependencies.is_empty()); - assert!(groups[1].dependencies.is_empty()); - assert_eq!(groups[2].dependencies, vec![0]); - assert_eq!(groups[3].dependencies, vec![1, 2]); - assert_eq!(groups[0].dependents, vec![2]); - assert_eq!(groups[1].dependents, vec![3]); - assert_eq!(groups[2].dependents, vec![3]); - } - - #[test] - fn package_cycles_are_scheduled_as_one_group() { - let jobs = [ - package("root", vec![]), - package("cycle-a", vec![0, 2]), - package("cycle-b", vec![1]), - package("downstream", vec![2]), - ]; - - let groups = package_groups(&jobs); - - assert_eq!(groups.len(), 3); - assert_eq!(groups[0].packages, vec![0]); - assert_eq!(groups[1].packages, vec![1, 2]); - assert_eq!(groups[2].packages, vec![3]); - assert_eq!(groups[1].dependencies, vec![0]); - assert_eq!(groups[2].dependencies, vec![1]); - assert_eq!(groups[0].dependents, vec![1]); - assert_eq!(groups[1].dependents, vec![2]); - } - - #[test] - fn package_downstream_of_cycle_waits_for_the_cycle() { - let jobs = vec![ - package("root", vec![]), - package("cycle-a", vec![0, 2]), - package("cycle-b", vec![1]), - package("downstream", vec![2]), - ]; - let gate = Arc::new((Mutex::new(false), Condvar::new())); - let worker_gate = Arc::clone(&gate); - let (started, starts) = mpsc::channel(); - let worker = thread::spawn(move || { - let pool = rayon::ThreadPoolBuilder::new().num_threads(2).build().unwrap(); - let progress = progress::packages(false, false); - pool.install(|| { - schedule_package_jobs(&jobs, &progress, &|package| { - started.send(String::clone(&package.name)).unwrap(); - if package.name == "cycle-b" { - let (lock, condition) = &*worker_gate; - let open = lock.lock().unwrap(); - drop(condition.wait_while(open, |open| !*open).unwrap()); - } - Ok(()) - }) - }) - }); - - assert_eq!(starts.recv_timeout(Duration::from_secs(1)).unwrap(), "root"); - let cycle = [ - starts.recv_timeout(Duration::from_secs(1)).unwrap(), - starts.recv_timeout(Duration::from_secs(1)).unwrap(), - ]; - assert_eq!( - cycle.into_iter().collect::>(), - BTreeSet::from(["cycle-a".to_owned(), "cycle-b".to_owned(),]) - ); - assert!(starts.recv_timeout(Duration::from_millis(100)).is_err()); - let (lock, condition) = &*gate; - *lock.lock().unwrap() = true; - condition.notify_all(); - - assert_eq!(starts.recv_timeout(Duration::from_secs(1)).unwrap(), "downstream"); - worker.join().unwrap().unwrap(); - } - - #[test] - fn independent_packages_start_concurrently() { - let jobs = vec![package("root-a", vec![]), package("root-b", vec![])]; - let gate = Arc::new((Mutex::new(false), Condvar::new())); - let worker_gate = Arc::clone(&gate); - let (started, starts) = mpsc::channel(); - let worker = thread::spawn(move || { - let pool = rayon::ThreadPoolBuilder::new().num_threads(2).build().unwrap(); - let progress = progress::packages(false, false); - pool.install(|| { - schedule_package_jobs(&jobs, &progress, &|package| { - started.send(String::clone(&package.name)).unwrap(); - let (lock, condition) = &*worker_gate; - let open = lock.lock().unwrap(); - drop(condition.wait_while(open, |open| !*open).unwrap()); - Ok(()) - }) - }) - }); - - let first = starts.recv_timeout(Duration::from_secs(1)).unwrap(); - let second = starts.recv_timeout(Duration::from_secs(1)); - let (lock, condition) = &*gate; - *lock.lock().unwrap() = true; - condition.notify_all(); - - assert!(second.is_ok(), "only {first} started before independent work was released"); - worker.join().unwrap().unwrap(); - } - - #[test] - fn dependent_package_waits_for_its_prerequisite() { - let jobs = vec![package("root", vec![]), package("dependent", vec![0])]; - let gate = Arc::new((Mutex::new(false), Condvar::new())); - let worker_gate = Arc::clone(&gate); - let (started, starts) = mpsc::channel(); - let worker = thread::spawn(move || { - let pool = rayon::ThreadPoolBuilder::new().num_threads(2).build().unwrap(); - let progress = progress::packages(false, false); - pool.install(|| { - schedule_package_jobs(&jobs, &progress, &|package| { - started.send(String::clone(&package.name)).unwrap(); - if package.name == "root" { - let (lock, condition) = &*worker_gate; - let open = lock.lock().unwrap(); - drop(condition.wait_while(open, |open| !*open).unwrap()); - } - Ok(()) - }) - }) - }); - - assert_eq!(starts.recv_timeout(Duration::from_secs(1)).unwrap(), "root"); - assert!(starts.recv_timeout(Duration::from_millis(100)).is_err()); - let (lock, condition) = &*gate; - *lock.lock().unwrap() = true; - condition.notify_all(); - - assert_eq!(starts.recv_timeout(Duration::from_secs(1)).unwrap(), "dependent"); - worker.join().unwrap().unwrap(); - } - - #[test] - fn packages_are_scheduled_exactly_once() { - let jobs = vec![ - package("root-a", vec![]), - package("root-b", vec![]), - package("fan-in", vec![0, 1]), - package("leaf", vec![2]), - ]; - let executions = Mutex::new(HashMap::new()); - let progress = progress::packages(false, false); - - schedule_package_jobs(&jobs, &progress, &|package| { - let mut executions = executions.lock().unwrap(); - *executions.entry(String::clone(&package.name)).or_insert(0) += 1; - Ok(()) - }) - .unwrap(); - - let executions = executions.into_inner().unwrap(); - assert_eq!(executions.len(), jobs.len()); - assert!(executions.values().all(|&count| count == 1)); - } -} 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 091cc3bd4..000000000 --- a/compiler-bin/src/lib.rs +++ /dev/null @@ -1,141 +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 - }; - let mut config = project_build_config(options.build); - config.diagnostics = !options.no_diagnostics; - project::start(project::build(config, 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, - diagnostics: true, - } -} 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/lsp.rs b/compiler-bin/src/lsp.rs deleted file mode 100644 index 1ab67ec0f..000000000 --- a/compiler-bin/src/lsp.rs +++ /dev/null @@ -1,1311 +0,0 @@ -pub mod capabilities; -pub mod error; -pub mod event; -pub mod extension; - -#[cfg(test)] -mod tests; - -use std::borrow::BorrowMut; -use std::collections::BTreeMap; -use std::ops::ControlFlow; -use std::path::PathBuf; -use std::sync::{Arc, LazyLock}; -use std::{env, fs, io, mem, process}; - -use analyzer::completion::SuggestionsCache; -use analyzer::position::PositionEncoding; -use analyzer::symbols::WorkspaceSymbolsCache; -use analyzer::{AnalyzerCapabilities, AnalyzerContext, AnalyzerHost}; -use async_lsp::client_monitor::ClientProcessMonitorLayer; -use async_lsp::concurrency::ConcurrencyLayer; -use async_lsp::panic::CatchUnwindLayer; -use async_lsp::router::Router; -use async_lsp::server::LifecycleLayer; -use async_lsp::{ClientSocket, LanguageClient, ResponseError}; -use building::QueryEngine; -use building::lifecycle::{ - AnalysisInvalidation, DiskObservation, DocumentKey, DocumentKind, FileLifecycle, ForeignEvent, - LifecycleChange, LifecycleEvent, ReloadFailure, SourceEvent, SourceUnitKey, -}; -use configuration::{Configuration, ConfigurationSettings, SourceDiscovery}; -use files::{FileId, ForeignSourceKind}; -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 rustc_hash::FxHashSet; -use tempfile::TempDir; -use tokio::task; -use tower::ServiceBuilder; - -use crate::lsp::capabilities::{ - ConfigurationCapabilities, negotiate_analyzer_capabilities, - negotiate_configuration_capabilities, negotiate_position_encoding, -}; -use crate::lsp::error::{AnalyzerResultExt, LspError}; -use crate::walk; - -static PRIM_DIRECTORY: LazyLock = - LazyLock::new(|| TempDir::new().expect("invariant violated: failed to create PRIM_DIRECTORY")); - -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"); - - 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); - } -} - -pub struct State { - pub startup_config: Arc, - pub config: Arc, - pub client: ClientSocket, - - pub engine: QueryEngine, - pub files: Arc>>, - pub diagnostics: event::DiagnosticScheduler, - - pub workspace_symbols_cache: Arc>, - pub suggestions_cache: Arc>, - - pub root: Option, - 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, -} - -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)); - let diagnostics = event::DiagnosticScheduler::default(); - - let workspace_symbols_cache = WorkspaceSymbolsCache::default(); - let workspace_symbols_cache = Arc::new(RwLock::new(workspace_symbols_cache)); - - let suggestions_cache = SuggestionsCache::default(); - let suggestions_cache = Arc::new(RwLock::new(suggestions_cache)); - - let root = None; - 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; - - State { - startup_config: Arc::clone(&config), - config, - client, - engine, - files, - diagnostics, - workspace_symbols_cache, - suggestions_cache, - root, - configuration_scope, - configuration_capabilities, - configuration_generation, - workspace_loaded, - selected_sources, - excluded_sources, - position_encoding, - analyzer_capabilities, - watched_files_dynamic_registration, - } - } - - fn spawn(&self, f: impl FnOnce(StateSnapshot) -> T + Send + 'static) -> task::JoinHandle - where - T: Send + 'static, - { - let snapshot = StateSnapshot { - engine: self.engine.snapshot(), - files: Arc::clone(&self.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)) - } - - fn invalidate_workspace_symbols(&self) { - let mut cache = self.workspace_symbols_cache.write(); - mem::take(&mut *cache); - } - - fn invalidate_suggestions_cache(&self) { - let mut cache = self.suggestions_cache.write(); - mem::take(&mut *cache); - } -} - -struct StateSnapshot { - engine: QueryEngine, - files: Arc>>, - workspace_symbols_cache: Arc>, - suggestions_cache: Arc>, - position_encoding: PositionEncoding, - analyzer_capabilities: AnalyzerCapabilities, -} - -impl StateSnapshot { - fn with_analyzer_context( - &self, - f: 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) - } -} - -struct LspAnalyzerHost<'a> { - queries: &'a QueryEngine, - files: RwLockReadGuard<'a, FileLifecycle>, -} - -impl AnalyzerHost for LspAnalyzerHost<'_> { - type Queries = QueryEngine; - - fn queries(&self) -> &QueryEngine { - self.queries - } - - fn file_id(&self, uri: &str) -> Option { - self.files.source_id(uri) - } - - fn file_uri(&self, file_id: FileId) -> Result, url::ParseError> { - let Some(uri) = self.files.source_path(file_id) else { - return Ok(None); - }; - Url::parse(&uri).map(Some) - } - - fn active_files(&self) -> impl Iterator { - self.files.source_ids() - } - - fn is_editable(&self, file_id: FileId) -> bool { - self.files.source_metadata(file_id).copied().unwrap_or(false) - } -} - -fn initialize( - state: &mut State, - p: extension::CustomInitializeParams, -) -> impl Future> + use<> { - let position_encoding = negotiate_position_encoding(&p.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.watched_files_dynamic_registration = - watched_files_dynamic_registration(&p.initialize_params.capabilities); - - state.configuration_scope = p - .initialize_params - .workspace_folders - .and_then(|folders| folders.first().map(|folder| Url::clone(&folder.uri))); - state.root = state - .configuration_scope - .as_ref() - .and_then(|uri| uri.to_file_path().ok()) - .or_else(|| env::current_dir().ok()); - async move { - Ok(InitializeResult { - server_info: Some(ServerInfo { - name: crate::PACKAGE_NAME.to_owned(), - version: Some(crate::VERSION.to_owned()), - }), - capabilities: ServerCapabilities { - completion_provider: Some(CompletionOptions { - resolve_provider: Some(true), - trigger_characters: Some(vec![".".to_string()]), - all_commit_characters: None, - work_done_progress_options: WorkDoneProgressOptions { - work_done_progress: None, - }, - completion_item: Some(CompletionOptionsCompletionItem { - label_details_support: Some(true), - }), - }), - code_action_provider: Some(CodeActionProviderCapability::Options( - CodeActionOptions { - code_action_kinds: Some(vec![CodeActionKind::QUICKFIX]), - ..CodeActionOptions::default() - }, - )), - definition_provider: Some(OneOf::Left(true)), - hover_provider: Some(HoverProviderCapability::Simple(true)), - references_provider: Some(OneOf::Left(true)), - rename_provider: Some(OneOf::Right(RenameOptions { - prepare_provider: Some(true), - work_done_progress_options: WorkDoneProgressOptions { - work_done_progress: None, - }, - })), - document_highlight_provider: Some(OneOf::Left(true)), - workspace_symbol_provider: Some(OneOf::Left(true)), - document_symbol_provider: Some(OneOf::Left(true)), - semantic_tokens_provider: Some( - SemanticTokensServerCapabilities::SemanticTokensOptions( - SemanticTokensOptions { - work_done_progress_options: WorkDoneProgressOptions { - work_done_progress: None, - }, - legend: SemanticTokensLegend { - token_types: analyzer::semantic_tokens::TOKEN_TYPES.to_vec(), - token_modifiers: analyzer::semantic_tokens::TOKEN_MODIFIERS - .to_vec(), - }, - range: Some(false), - full: Some(SemanticTokensFullOptions::Bool(true)), - }, - ), - ), - text_document_sync: Some(TextDocumentSyncCapability::Options( - TextDocumentSyncOptions { - open_close: Some(true), - change: Some(TextDocumentSyncKind::INCREMENTAL), - save: Some(TextDocumentSyncSaveOptions::Supported(true)), - ..TextDocumentSyncOptions::default() - }, - )), - position_encoding: Some(PositionEncodingKind::from(position_encoding)), - ..ServerCapabilities::default() - }, - }) - } -} - -fn watched_files_dynamic_registration(capabilities: &ClientCapabilities) -> bool { - capabilities - .workspace - .as_ref() - .and_then(|workspace| workspace.did_change_watched_files.as_ref()) - .and_then(|watched_files| watched_files.dynamic_registration) - .unwrap_or(false) -} - -fn shutdown(_state: &mut State, (): ()) -> impl Future> + use<> { - async { Ok(()) } -} - -fn initialized(state: &mut State, _: InitializedParams) -> Result<(), LspError> { - let _span = tracing::info_span!("initialization").entered(); - register_file_watcher(state); - register_configuration_changes(state); - - if state.configuration_capabilities.workspace_configuration { - request_workspace_configuration(state); - Ok(()) - } else { - apply_configuration(state, Arc::clone(&state.startup_config)) - } -} - -fn register_configuration_changes(state: &State) { - if !state.configuration_capabilities.dynamic_registration { - return; - } - - let registration = Registration { - id: "iris-workspace-configuration".to_string(), - method: notification::DidChangeConfiguration::METHOD.to_string(), - register_options: None, - }; - let parameters = RegistrationParams { registrations: vec![registration] }; - let mut client = ClientSocket::clone(&state.client); - task::spawn(async move { - if let Err(error) = client.register_capability(parameters).await { - tracing::warn!("Failed to register workspace configuration changes: {error}"); - } - }); -} - -struct ConfigurationReceived { - generation: u64, - result: Result, String>, -} - -fn request_workspace_configuration(state: &mut State) { - state.configuration_generation = state.configuration_generation.wrapping_add(1); - let generation = state.configuration_generation; - let parameters = ConfigurationParams { - items: vec![ConfigurationItem { - scope_uri: state.configuration_scope.clone(), - section: Some("iris.server".to_string()), - }], - }; - let mut client = ClientSocket::clone(&state.client); - task::spawn(async move { - let result = tokio::time::timeout( - std::time::Duration::from_secs(10), - client.configuration(parameters), - ) - .await - .map_err(|_| "workspace/configuration request timed out".to_string()) - .and_then(|result| result.map_err(|error| error.to_string())); - if let Err(error) = client.emit(ConfigurationReceived { generation, result }) { - tracing::error!("Failed to deliver workspace configuration: {error}"); - } - }); -} - -fn finish_workspace_configuration( - state: &mut State, - ConfigurationReceived { generation, result }: ConfigurationReceived, -) -> Result<(), LspError> { - if generation != state.configuration_generation { - return Ok(()); - } - - let configuration = result - .map_err(|error| format!("Failed to retrieve Iris settings: {error}")) - .and_then(|mut values| { - if values.len() != 1 { - return Err(format!( - "Invalid workspace/configuration response: expected one item, received {}", - values.len() - )); - } - let value = values.pop().expect("invariant violated: expected one configuration item"); - serde_json::from_value::>(value) - .map(|settings| settings.unwrap_or_default().apply_to(&state.startup_config)) - .map_err(|error| format!("Invalid Iris settings: {error}")) - }); - - match configuration { - Ok(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 { - apply_configuration(state, Arc::clone(&state.startup_config))?; - } - } - Ok(()) - } - Err(error) => { - report_configuration_error(state, &error); - if state.workspace_loaded { - Ok(()) - } else { - apply_configuration(state, Arc::clone(&state.startup_config)) - } - } - } -} - -fn did_change_configuration( - state: &mut State, - _: DidChangeConfigurationParams, -) -> Result<(), LspError> { - if state.configuration_capabilities.workspace_configuration { - request_workspace_configuration(state); - } - Ok(()) -} - -fn report_configuration_error(state: &mut State, error: &str) { - tracing::error!("{error}"); - let message = if state.workspace_loaded { - format!("{error}. The previous Iris settings remain active.") - } else { - format!("{error}. Iris will use its startup settings.") - }; - if let Err(error) = - state.client.show_message(ShowMessageParams { typ: MessageType::ERROR, message }) - { - tracing::warn!("Failed to report configuration error: {error}"); - } -} - -fn register_file_watcher(state: &State) { - if !state.watched_files_dynamic_registration { - return; - } - - let parameters = file_watcher_registration(); - let mut client = ClientSocket::clone(&state.client); - task::spawn(async move { - if let Err(error) = client.register_capability(parameters).await { - tracing::warn!("Failed to register source file watcher: {error}"); - } - }); -} - -fn file_watcher_registration() -> RegistrationParams { - let options = DidChangeWatchedFilesRegistrationOptions { - watchers: vec![ - FileSystemWatcher { - glob_pattern: GlobPattern::String("**/*.purs".to_string()), - kind: None, - }, - FileSystemWatcher { - glob_pattern: GlobPattern::String("**/*.js".to_string()), - kind: None, - }, - FileSystemWatcher { - glob_pattern: GlobPattern::String("**/*.jsx".to_string()), - kind: None, - }, - ], - }; - let register_options = serde_json::to_value(options) - .expect("invariant violated: watched file registration options must serialize"); - let registration = Registration { - id: "purescript-source-files".to_string(), - method: notification::DidChangeWatchedFiles::METHOD.to_string(), - register_options: Some(register_options), - }; - RegistrationParams { registrations: vec![registration] } -} - -fn exit(_state: &mut State, (): ()) -> Result<(), LspError> { - Ok(()) -} - -fn discover_manual( - root: &std::path::Path, - program: &str, - arguments: &[String], -) -> Result, LspError> { - tracing::info!("Using '{}'", program); - - let mut command = process::Command::new(program); - command.args(arguments); - - let output = command.output()?; - if !output.status.success() { - return Err(LspError::SourceCommandFailed(output.status)); - } - let output = str::from_utf8(&output.stdout)?; - - let walk::Walk { files, .. } = walk::walk(root, output.lines())?; - let files = files.into_iter().map(|file| { - let editable = file.starts_with(root); - (file, editable) - }); - Ok(files.collect()) -} - -fn discover_spago(root: &std::path::Path) -> Result, LspError> { - 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)) - }); - - Ok(files.sorted().collect_vec()) -} - -fn apply_configuration( - state: &mut State, - configuration: Arc, -) -> Result<(), LspError> { - 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 { - 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)); - } - - let refresh_diagnostics = state.workspace_loaded; - reconcile_files(state, &prepared, refresh_diagnostics)?; - state.config = configuration; - state.workspace_loaded = true; - Ok(()) -} - -fn reconcile_files( - state: &mut State, - files: &BTreeMap, bool)>, - refresh_diagnostics: bool, -) -> 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 mut lifecycle_change = LifecycleChange::default(); - for source in state.selected_sources.difference(&selected_sources).cloned().collect_vec() { - 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 }, - }; - lifecycle_change.combine(apply_lifecycle_event(state, event)); - for kind in ForeignSourceKind::ALL { - let event = LifecycleEvent::Foreign { - unit: SourceUnitKey::clone(&unit), - kind, - event: ForeignEvent::DiskObserved { disk: DiskObservation::NotFound }, - }; - lifecycle_change.combine(apply_lifecycle_event(state, event)); - } - } - for (file, (content, editable)) in files { - let uri = - Url::from_file_path(file).map_err(|_| LspError::PathParseFail(PathBuf::clone(file)))?; - let unit = source_unit_from_source_uri(&uri)?; - let event = LifecycleEvent::Source { - unit: SourceUnitKey::clone(&unit), - event: SourceEvent::DiskObserved { - disk: DiskObservation::Found(Arc::clone(content)), - metadata: *editable, - }, - }; - 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)?; - } - - state.excluded_sources.extend(state.selected_sources.difference(&selected_sources).cloned()); - for selected in &selected_sources { - state.excluded_sources.remove(selected); - } - state.selected_sources = selected_sources; - - tracing::info!("Loaded {} files.", files.len()); - - Ok(()) -} - -fn definition( - snapshot: StateSnapshot, - p: 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 result = snapshot.with_analyzer_context(|context| { - analyzer::definition::implementation(context, uri, position) - }); - - result.on_non_fatal(None) -} - -fn hover(snapshot: StateSnapshot, p: 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 result = snapshot - .with_analyzer_context(|context| analyzer::hover::implementation(context, uri, position)); - - result.on_non_fatal(None) -} - -fn code_action( - snapshot: StateSnapshot, - p: 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 result = snapshot.with_analyzer_context(|context| { - analyzer::code_action::implementation(context, uri, range, action_context) - }); - - result.on_non_fatal(None) -} - -fn completion( - snapshot: StateSnapshot, - p: 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 mut cache = snapshot.suggestions_cache.write(); - - let result = snapshot.with_analyzer_context(|context| { - analyzer::completion::implementation(context, &mut cache, uri, position) - }); - - result.on_non_fatal(None) -} - -fn resolve_completion_item( - snapshot: StateSnapshot, - item: CompletionItem, -) -> Result { - let _span = tracing::info_span!("resolve_completion_item").entered(); - analyzer::completion::resolve::implementation(&snapshot.engine, item) - .or_else(|(error, item)| Err(error).on_non_fatal(item)) -} - -fn references( - snapshot: StateSnapshot, - p: 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 result = snapshot.with_analyzer_context(|context| { - analyzer::references::implementation(context, uri, position) - }); - - result.on_non_fatal(None) -} - -fn rename(snapshot: StateSnapshot, p: 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 result = snapshot.with_analyzer_context(|context| { - analyzer::rename::implementation(context, uri, position, new_name) - }); - - result.on_non_fatal(None) -} - -fn prepare_rename( - snapshot: StateSnapshot, - p: TextDocumentPositionParams, -) -> Result, LspError> { - let _span = tracing::info_span!("prepare_rename").entered(); - let uri = p.text_document.uri; - let position = p.position; - - let result = - snapshot.with_analyzer_context(|context| analyzer::rename::prepare(context, uri, position)); - - result.on_non_fatal(None) -} - -fn document_highlight( - snapshot: StateSnapshot, - p: 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 result = snapshot.with_analyzer_context(|context| { - analyzer::document_highlight::implementation(context, uri, position) - }); - - result.on_non_fatal(None) -} - -fn workspace_symbols( - snapshot: StateSnapshot, - p: 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) - }); - - result.on_non_fatal(None) -} - -fn document_symbols( - snapshot: StateSnapshot, - p: DocumentSymbolParams, -) -> Result, LspError> { - let _span = tracing::info_span!("document_symbols").entered(); - let uri = p.text_document.uri; - let result = - snapshot.with_analyzer_context(|context| analyzer::symbols::document(context, uri)); - - result.on_non_fatal(None) -} - -fn semantic_tokens( - snapshot: StateSnapshot, - p: SemanticTokensParams, -) -> Result, LspError> { - let _span = tracing::info_span!("semantic_tokens").entered(); - let uri = p.text_document.uri; - let result = snapshot.with_analyzer_context(|context| { - analyzer::semantic_tokens::implementation(context, uri) - .map(|tokens| tokens.map(SemanticTokensResult::Tokens)) - }); - - result.on_non_fatal(None) -} - -fn document_content( - state: &State, - document: DocumentKind, - uri: &Url, -) -> Result, LspError> { - 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) - } - DocumentKind::Foreign(_) => { - let file_id = files - .foreign_id(uri.as_str()) - .ok_or_else(|| LspError::InvalidContentChange(Url::clone(uri)))?; - state - .engine - .foreign_content(file_id) - .ok_or_else(|| LspError::InvalidContentChange(Url::clone(uri))) - } - } -} - -fn apply_content_changes( - uri: &Url, - content: &str, - content_changes: &[TextDocumentContentChangeEvent], - position_encoding: PositionEncoding, -) -> Result, LspError> { - let mut content = content.to_string(); - for content_change in content_changes { - let Some(range) = content_change.range else { - content = content_change.text.clone(); - continue; - }; - - let positions = analyzer::position::PositionConverter::new(&content, position_encoding); - let start = positions - .protocol_position_to_utf8(range.start) - .and_then(|position| positions.utf8_position_to_offset(position)); - - let end = positions - .protocol_position_to_utf8(range.end) - .and_then(|position| positions.utf8_position_to_offset(position)); - - let (Some(start), Some(end)) = (start, end) else { - return Err(LspError::InvalidContentChange(Url::clone(uri))); - }; - - let start = usize::from(start); - let end = usize::from(end); - - if start > end { - return Err(LspError::InvalidContentChange(Url::clone(uri))); - } - - content.replace_range(start..end, &content_change.text); - } - 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() { - 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)?; - let event = match document { - DocumentKind::Foreign(kind) => LifecycleEvent::Foreign { - unit, - kind, - event: ForeignEvent::Changed { text: content, version: p.text_document.version }, - }, - DocumentKind::Source => LifecycleEvent::Source { - unit, - event: SourceEvent::Changed { text: content, version: p.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))?; - } - - Ok(()) -} - -fn did_open(state: &mut State, p: DidOpenTextDocumentParams) -> Result<(), LspError> { - let uri = &p.text_document.uri; - let (document, unit) = source_unit_from_document_uri(uri)?; - - let change = match document { - DocumentKind::Foreign(kind) => { - let event = LifecycleEvent::Foreign { - unit, - kind, - event: ForeignEvent::Opened { - text: Arc::from(p.text_document.text.as_str()), - version: p.text_document.version, - }, - }; - apply_lifecycle_event(state, event) - } - DocumentKind::Source => { - let editable = source_editable(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, - }, - }; - let mut change = apply_lifecycle_event(state, event); - change.combine(observe_sibling_foreign(state, &unit)?); - change - } - }; - finish_lifecycle_change(state, &change)?; - - if state.config.diagnostics.on_open { - emit_associated_diagnostics(state, p.text_document.uri)?; - } - - Ok(()) -} - -fn did_close(state: &mut State, p: DidCloseTextDocumentParams) -> Result<(), LspError> { - let uri = p.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 change = match document { - DocumentKind::Foreign(kind) => { - let event = - LifecycleEvent::Foreign { unit, kind, event: ForeignEvent::Closed { disk } }; - apply_lifecycle_event(state, event) - } - DocumentKind::Source => { - let document = DocumentKey::Source(SourceUnitKey::clone(&unit)); - let was_open = state.files.read().is_open(&document); - let event = LifecycleEvent::Source { - unit: SourceUnitKey::clone(&unit), - event: SourceEvent::Closed { disk }, - }; - let mut change = apply_lifecycle_event(state, event); - if was_open { - change.combine(observe_sibling_foreign(state, &unit)?); - } - change - } - }; - finish_lifecycle_change(state, &change)?; - emit_diagnostics_for_change(state, &change)?; - Ok(()) -} - -fn did_save(state: &mut State, p: DidSaveTextDocumentParams) -> Result<(), LspError> { - state.invalidate_suggestions_cache(); - - if state.config.diagnostics.on_save { - emit_associated_diagnostics(state, p.text_document.uri)?; - } - Ok(()) -} - -fn did_change_watched_files( - state: &mut State, - p: DidChangeWatchedFilesParams, -) -> Result<(), LspError> { - let mut source_units = FxHashSet::default(); - let mut foreign_units = FxHashSet::default(); - for change in p.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()) { - 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()) { - continue; - } - source_units.insert(unit); - } - None => {} - } - } - - let mut lifecycle_change = LifecycleChange::default(); - 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) { - continue; - } - let uri = Url::parse(unit.source())?; - let disk = observe_disk(&uri); - let source_found = matches!(disk, DiskObservation::Found(_)); - let metadata = source_editable(state, &unit, &uri); - let event = LifecycleEvent::Source { - unit: SourceUnitKey::clone(&unit), - event: SourceEvent::DiskObserved { disk, metadata }, - }; - lifecycle_change.combine(apply_lifecycle_event(state, event)); - if source_found { - lifecycle_change.combine(observe_sibling_foreign(state, &unit)?); - observed_foreign.insert(unit); - } - } - - for (unit, kind) in foreign_units { - if observed_foreign.contains(&unit) { - continue; - } - let document = DocumentKey::Foreign(SourceUnitKey::clone(&unit), kind); - if state.files.read().is_open(&document) { - continue; - } - let tracked = { - let files = state.files.read(); - files.source_id(unit.source()).is_some() - || files.foreign_id(unit.foreign_for(kind)).is_some() - }; - if !tracked { - continue; - } - let uri = Url::parse(unit.foreign_for(kind))?; - let event = LifecycleEvent::Foreign { - unit, - kind, - event: ForeignEvent::DiskObserved { disk: observe_disk(&uri) }, - }; - lifecycle_change.combine(apply_lifecycle_event(state, event)); - } - - finish_lifecycle_change(state, &lifecycle_change)?; - emit_diagnostics_for_change(state, &lifecycle_change)?; - Ok(()) -} - -fn document_kind(uri: &Url) -> Option { - if uri.path().ends_with(".js") { - Some(DocumentKind::Foreign(ForeignSourceKind::JavaScript)) - } else if uri.path().ends_with(".jsx") { - Some(DocumentKind::Foreign(ForeignSourceKind::Jsx)) - } else if uri.path().ends_with(".purs") { - Some(DocumentKind::Source) - } else { - None - } -} - -fn source_unit_from_document_uri(uri: &Url) -> Result<(DocumentKind, SourceUnitKey), LspError> { - let document = - document_kind(uri).ok_or_else(|| LspError::UnsupportedDocumentUri(Url::clone(uri)))?; - let unit = match document { - DocumentKind::Source => source_unit_from_source_uri(uri)?, - DocumentKind::Foreign(_) => source_unit_from_foreign_uri(uri)?, - }; - Ok((document, unit)) -} - -fn file_uri_with_extension(uri: &Url, extension: &str) -> Result { - if uri.scheme() != "file" || uri.to_file_path().is_err() { - return Err(LspError::InvalidFileUri(Url::clone(uri))); - } - let uri_path = uri.path(); - let file_name_start = uri_path.rfind('/').map_or(0, |index| index + 1); - let extension_start = uri_path[file_name_start..] - .rfind('.') - .filter(|index| *index > 0) - .map_or(uri_path.len(), |index| file_name_start + index); - let mut sibling_path = String::from(&uri_path[..extension_start]); - sibling_path.push('.'); - sibling_path.push_str(extension); - - let mut sibling_uri = Url::clone(uri); - sibling_uri.set_path(&sibling_path); - Ok(sibling_uri) -} - -fn source_unit_from_source_uri(source_uri: &Url) -> Result { - let javascript_uri = file_uri_with_extension(source_uri, "js")?; - let jsx_uri = file_uri_with_extension(source_uri, "jsx")?; - Ok(SourceUnitKey::with_foreign_sources( - source_uri.as_str(), - javascript_uri.as_str(), - jsx_uri.as_str(), - )) -} - -fn source_unit_from_foreign_uri(foreign_uri: &Url) -> Result { - let source_uri = file_uri_with_extension(foreign_uri, "purs")?; - source_unit_from_source_uri(&source_uri) -} - -fn emit_associated_diagnostics(state: &mut State, uri: Url) -> Result<(), LspError> { - let (_, unit) = source_unit_from_document_uri(&uri)?; - event::emit_collect_diagnostics(state, Url::parse(unit.source())?) -} - -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) -} - -fn finish_lifecycle_change(state: &mut State, change: &LifecycleChange) -> Result<(), LspError> { - state.diagnostics.invalidate(change, &state.files.read()); - if !matches!(change.analysis(), AnalysisInvalidation::None) { - state.invalidate_workspace_symbols(); - state.invalidate_suggestions_cache(); - } - for warning in change.warnings() { - tracing::warn!("{warning}"); - } - for removed in change.removed_sources() { - state.client.publish_diagnostics(PublishDiagnosticsParams { - uri: Url::parse(&removed.locator)?, - diagnostics: Vec::new(), - version: None, - })?; - } - Ok(()) -} - -fn observe_sibling_foreign( - state: &mut State, - unit: &SourceUnitKey, -) -> Result { - 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) { - continue; - } - let uri = Url::parse(unit.foreign_for(kind))?; - let event = LifecycleEvent::Foreign { - unit: SourceUnitKey::clone(unit), - kind, - event: ForeignEvent::DiskObserved { disk: observe_disk(&uri) }, - }; - change.combine(apply_lifecycle_event(state, event)); - } - Ok(change) -} - -fn observe_disk(uri: &Url) -> DiskObservation { - let path = uri.to_file_path().expect("invariant violated: expected a valid file URI"); - match fs::read_to_string(path) { - Ok(content) => DiskObservation::Found(Arc::from(content)), - Err(error) if error.kind() == io::ErrorKind::NotFound => DiskObservation::NotFound, - Err(error) => { - let kind = error.kind(); - DiskObservation::Failed(ReloadFailure::new(kind, error.to_string())) - } - } -} - -fn source_editable(state: &State, unit: &SourceUnitKey, uri: &Url) -> bool { - let previous = { - 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() - }; - previous.unwrap_or_else(|| match (&state.root, uri.to_file_path()) { - (Some(root), Ok(path)) => path.starts_with(root), - (Some(_), Err(())) => false, - (None, _) => true, - }) -} - -fn emit_diagnostics_for_change( - state: &mut State, - change: &LifecycleChange, -) -> Result<(), LspError> { - match change.analysis() { - AnalysisInvalidation::None => Ok(()), - AnalysisInvalidation::Sources(sources) => { - for file_id in sources { - event::emit_collect_diagnostics_id(state, *file_id)?; - } - Ok(()) - } - AnalysisInvalidation::Workspace => event::emit_collect_all_diagnostics(state), - } -} - -trait RequestExtension: BorrowMut> { - fn request_snapshot( - &mut self, - action: impl Fn(StateSnapshot, R::Params) -> Result + Send + Copy + 'static, - ) -> &mut Self { - 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) - }) - } - }); - self - } - - fn notification_ext( - &mut self, - 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 _ = action(state, parameters).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, - ) -> &mut Self - where - E: Send + 'static, - { - let this: &mut Router = self.borrow_mut(); - this.event::(move |state, event| { - let _ = action(state, event).inspect_err(|error| error.emit_trace()); - ControlFlow::Continue(()) - }); - self - } -} - -impl RequestExtension for Router {} - -pub async fn async_start(config: Arc) { - 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)); - - router - .request::(initialize) - .request::(shutdown) - .request_snapshot::(definition) - .request_snapshot::(hover) - .request_snapshot::(code_action) - .request_snapshot::(completion) - .request_snapshot::(resolve_completion_item) - .request_snapshot::(references) - .request_snapshot::(prepare_rename) - .request_snapshot::(rename) - .request_snapshot::(document_highlight) - .request_snapshot::(workspace_symbols) - .request_snapshot::(document_symbols) - .request_snapshot::(semantic_tokens) - .notification_ext::(initialized) - .notification_ext::(exit) - .notification_ext::(did_open) - .notification_ext::(did_save) - .notification_ext::(did_close) - .notification_ext::(did_change_configuration) - .notification_ext::(did_change) - .notification_ext::(did_change_watched_files) - .event_ext::(event::collect_diagnostics) - .event_ext::(event::finish_diagnostics) - .event_ext::(finish_workspace_configuration); - - ServiceBuilder::new() - .layer(LifecycleLayer::default()) - .layer(CatchUnwindLayer::default()) - .layer(ConcurrencyLayer::default()) - .layer(ClientProcessMonitorLayer::new(client)) - .service(router) - }); - - #[cfg(unix)] - let (stdin, stdout) = ( - async_lsp::stdio::PipeStdin::lock_tokio().unwrap(), - async_lsp::stdio::PipeStdout::lock_tokio().unwrap(), - ); - - #[cfg(not(unix))] - let (stdin, stdout) = ( - tokio_util::compat::TokioAsyncReadCompatExt::compat(tokio::io::stdin()), - 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 -} diff --git a/compiler-bin/src/lsp/capabilities.rs b/compiler-bin/src/lsp/capabilities.rs deleted file mode 100644 index f43b12c67..000000000 --- a/compiler-bin/src/lsp/capabilities.rs +++ /dev/null @@ -1,170 +0,0 @@ -use analyzer::AnalyzerCapabilities; -use analyzer::position::PositionEncoding; -use lsp_types::{InitializeParams, PositionEncodingKind}; - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub struct ConfigurationCapabilities { - pub workspace_configuration: bool, - pub dynamic_registration: bool, -} - -pub fn negotiate_configuration_capabilities( - params: &InitializeParams, -) -> ConfigurationCapabilities { - let Some(workspace) = ¶ms.capabilities.workspace else { - return ConfigurationCapabilities::default(); - }; - let workspace_configuration = workspace.configuration == Some(true); - let dynamic_registration = workspace_configuration - && workspace - .did_change_configuration - .as_ref() - .is_some_and(|capability| capability.dynamic_registration == Some(true)); - ConfigurationCapabilities { workspace_configuration, dynamic_registration } -} - -pub fn negotiate_analyzer_capabilities(params: &InitializeParams) -> AnalyzerCapabilities { - let workspace_edit = params - .capabilities - .workspace - .as_ref() - .and_then(|workspace| workspace.workspace_edit.as_ref()); - let honors_rename_annotations = params - .capabilities - .text_document - .as_ref() - .and_then(|text_document| text_document.rename.as_ref()) - .is_some_and(|rename| rename.honors_change_annotations == Some(true)); - let change_annotations = workspace_edit.is_some_and(|workspace_edit| { - workspace_edit.document_changes == Some(true) - && workspace_edit.change_annotation_support.is_some() - && honors_rename_annotations - }); - - if change_annotations { - AnalyzerCapabilities::default().with_change_annotations() - } else { - AnalyzerCapabilities::default() - } -} - -pub fn negotiate_position_encoding(params: &InitializeParams) -> PositionEncoding { - let Some(encodings) = params - .capabilities - .general - .as_ref() - .and_then(|general| general.position_encodings.as_ref()) - else { - return PositionEncoding::Utf16; - }; - - if encodings.contains(&PositionEncodingKind::UTF8) { - PositionEncoding::Utf8 - } else if encodings.contains(&PositionEncodingKind::UTF16) { - PositionEncoding::Utf16 - } else if encodings.contains(&PositionEncodingKind::UTF32) { - PositionEncoding::Utf32 - } else { - PositionEncoding::Utf16 - } -} - -#[cfg(test)] -mod tests { - use lsp_types::{ - ClientCapabilities, DynamicRegistrationClientCapabilities, GeneralClientCapabilities, - WorkspaceClientCapabilities, - }; - - use super::*; - - fn params(position_encodings: Option>) -> InitializeParams { - InitializeParams { - capabilities: ClientCapabilities { - general: Some(GeneralClientCapabilities { - position_encodings, - ..GeneralClientCapabilities::default() - }), - ..ClientCapabilities::default() - }, - ..InitializeParams::default() - } - } - - #[test] - fn defaults_to_utf16_without_client_preference() { - let params = InitializeParams::default(); - - let encoding = negotiate_position_encoding(¶ms); - assert_eq!(encoding, PositionEncoding::Utf16); - } - - #[test] - fn prefers_utf8_when_available() { - let params = params(Some(vec![ - PositionEncodingKind::UTF32, - PositionEncodingKind::UTF16, - PositionEncodingKind::UTF8, - ])); - - let encoding = negotiate_position_encoding(¶ms); - assert_eq!(encoding, PositionEncoding::Utf8); - } - - #[test] - fn falls_back_to_utf16_before_utf32() { - let params = params(Some(vec![PositionEncodingKind::UTF32, PositionEncodingKind::UTF16])); - - let encoding = negotiate_position_encoding(¶ms); - assert_eq!(encoding, PositionEncoding::Utf16); - } - - #[test] - fn supports_utf32_when_it_is_the_only_known_option() { - let params = params(Some(vec![PositionEncodingKind::UTF32])); - - let encoding = negotiate_position_encoding(¶ms); - assert_eq!(encoding, PositionEncoding::Utf32); - } - - #[test] - fn workspace_configuration_and_registration_are_negotiated_independently() { - let params = InitializeParams { - capabilities: ClientCapabilities { - workspace: Some(WorkspaceClientCapabilities { - configuration: Some(true), - did_change_configuration: Some(DynamicRegistrationClientCapabilities { - dynamic_registration: Some(true), - }), - ..WorkspaceClientCapabilities::default() - }), - ..ClientCapabilities::default() - }, - ..InitializeParams::default() - }; - - assert_eq!( - negotiate_configuration_capabilities(¶ms), - ConfigurationCapabilities { workspace_configuration: true, dynamic_registration: true } - ); - - let params = InitializeParams { - capabilities: ClientCapabilities { - workspace: Some(WorkspaceClientCapabilities { - configuration: Some(false), - did_change_configuration: Some(DynamicRegistrationClientCapabilities { - dynamic_registration: Some(true), - }), - ..WorkspaceClientCapabilities::default() - }), - ..ClientCapabilities::default() - }, - ..InitializeParams::default() - }; - - assert_eq!( - negotiate_configuration_capabilities(¶ms), - ConfigurationCapabilities::default() - ); - } -} diff --git a/compiler-bin/src/lsp/error.rs b/compiler-bin/src/lsp/error.rs deleted file mode 100644 index ccb035034..000000000 --- a/compiler-bin/src/lsp/error.rs +++ /dev/null @@ -1,104 +0,0 @@ -use std::path::PathBuf; -use std::{io, process, str}; - -use analyzer::AnalyzerError; -use async_lsp::ErrorCode; -use building::QueryError; -use lsp_types::Url; -use spago::LockfileGlobSetError; -use thiserror::Error; -use tokio::task; - -use crate::walk; - -#[derive(Error, Debug)] -pub enum LspError { - #[error("AnalyzerError: {0}")] - AnalyzerError(#[from] AnalyzerError), - #[error("QueryError: {0}")] - QueryError(#[from] QueryError), - #[error("Failed to parse file {0}")] - PathParseFail(PathBuf), - #[error("Expected a file URI, received {0}")] - InvalidFileUri(Url), - #[error("Expected a PureScript or JavaScript document URI, received {0}")] - UnsupportedDocumentUri(Url), - #[error("Invalid content change for document {0}")] - InvalidContentChange(Url), - #[error("UrlParseError: {0}")] - UrlParseError(#[from] url::ParseError), - #[error("Invalid or missing workspace root")] - MissingRoot, - #[error("SpagoError: {0}")] - SpagoLock(#[from] LockfileGlobSetError), - #[error("IoError: {0}")] - IoError(#[from] io::Error), - #[error("JoinError: {0}")] - JoinError(#[from] task::JoinError), - #[error("Utf8Error: {0}")] - Utf8Error(#[from] str::Utf8Error), - #[error("Source discovery command failed with {0}")] - SourceCommandFailed(process::ExitStatus), - #[error("GlobSetError: {0}")] - GlobSetError(#[from] globset::Error), - #[error("WalkError: {0}")] - WalkError(#[from] walk::Error), - #[error("async_lsp::Error: {0}")] - AsyncLspError(#[from] async_lsp::Error), -} - -impl LspError { - #[inline] - fn as_query_error(&self) -> Option<&QueryError> { - match self { - LspError::AnalyzerError(AnalyzerError::QueryError(q)) => Some(q), - LspError::QueryError(q) => Some(q), - _ => None, - } - } - - pub fn code(&self) -> ErrorCode { - if let Some(QueryError::Cancelled) = self.as_query_error() { - return ErrorCode::REQUEST_CANCELLED; - } - if matches!(self, LspError::AnalyzerError(AnalyzerError::RenameRejected(_))) { - return ErrorCode::INVALID_PARAMS; - } - ErrorCode::REQUEST_FAILED - } - - pub fn message(&self) -> &str { - if let Some(QueryError::Cancelled) = self.as_query_error() { - return "Request cancelled"; - } - if let LspError::AnalyzerError(AnalyzerError::RenameRejected(message)) = self { - return message; - } - "Request failed" - } - - pub fn emit_trace(&self) { - if let Some(QueryError::Cancelled) = self.as_query_error() { - tracing::warn!("{self}") - } else if matches!(self, LspError::AnalyzerError(AnalyzerError::RenameRejected(_))) { - tracing::warn!("{self}") - } else { - tracing::error!("{self}") - } - } -} - -pub trait AnalyzerResultExt { - /// Convenience method for handling an [`AnalyzerError::NonFatal`] - /// error, turning it into [`Result::Ok`] with the given item. - fn on_non_fatal(self, item: T) -> Result; -} - -impl AnalyzerResultExt for Result { - fn on_non_fatal(self, item: T) -> Result { - self.or_else(|error| match error { - AnalyzerError::NonFatal => Ok(item), - _ => Err(LspError::from(error)), - }) - } -} diff --git a/compiler-bin/src/lsp/event.rs b/compiler-bin/src/lsp/event.rs deleted file mode 100644 index ecfe81fe8..000000000 --- a/compiler-bin/src/lsp/event.rs +++ /dev/null @@ -1,315 +0,0 @@ -use std::collections::hash_map::Entry; - -use analyzer::diagnostics::CollectedDiagnostics; -use async_lsp::{ClientSocket, LanguageClient}; -use building::lifecycle::{AnalysisInvalidation, FileLifecycle, LifecycleChange}; -use files::FileId; -use lsp_types::{PublishDiagnosticsParams, Url}; -use rustc_hash::FxHashMap; -use tokio::task; - -use crate::lsp::error::LspError; -use crate::lsp::{State, StateSnapshot}; - -#[derive(Default)] -pub struct DiagnosticScheduler { - generations: FxHashMap, - jobs: FxHashMap, -} - -struct DiagnosticJob { - running: DiagnosticTicket, - queued: Option, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct DiagnosticTicket { - file_id: FileId, - generation: u64, - version: Option, -} - -impl DiagnosticScheduler { - pub fn invalidate(&mut self, change: &LifecycleChange, files: &FileLifecycle) { - match change.analysis() { - AnalysisInvalidation::None => {} - AnalysisInvalidation::Sources(sources) => { - for file_id in sources { - self.invalidate_source(*file_id); - } - } - AnalysisInvalidation::Workspace => { - let source_ids = files.source_ids(); - let source_ids = source_ids.collect::>(); - for file_id in source_ids { - self.invalidate_source(file_id); - } - } - } - for removed in change.removed_sources() { - self.invalidate_source(removed.file_id); - } - } - - fn invalidate_source(&mut self, file_id: FileId) { - let generation = self.generations.entry(file_id).or_default(); - *generation = generation - .checked_add(1) - .expect("invariant violated: diagnostic generation overflowed"); - if let Some(job) = self.jobs.get_mut(&file_id) { - job.queued = None; - } - } - - fn schedule(&mut self, file_id: FileId, version: Option) -> Option { - let generation = self.generations.get(&file_id).copied().unwrap_or_default(); - let ticket = DiagnosticTicket { file_id, generation, version }; - match self.jobs.entry(file_id) { - Entry::Vacant(entry) => { - entry.insert(DiagnosticJob { running: ticket, queued: None }); - Some(ticket) - } - Entry::Occupied(mut entry) => { - let job = entry.get_mut(); - if job.running != ticket && job.queued != Some(ticket) { - job.queued = Some(ticket); - } - None - } - } - } - - fn is_running(&self, ticket: DiagnosticTicket) -> bool { - self.jobs.get(&ticket.file_id).is_some_and(|job| job.running == ticket) - } - - fn is_current(&self, ticket: DiagnosticTicket) -> bool { - self.generations.get(&ticket.file_id).copied().unwrap_or_default() == ticket.generation - } - - fn complete(&mut self, ticket: DiagnosticTicket) -> Option { - let job = self.jobs.get_mut(&ticket.file_id)?; - if job.running != ticket { - return None; - } - let next = job.queued.take(); - if let Some(next) = next { - job.running = next; - } else { - self.jobs.remove(&ticket.file_id); - } - next - } -} - -pub fn emit_collect_diagnostics(state: &mut State, uri: Url) -> Result<(), LspError> { - let files = state.files.read(); - let uri = uri.as_str(); - - if let Some(file_id) = files.source_id(uri) { - state.client.emit(CollectDiagnostics(file_id))?; - } - - Ok(()) -} - -pub fn emit_collect_diagnostics_id(state: &mut State, file_id: FileId) -> Result<(), LspError> { - 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 editable_files = files - .source_ids() - .filter(|file_id| files.source_metadata(*file_id).copied().unwrap_or(false)); - for file_id in editable_files { - state.client.emit(CollectDiagnostics(file_id))?; - } - Ok(()) -} - -pub struct CollectDiagnostics(FileId); - -pub fn collect_diagnostics( - state: &mut State, - CollectDiagnostics(file_id): CollectDiagnostics, -) -> Result<(), LspError> { - let version = { - let files = state.files.read(); - if !files.contains_source(file_id) { - return Ok(()); - } - files.source_version(file_id) - }; - if let Some(ticket) = state.diagnostics.schedule(file_id, version) { - start_diagnostics(state, ticket); - } - Ok(()) -} - -fn start_diagnostics(state: &State, ticket: DiagnosticTicket) { - let worker = state.spawn(move |snapshot| { - let _span = tracing::info_span!("collect_diagnostics").entered(); - collect_diagnostics_core(snapshot, ticket) - }); - let client = ClientSocket::clone(&state.client); - task::spawn(async move { - let collected = await_diagnostics(worker).await; - let event = DiagnosticsFinished { ticket, collected }; - if let Err(error) = client.emit(event) { - LspError::from(error).emit_trace(); - } - }); -} - -fn collect_diagnostics_core( - snapshot: StateSnapshot, - ticket: DiagnosticTicket, -) -> Option { - let result = snapshot.with_analyzer_context(|context| { - analyzer::diagnostics::implementation(context, ticket.file_id) - }); - match result { - Ok(collected) => Some(collected), - Err(error) => { - LspError::from(error).emit_trace(); - None - } - } -} - -async fn await_diagnostics( - worker: task::JoinHandle>, -) -> Option { - match worker.await { - Ok(collected) => collected, - Err(error) => { - LspError::JoinError(error).emit_trace(); - None - } - } -} - -pub struct DiagnosticsFinished { - ticket: DiagnosticTicket, - collected: Option, -} - -pub fn finish_diagnostics( - state: &mut State, - DiagnosticsFinished { ticket, collected }: DiagnosticsFinished, -) -> Result<(), LspError> { - let running = state.diagnostics.is_running(ticket); - let current = running && state.diagnostics.is_current(ticket) && { - let files = state.files.read(); - files.contains_source(ticket.file_id) - && files.source_version(ticket.file_id) == ticket.version - }; - let next = state.diagnostics.complete(ticket); - - let publish_result = if current && let Some(collected) = collected { - state.client.publish_diagnostics(PublishDiagnosticsParams { - uri: collected.uri, - diagnostics: collected.diagnostics, - version: ticket.version, - }) - } else { - Ok(()) - }; - if let Some(next) = next { - start_diagnostics(state, next); - } - publish_result.map_err(LspError::from) -} - -#[cfg(test)] -mod tests { - use std::sync::Arc; - - use building::QueryEngine; - use building::lifecycle::{ - DiskObservation, FileLifecycle, LifecycleEvent, SourceEvent, SourceUnitKey, - }; - use files::Files; - - use super::{DiagnosticScheduler, await_diagnostics}; - - fn file_id() -> files::FileId { - let mut files = Files::default(); - files.insert("file:///src/Main.purs", "module Main where\n") - } - - #[test] - fn coalesces_requests_to_the_latest_generation() { - let file_id = file_id(); - let mut scheduler = DiagnosticScheduler::default(); - let first = scheduler.schedule(file_id, Some(1)).unwrap(); - - scheduler.invalidate_source(file_id); - assert_eq!(scheduler.schedule(file_id, Some(2)), None); - let queued = scheduler.jobs[&file_id].queued.unwrap(); - assert_eq!(queued.version, Some(2)); - - assert_eq!(scheduler.complete(first), Some(queued)); - assert!(scheduler.is_running(queued)); - } - - #[test] - fn invalidation_discards_a_queued_stale_request() { - let file_id = file_id(); - let mut scheduler = DiagnosticScheduler::default(); - let first = scheduler.schedule(file_id, Some(1)).unwrap(); - scheduler.invalidate_source(file_id); - scheduler.schedule(file_id, Some(2)); - - scheduler.invalidate_source(file_id); - assert_eq!(scheduler.jobs[&file_id].queued, None); - assert_eq!(scheduler.complete(first), None); - assert!(!scheduler.is_current(first)); - } - - #[test] - fn workspace_change_invalidates_unrelated_running_diagnostics() { - let engine = QueryEngine::default(); - let mut lifecycle = FileLifecycle::default(); - let first_unit = SourceUnitKey::new("file:///src/Main.purs", "file:///src/Main.js"); - let event = LifecycleEvent::Source { - unit: first_unit, - event: SourceEvent::DiskObserved { - disk: DiskObservation::Found(Arc::from("module Main where\n")), - metadata: true, - }, - }; - lifecycle.apply(&engine, event); - let first_id = lifecycle.source_id("file:///src/Main.purs").unwrap(); - - let mut scheduler = DiagnosticScheduler::default(); - let ticket = scheduler.schedule(first_id, None).unwrap(); - let second_unit = SourceUnitKey::new("file:///src/Library.purs", "file:///src/Library.js"); - let event = LifecycleEvent::Source { - unit: second_unit, - event: SourceEvent::DiskObserved { - disk: DiskObservation::Found(Arc::from("module Library where\n")), - metadata: true, - }, - }; - let change = lifecycle.apply(&engine, event); - scheduler.invalidate(&change, &lifecycle); - - assert!(!scheduler.is_current(ticket)); - } - - #[tokio::test] - async fn failed_diagnostics_worker_returns_no_result() { - let worker = tokio::spawn(async { - panic!("diagnostics worker failed"); - #[allow(unreachable_code)] - None - }); - - assert!(await_diagnostics(worker).await.is_none()); - } -} diff --git a/compiler-bin/src/lsp/extension.rs b/compiler-bin/src/lsp/extension.rs deleted file mode 100644 index f12a1bb69..000000000 --- a/compiler-bin/src/lsp/extension.rs +++ /dev/null @@ -1,20 +0,0 @@ -use lsp_types::*; -use serde::{Deserialize, Serialize}; - -pub enum CustomInitialize {} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CustomInitializeParams { - #[serde(flatten)] - pub initialize_params: InitializeParams, - pub work_done_token: Option, -} - -impl request::Request for CustomInitialize { - type Params = CustomInitializeParams; - - type Result = InitializeResult; - - const METHOD: &'static str = request::Initialize::METHOD; -} diff --git a/compiler-bin/src/lsp/tests.rs b/compiler-bin/src/lsp/tests.rs deleted file mode 100644 index 6b788f9d6..000000000 --- a/compiler-bin/src/lsp/tests.rs +++ /dev/null @@ -1,289 +0,0 @@ -use std::fs; -use std::sync::Arc; - -use analyzer::position::PositionEncoding; -use async_lsp::ResponseError; -use async_lsp::router::Router; -use building::lifecycle::{ - ContentAuthority, DiskObservation, DocumentKind, ForeignEvent, LifecycleEvent, SourceEvent, - SourceUnitKey, -}; -use configuration::{Configuration, Diagnostics}; -use files::ForeignSourceKind; -use lsp_types::{ - DidCloseTextDocumentParams, Position, Range, TextDocumentContentChangeEvent, - TextDocumentIdentifier, Url, -}; -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, -}; - -fn test_config() -> Arc { - Arc::new(Configuration { - diagnostics: Diagnostics { on_open: false, on_save: false, on_change: false }, - ..Configuration::default() - }) -} - -fn assert_source_close_result( - source_uri: Url, - foreign_uri: Url, - source_authority: Option, -) { - 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 event = LifecycleEvent::Source { - unit: SourceUnitKey::clone(&unit), - event: SourceEvent::Opened { - text: Arc::from("module Main where\n"), - version: 1, - metadata: true, - }, - }; - apply_lifecycle_event(&mut state, event); - let event = LifecycleEvent::Foreign { - unit: SourceUnitKey::clone(&unit), - kind: ForeignSourceKind::JavaScript, - event: ForeignEvent::DiskObserved { - disk: DiskObservation::Found(Arc::from("export const life = 42;\n")), - }, - }; - apply_lifecycle_event(&mut state, event); - - let parameters = DidCloseTextDocumentParams { - text_document: TextDocumentIdentifier { uri: Url::clone(&source_uri) }, - }; - did_close(&mut state, parameters).unwrap(); - - 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); - - Router::::new(state) - }); -} - -#[test] -fn source_and_foreign_uris_produce_the_same_unit_key() { - let directory = tempdir().unwrap(); - let source_path = directory.path().join("Source Files").join("Main.purs"); - let foreign_path = source_path.with_extension("js"); - let jsx_path = source_path.with_extension("jsx"); - let source_uri = Url::from_file_path(source_path).unwrap(); - let foreign_uri = Url::from_file_path(foreign_path).unwrap(); - let jsx_uri = Url::from_file_path(jsx_path).unwrap(); - - let from_source = source_unit_from_source_uri(&source_uri).unwrap(); - let from_foreign = source_unit_from_foreign_uri(&foreign_uri).unwrap(); - let from_jsx = source_unit_from_foreign_uri(&jsx_uri).unwrap(); - - assert_eq!(from_source, from_foreign); - assert_eq!(from_source, from_jsx); - assert_eq!(from_source.source(), source_uri.as_str()); - assert_eq!(from_source.foreign(), foreign_uri.as_str()); - assert_eq!(from_source.foreign_for(ForeignSourceKind::Jsx), jsx_uri.as_str()); -} - -#[test] -fn localhost_source_and_foreign_uris_keep_the_same_authority() { - let source_uri = - Url::parse("file://localhost/workspace/Source%20Files/Main.purs?view=1#selection").unwrap(); - let foreign_uri = - Url::parse("file://localhost/workspace/Source%20Files/Main.js?view=1#selection").unwrap(); - - let from_source = source_unit_from_source_uri(&source_uri).unwrap(); - let from_foreign = source_unit_from_foreign_uri(&foreign_uri).unwrap(); - - assert_eq!(from_source, from_foreign); - assert_eq!(from_source.source(), source_uri.as_str()); - assert_eq!(from_source.foreign(), foreign_uri.as_str()); -} - -#[test] -fn non_file_document_uris_are_rejected() { - let source_uri = Url::parse("untitled:Main.purs").unwrap(); - assert!(source_unit_from_source_uri(&source_uri).is_err()); -} - -#[test] -fn document_kind_is_bounded_to_source_and_foreign_extensions() { - let source_uri = Url::parse("file:///workspace/Main.purs").unwrap(); - let foreign_uri = Url::parse("file:///workspace/Main.js").unwrap(); - let jsx_uri = Url::parse("file:///workspace/Main.jsx").unwrap(); - let unsupported_uri = Url::parse("file:///workspace/Main.json").unwrap(); - - assert_eq!(document_kind(&source_uri), Some(DocumentKind::Source)); - assert_eq!( - document_kind(&foreign_uri), - Some(DocumentKind::Foreign(ForeignSourceKind::JavaScript)) - ); - assert_eq!(document_kind(&jsx_uri), Some(DocumentKind::Foreign(ForeignSourceKind::Jsx))); - assert_eq!(document_kind(&unsupported_uri), None); - assert!(source_unit_from_document_uri(&unsupported_uri).is_err()); -} - -#[test] -fn closing_a_deleted_source_also_removes_its_deleted_disk_foreign() { - let directory = tempdir().unwrap(); - let source_path = directory.path().join("Main.purs"); - let foreign_path = source_path.with_extension("js"); - let source_uri = Url::from_file_path(source_path).unwrap(); - let foreign_uri = Url::from_file_path(foreign_path).unwrap(); - assert_source_close_result(source_uri, foreign_uri, None); -} - -#[test] -fn failed_source_reload_still_removes_its_deleted_disk_foreign() { - let directory = tempdir().unwrap(); - let source_path = directory.path().join("Main.purs"); - let foreign_path = source_path.with_extension("js"); - fs::write(&source_path, [0xff]).unwrap(); - let source_uri = Url::from_file_path(source_path).unwrap(); - let foreign_uri = Url::from_file_path(foreign_path).unwrap(); - assert_source_close_result(source_uri, foreign_uri, Some(ContentAuthority::Retained)); -} - -#[test] -fn duplicate_source_close_does_not_reconcile_foreign() { - let directory = tempdir().unwrap(); - let source_path = directory.path().join("Main.purs"); - let foreign_path = source_path.with_extension("js"); - fs::write(&source_path, "module Main where\n").unwrap(); - fs::write(&foreign_path, "export const life = 42;\n").unwrap(); - let source_uri = Url::from_file_path(source_path).unwrap(); - 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 event = LifecycleEvent::Source { - unit: SourceUnitKey::clone(&unit), - event: SourceEvent::Opened { - text: Arc::from("module Main where\n"), - version: 1, - metadata: true, - }, - }; - apply_lifecycle_event(&mut state, event); - let event = LifecycleEvent::Foreign { - unit: SourceUnitKey::clone(&unit), - kind: ForeignSourceKind::JavaScript, - event: ForeignEvent::DiskObserved { - disk: DiskObservation::Found(Arc::from("export const life = 42;\n")), - }, - }; - apply_lifecycle_event(&mut state, event); - - let parameters = DidCloseTextDocumentParams { - text_document: TextDocumentIdentifier { uri: Url::clone(&source_uri) }, - }; - 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 parameters = DidCloseTextDocumentParams { - text_document: TextDocumentIdentifier { uri: Url::clone(&source_uri) }, - }; - did_close(&mut state, parameters).unwrap(); - - 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_content(foreign_id).unwrap().as_ref(), - "export const life = 42;\n", - ); - drop(files); - - Router::::new(state) - }); -} - -#[test] -fn disk_observation_distinguishes_content_and_absence() { - let directory = tempdir().unwrap(); - let source_path = directory.path().join("Main.purs"); - let source_uri = Url::from_file_path(&source_path).unwrap(); - - fs::write(&source_path, "module Main where\n").unwrap(); - assert!(matches!( - observe_disk(&source_uri), - DiskObservation::Found(content) if content.as_ref() == "module Main where\n" - )); - - fs::remove_file(source_path).unwrap(); - assert_eq!(observe_disk(&source_uri), DiskObservation::NotFound); -} - -#[test] -fn incremental_content_changes_apply_sequentially() { - let uri = Url::parse("file:///workspace/Main.purs").unwrap(); - let changes = [ - TextDocumentContentChangeEvent { - range: Some(Range::new(Position::new(1, 0), Position::new(1, 4))), - range_length: Some(4), - text: "answer".to_string(), - }, - TextDocumentContentChangeEvent { - range: Some(Range::new(Position::new(1, 9), Position::new(1, 10))), - range_length: Some(1), - text: "42".to_string(), - }, - ]; - - let content = apply_content_changes( - &uri, - "module Main where\nlife = 0\n", - &changes, - PositionEncoding::Utf16, - ) - .unwrap(); - - assert_eq!(content.as_ref(), "module Main where\nanswer = 42\n"); -} - -#[test] -fn incremental_content_changes_use_negotiated_position_encoding() { - let uri = Url::parse("file:///workspace/Main.purs").unwrap(); - let changes = [TextDocumentContentChangeEvent { - range: Some(Range::new(Position::new(0, 3), Position::new(0, 4))), - range_length: Some(1), - text: "c".to_string(), - }]; - - let content = apply_content_changes(&uri, "a😀b", &changes, PositionEncoding::Utf16).unwrap(); - - assert_eq!(content.as_ref(), "a😀c"); -} - -#[test] -fn full_content_change_resets_incremental_change_base() { - let uri = Url::parse("file:///workspace/Main.purs").unwrap(); - let changes = [ - TextDocumentContentChangeEvent { - range: None, - range_length: None, - text: "life = 1".to_string(), - }, - TextDocumentContentChangeEvent { - range: Some(Range::new(Position::new(0, 7), Position::new(0, 8))), - range_length: Some(1), - text: "2".to_string(), - }, - ]; - - let content = - apply_content_changes(&uri, "discarded", &changes, PositionEncoding::Utf16).unwrap(); - - assert_eq!(content.as_ref(), "life = 2"); -} 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 be19f956b..000000000 --- a/compiler-bin/src/progress.rs +++ /dev/null @@ -1,921 +0,0 @@ -use std::collections::VecDeque; -use std::fmt; -use std::io::{self, IsTerminal, Write}; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex, OnceLock}; -use std::thread::{self, JoinHandle}; -use std::time::{Duration, Instant}; - -use console::{Color, Style, measure_text_width, truncate_str}; -use indicatif::{MultiProgress, ProgressBar, ProgressState, ProgressStyle}; -use itertools::Itertools; -use ratatui::backend::CrosstermBackend; -use ratatui::buffer::Buffer; -use ratatui::layout::Rect; -use ratatui::style::{Color as RatatuiColor, Modifier, Style as RatatuiStyle}; -use ratatui::text::{Line, Span}; -use ratatui::widgets::{Paragraph, Widget}; -use ratatui::{Terminal, TerminalOptions, Viewport}; -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); -const PACKAGE_ANIMATION_INTERVAL: Duration = Duration::from_millis(80); -const PACKAGE_HISTORY_LENGTH: usize = 10; -const PACKAGE_REGION_WIDTH: usize = 80; -const PACKAGE_PREPARATION_HEIGHT: u16 = 1; -const PACKAGE_REGION_HEIGHT: u16 = PACKAGE_HISTORY_LENGTH as u16 + 2; -static TERMINAL_THEME_MODE: OnceLock> = OnceLock::new(); - -type PackageTerminal = Terminal>; - -pub(crate) struct PackageProgress { - state: Arc>, - terminal: Option>>, - appearance: PackageAppearance, - started: Instant, - animation: Option, -} - -struct PackageAnimation { - active: Arc, - thread: Mutex>>, -} - -struct PackageProgressState { - total: usize, - completed_count: usize, - completed: VecDeque, - phase: PackageProgressPhase, -} - -struct CompletedPackage { - name: String, - elapsed: Duration, -} - -enum PackageProgressPhase { - Preparing, - Compiling, - Finished(Duration), -} - -#[derive(Clone, Copy)] -enum PackageAppearance { - TrueColor { foreground: (u8, u8, u8), background: (u8, u8, u8) }, - Ansi, - Plain, -} - -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 packages(show: bool, color: bool) -> PackageProgress { - let state = Arc::new(Mutex::new(PackageProgressState { - total: 0, - completed_count: 0, - completed: VecDeque::with_capacity(PACKAGE_HISTORY_LENGTH), - phase: PackageProgressPhase::Preparing, - })); - let appearance = package_appearance(color); - let terminal = if show && io::stderr().is_terminal() { - let backend = CrosstermBackend::new(io::stderr()); - let options = TerminalOptions { viewport: Viewport::Inline(PACKAGE_PREPARATION_HEIGHT) }; - Terminal::with_options(backend, options).ok().map(|terminal| Arc::new(Mutex::new(terminal))) - } else { - None - }; - let started = Instant::now(); - let animation = terminal.as_ref().map(|terminal| { - let active = Arc::new(AtomicBool::new(true)); - let animation_active = Arc::clone(&active); - let animation_state = Arc::clone(&state); - let animation_terminal = Arc::clone(terminal); - let thread = thread::spawn(move || { - while animation_active.load(Ordering::Acquire) { - thread::sleep(PACKAGE_ANIMATION_INTERVAL); - if !animation_active.load(Ordering::Acquire) { - break; - } - let animation_frame = package_animation_frame(started.elapsed()); - let _ = draw_packages( - &animation_state, - &animation_terminal, - appearance, - animation_frame, - ); - } - }); - PackageAnimation { active, thread: Mutex::new(Some(thread)) } - }); - let progress = PackageProgress { state, terminal, appearance, started, animation }; - progress.draw(); - progress -} - -impl PackageProgress { - pub(crate) fn begin_compilation(&self, total: usize) { - let mut state = - self.state.lock().expect("invariant violated: package progress state is not poisoned"); - state.total = total; - state.phase = PackageProgressPhase::Compiling; - drop(state); - self.expand_terminal(); - self.draw(); - } - - pub(crate) fn complete(&self, name: &str, elapsed: Duration) { - let mut state = - self.state.lock().expect("invariant violated: package progress state is not poisoned"); - if state.completed.len() == PACKAGE_HISTORY_LENGTH { - state.completed.pop_front(); - } - state.completed.push_back(CompletedPackage { name: name.to_owned(), elapsed }); - state.completed_count += 1; - drop(state); - self.draw(); - } - - pub(crate) fn finish(&self, elapsed: Duration) { - self.stop_animation(); - let mut state = - self.state.lock().expect("invariant violated: package progress state is not poisoned"); - state.phase = PackageProgressPhase::Finished(elapsed); - drop(state); - if let Some(terminal) = &self.terminal { - let _ = finish_packages(&self.state, terminal, self.appearance); - } - } - - pub(crate) fn clear(&self) { - self.stop_animation(); - if let Some(terminal) = &self.terminal { - let mut terminal = terminal - .lock() - .expect("invariant violated: package progress terminal is not poisoned"); - let origin = terminal.get_frame().area().as_position(); - let _ = terminal.clear(); - let _ = terminal.set_cursor_position(origin); - let _ = terminal.show_cursor(); - let _ = Write::flush(terminal.backend_mut()); - } - } - - fn draw(&self) { - let Some(terminal) = &self.terminal else { - return; - }; - let animation_frame = package_animation_frame(self.started.elapsed()); - let _ = draw_packages(&self.state, terminal, self.appearance, animation_frame); - } - - fn expand_terminal(&self) { - let Some(terminal) = &self.terminal else { - return; - }; - let mut terminal = - terminal.lock().expect("invariant violated: package progress terminal is not poisoned"); - let origin = terminal.get_frame().area().as_position(); - let _ = terminal.clear(); - let _ = terminal.set_cursor_position(origin); - let _ = Write::flush(terminal.backend_mut()); - - let backend = CrosstermBackend::new(io::stderr()); - let options = TerminalOptions { viewport: Viewport::Inline(PACKAGE_REGION_HEIGHT) }; - if let Ok(expanded) = Terminal::with_options(backend, options) { - *terminal = expanded; - } - } - - fn stop_animation(&self) { - let Some(animation) = &self.animation else { - return; - }; - animation.active.store(false, Ordering::Release); - if let Some(thread) = animation - .thread - .lock() - .expect("invariant violated: package animation thread is not poisoned") - .take() - { - let _ = thread.join(); - } - } -} - -impl Drop for PackageProgress { - fn drop(&mut self) { - self.stop_animation(); - } -} - -fn package_animation_frame(elapsed: Duration) -> usize { - (elapsed.as_millis() / PACKAGE_ANIMATION_INTERVAL.as_millis()) as usize -} - -fn draw_packages( - state: &Arc>, - terminal: &Arc>, - appearance: PackageAppearance, - animation_frame: usize, -) -> io::Result { - let state = state.lock().expect("invariant violated: package progress state is not poisoned"); - let mut terminal = - terminal.lock().expect("invariant violated: package progress terminal is not poisoned"); - let mut viewport = Rect::ZERO; - terminal.draw(|frame| { - viewport = frame.area(); - let content = Rect { width: viewport.width.min(PACKAGE_REGION_WIDTH as u16), ..viewport }; - frame.render_widget(PackageView { state: &state, appearance, animation_frame }, content); - })?; - Ok(viewport) -} - -fn finish_packages( - state: &Arc>, - terminal: &Arc>, - appearance: PackageAppearance, -) -> io::Result<()> { - let area = draw_packages(state, terminal, appearance, 0)?; - let mut terminal = - terminal.lock().expect("invariant violated: package progress terminal is not poisoned"); - 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()?; - let writer = terminal.backend_mut(); - writer.write_all(b"\r\n")?; - Write::flush(writer) - } else { - Ok(()) - } -} - -fn package_appearance(color: bool) -> PackageAppearance { - if !color { - return PackageAppearance::Plain; - } - if !console::true_colors_enabled_stderr() { - return PackageAppearance::Ansi; - } - - let mut options = QueryOptions::default(); - options.timeout = Duration::from_millis(100); - let Ok(palette) = terminal_colorsaurus::color_palette(options) else { - return PackageAppearance::Ansi; - }; - PackageAppearance::TrueColor { - foreground: palette.foreground.scale_to_8bit(), - background: palette.background.scale_to_8bit(), - } -} - -struct PackageView<'a> { - state: &'a PackageProgressState, - appearance: PackageAppearance, - animation_frame: usize, -} - -impl Widget for PackageView<'_> { - fn render(self, area: Rect, buffer: &mut Buffer) { - if area.is_empty() { - return; - } - let width = area.width as usize; - if matches!(self.state.phase, PackageProgressPhase::Preparing) { - Paragraph::new(render_package_preparation( - width, - self.appearance, - self.animation_frame, - )) - .render(Rect::new(area.x, area.y, area.width, 1), buffer); - return; - } - if area.height < PACKAGE_REGION_HEIGHT { - Paragraph::new(render_package_progress( - self.state, - width, - self.appearance, - self.animation_frame, - )) - .render(Rect::new(area.x, area.bottom() - 1, area.width, 1), buffer); - return; - } - - let blank_rows = PACKAGE_HISTORY_LENGTH.saturating_sub(self.state.completed.len()); - for (index, package) in self.state.completed.iter().enumerate() { - let row = blank_rows + index; - let line = Line::styled( - render_completed_package(package, width), - package_history_style(self.appearance, row), - ); - Paragraph::new(line) - .render(Rect::new(area.x, area.y + row as u16, area.width, 1), buffer); - } - - if area.height > PACKAGE_HISTORY_LENGTH as u16 { - let separator = "─".repeat(width); - Paragraph::new(separator).render( - Rect::new(area.x, area.y + PACKAGE_HISTORY_LENGTH as u16, area.width, 1), - buffer, - ); - } - Paragraph::new(render_package_progress( - self.state, - width, - self.appearance, - self.animation_frame, - )) - .render( - Rect::new(area.x, area.y + PACKAGE_HISTORY_LENGTH as u16 + 1, area.width, 1), - buffer, - ); - } -} - -fn render_completed_package(package: &CompletedPackage, width: usize) -> String { - let elapsed_milliseconds = package.elapsed.as_secs_f64() * 1_000.0; - let timing = format!("{elapsed_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 name = sanitize(&package.name); - let name = truncate_str(&name, name_width, "…"); - let padding = name_width - measure_text_width(&name) + 2; - format!("{name}{}{timing}", " ".repeat(padding)) -} - -fn render_package_progress( - state: &PackageProgressState, - width: usize, - appearance: PackageAppearance, - animation_frame: usize, -) -> Line<'static> { - let completed = state.completed_count; - let status = match state.phase { - PackageProgressPhase::Finished(elapsed) => format!("Finished in {elapsed:.2?}"), - PackageProgressPhase::Compiling => "Compiling".to_owned(), - PackageProgressPhase::Preparing => unreachable!("preparation is rendered separately"), - }; - let count = format!("{completed}/{}", state.total); - let count_width = measure_text_width(&count); - if width <= count_width + 4 { - return Line::from(truncate_str(&count, width, "").to_string()); - } - - const MINIMUM_BAR_WIDTH: usize = 8; - let status_width = width.saturating_sub(count_width + 4 + MINIMUM_BAR_WIDTH); - let status = truncate_str(&status, status_width, "…"); - let bar_width = width - measure_text_width(&status) - count_width - 4; - let accent = package_accent_style(appearance); - let mut spans = vec![Span::styled(status.into_owned(), accent), Span::raw(" [")]; - spans.extend(render_package_bar_track( - appearance, - bar_width, - completed, - state.total, - animation_frame, - matches!(state.phase, PackageProgressPhase::Finished(_)), - )); - spans.push(Span::raw("] ")); - spans.push(Span::styled(count, accent)); - Line::from(spans) -} - -fn render_package_preparation( - width: usize, - appearance: PackageAppearance, - animation_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 accent = package_accent_style(appearance); - let mut spans = vec![Span::styled(status, accent), Span::raw(" [")]; - spans.extend(render_package_activity_bar(appearance, bar_width, animation_frame)); - spans.push(Span::raw("]")); - Line::from(spans) -} - -fn render_package_activity_bar( - appearance: PackageAppearance, - width: usize, - animation_frame: usize, -) -> Vec> { - const PULSE_WIDTH: usize = 5; - if matches!(appearance, PackageAppearance::Plain) { - let mut bar = vec!['-'; width]; - if width > 0 { - bar[animation_frame % width] = '>'; - } - return vec![Span::raw(bar.into_iter().collect::())]; - } - - let spans = (0..width).map(|index| { - let cycle = width + PULSE_WIDTH; - let highlight = animation_frame % cycle; - let distance = index + PULSE_WIDTH; - let distance = distance.abs_diff(highlight).min(cycle - distance.abs_diff(highlight)); - let character = if distance < PULSE_WIDTH { "⣿" } else { "⠀" }; - Span::styled( - character, - package_bar_style(appearance, index, width, width, animation_frame, false), - ) - }); - spans.collect_vec() -} - -fn render_package_bar_track( - appearance: PackageAppearance, - width: usize, - completed: usize, - total: usize, - animation_frame: usize, - finished: bool, -) -> Vec> { - let filled_eighths = if total == 0 { width * 8 } else { width * 8 * completed / total }; - if matches!(appearance, PackageAppearance::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 cell_eighths = filled_eighths.saturating_sub(index * 8).min(8); - Span::styled( - BRAILLE_FILL[cell_eighths], - package_bar_style(appearance, index, width, filled, animation_frame, finished), - ) - }); - spans.collect_vec() -} - -fn package_bar_style( - appearance: PackageAppearance, - index: usize, - width: usize, - filled: usize, - animation_frame: usize, - finished: bool, -) -> RatatuiStyle { - let style = RatatuiStyle::default(); - match appearance { - PackageAppearance::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 highlight = animation_frame % filled; - let distance = index.abs_diff(highlight); - let brightness = match distance { - 0 => 0.35, - 1 => 0.2, - 2 => 0.08, - _ => 0.0, - }; - let gold = (248, 216, 120); - let tint = |channel: u8, gold: u8| { - (channel as f32 + (gold as f32 - channel as f32) * brightness).round() as u8 - }; - color = (tint(color.0, gold.0), tint(color.1, gold.1), tint(color.2, gold.2)); - } - style.fg(RatatuiColor::Rgb(color.0, color.1, color.2)) - } - PackageAppearance::TrueColor { foreground, .. } if index == filled => style - .fg(RatatuiColor::Rgb(foreground.0, foreground.1, foreground.2)) - .add_modifier(Modifier::BOLD), - PackageAppearance::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(RatatuiColor::Rgb( - blend(foreground.0, background.0), - blend(foreground.1, background.1), - blend(foreground.2, background.2), - )) - } - PackageAppearance::Ansi if index < filled => { - let color = if index < filled / 3 { - RatatuiColor::Blue - } else if index < filled * 2 / 3 { - RatatuiColor::Cyan - } else { - RatatuiColor::Green - }; - let style = style.fg(color); - if !finished && index == animation_frame % filled.max(1) { - style.add_modifier(Modifier::BOLD) - } else { - style - } - } - PackageAppearance::Ansi if index == filled => { - style.fg(RatatuiColor::White).add_modifier(Modifier::BOLD) - } - PackageAppearance::Ansi => style.fg(RatatuiColor::DarkGray), - PackageAppearance::Plain => unreachable!("plain package bars are rendered without styles"), - } -} - -fn package_history_style(appearance: PackageAppearance, row: usize) -> RatatuiStyle { - let style = RatatuiStyle::default(); - match appearance { - PackageAppearance::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.fg(RatatuiColor::Rgb( - blend(foreground.0, background.0), - blend(foreground.1, background.1), - blend(foreground.2, background.2), - )) - } - PackageAppearance::Ansi if row + 1 == PACKAGE_HISTORY_LENGTH => { - style.fg(RatatuiColor::Green).add_modifier(Modifier::BOLD) - } - PackageAppearance::Ansi if row * 2 < PACKAGE_HISTORY_LENGTH => { - style.add_modifier(Modifier::DIM) - } - PackageAppearance::Ansi | PackageAppearance::Plain => style, - } -} - -fn package_accent_style(appearance: PackageAppearance) -> RatatuiStyle { - match appearance { - PackageAppearance::Plain => RatatuiStyle::default(), - PackageAppearance::TrueColor { .. } | PackageAppearance::Ansi => { - RatatuiStyle::default().fg(RatatuiColor::Cyan) - } - } -} - -fn sanitize(name: &str) -> String { - let characters = - name.chars().map(|character| if character.is_control() { '�' } else { character }); - characters.collect() -} - -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)) -} - -#[cfg(test)] -mod tests { - use super::*; - use itertools::Itertools; - - fn package_state(names: &[&str], total: usize) -> PackageProgressState { - let completed = names.iter().map(|name| CompletedPackage { - name: (*name).to_owned(), - elapsed: Duration::from_millis(125), - }); - let completed = completed.collect(); - PackageProgressState { - total, - completed_count: names.len(), - completed, - phase: PackageProgressPhase::Compiling, - } - } - - fn render_state( - state: &PackageProgressState, - width: u16, - appearance: PackageAppearance, - ) -> Buffer { - render_state_at_height(state, width, PACKAGE_REGION_HEIGHT, appearance) - } - - fn render_state_at_height( - state: &PackageProgressState, - width: u16, - height: u16, - appearance: PackageAppearance, - ) -> Buffer { - let area = Rect::new(0, 0, width, height); - let mut buffer = Buffer::empty(area); - PackageView { state, appearance, animation_frame: 0 }.render(area, &mut buffer); - buffer - } - - fn row_text(buffer: &Buffer, row: u16) -> String { - let characters = (0..buffer.area.width).map(|column| { - buffer - .cell((column, row)) - .expect("invariant violated: rendered package row has no cell") - .symbol() - }); - characters.collect() - } - - #[test] - fn package_frame_has_ten_history_rows_and_two_status_rows() { - let names = ["one", "two", "three"]; - let state = package_state(&names, 12); - - let buffer = render_state(&state, 60, PackageAppearance::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).contains("3/12")); - } - - #[test] - fn package_frame_transitions_from_preparation_to_compilation() { - let progress = packages(false, false); - let state = progress.state.lock().unwrap(); - let preparing = render_state(&state, 60, PackageAppearance::Plain); - drop(state); - - progress.begin_compilation(12); - let state = progress.state.lock().unwrap(); - let compiling = render_state(&state, 60, PackageAppearance::Plain); - - assert!(row_text(&preparing, 0).starts_with("Preparing [")); - assert!(!row_text(&preparing, 0).contains("0/0")); - assert!((1..PACKAGE_REGION_HEIGHT).all(|row| row_text(&preparing, row).trim().is_empty())); - assert!(row_text(&compiling, 11).starts_with("Compiling [")); - assert!(row_text(&compiling, 11).ends_with("0/12")); - } - - #[test] - fn package_frame_caps_width_and_aligns_timing_digits() { - let state = PackageProgressState { - total: 2, - completed_count: 2, - completed: VecDeque::from([ - CompletedPackage { - name: "short".to_owned(), - elapsed: Duration::from_micros(9_500), - }, - CompletedPackage { - name: "a-much-longer-package-name".to_owned(), - elapsed: Duration::from_micros(123_450), - }, - ]), - phase: PackageProgressPhase::Compiling, - }; - - let buffer = render_state(&state, PACKAGE_REGION_WIDTH as u16, PackageAppearance::Plain); - let short = row_text(&buffer, 8); - let long = row_text(&buffer, 9); - - assert_eq!(measure_text_width(&short), PACKAGE_REGION_WIDTH); - assert_eq!(measure_text_width(&long), PACKAGE_REGION_WIDTH); - assert_eq!(short.find('.'), long.find('.')); - assert!(short.ends_with("9.50 ms")); - assert!(long.ends_with("123.45 ms")); - } - - #[test] - fn package_frame_keeps_only_the_latest_ten_completions() { - let names = (1..=12).map(|number| format!("package-{number}")).collect_vec(); - let progress = packages(false, false); - progress.begin_compilation(names.len()); - for name in &names { - progress.complete(name, Duration::from_millis(125)); - } - let state = progress.state.lock().unwrap(); - - let buffer = render_state(&state, 60, PackageAppearance::Plain); - let frame = (0..PACKAGE_HISTORY_LENGTH as u16).map(|row| row_text(&buffer, row)).join("\n"); - - assert!(!frame.contains("package-1 ")); - assert!(!frame.contains("package-2 ")); - assert!(frame.contains("package-3 ")); - assert!(frame.contains("package-12 ")); - } - - #[test] - fn package_frame_preserves_progress_status() { - let state = package_state(&["completed"], 12); - - let buffer = render_state(&state, 60, PackageAppearance::Plain); - let progress = row_text(&buffer, 11); - - assert!(progress.contains("1/12")); - assert!(progress.contains('[')); - assert!(progress.contains(']')); - assert_eq!(measure_text_width(&progress), 60); - } - - #[test] - fn package_bar_does_not_exceed_extremely_narrow_terminals() { - let state = package_state(&[], 12); - - for width in 1..=8 { - let buffer = render_state(&state, width, PackageAppearance::Plain); - assert_eq!(measure_text_width(&row_text(&buffer, 11)), width as usize); - } - } - - #[test] - fn short_terminal_keeps_status_visible() { - let state = package_state(&["completed"], 12); - - let buffer = render_state_at_height(&state, 40, 4, PackageAppearance::Plain); - - assert!(row_text(&buffer, 3).contains("1/12")); - assert!(!row_text(&buffer, 3).contains("completed")); - } - - #[test] - fn package_frame_sanitizes_and_truncates_names_without_wrapping() { - let state = package_state(&["a\nvery-long-package-name"], 1); - - let buffer = render_state(&state, 18, PackageAppearance::Plain); - - let package = row_text(&buffer, 9); - assert!(package.contains('�')); - assert!(package.contains('…')); - assert!(package.ends_with("125.00 ms")); - } - - #[test] - fn package_history_uses_fixed_row_opacity_and_shifts_on_completion() { - let appearance = - PackageAppearance::TrueColor { foreground: (240, 220, 200), background: (20, 30, 40) }; - let mut state = package_state(&["first"], 2); - - let first_frame = render_state(&state, 60, appearance); - let first_style = first_frame.cell((0, 9)).unwrap().style(); - - state.completed.push_back(CompletedPackage { - name: "second".to_owned(), - elapsed: Duration::from_millis(125), - }); - state.completed_count += 1; - let second_frame = render_state(&state, 60, appearance); - let shifted_style = second_frame.cell((0, 8)).unwrap().style(); - let newest_style = second_frame.cell((0, 9)).unwrap().style(); - - assert_eq!(first_style.fg, Some(RatatuiColor::Rgb(240, 220, 200))); - assert_eq!(shifted_style.fg, Some(RatatuiColor::Rgb(220, 203, 185))); - assert_eq!(newest_style, first_style); - } - - #[test] - fn true_color_history_fades_from_oldest_to_newest_row() { - let appearance = - PackageAppearance::TrueColor { foreground: (240, 220, 200), background: (20, 30, 40) }; - - assert_eq!(package_history_style(appearance, 0).fg, Some(RatatuiColor::Rgb(60, 64, 69))); - assert_eq!(package_history_style(appearance, 9).fg, Some(RatatuiColor::Rgb(240, 220, 200))); - } - - #[test] - fn true_color_package_bar_uses_iris_costume_gradient() { - let appearance = - PackageAppearance::TrueColor { foreground: (240, 240, 240), background: (20, 20, 20) }; - - let bar = render_package_bar_track(appearance, 7, 7, 7, 0, true); - - assert_eq!(bar.first().unwrap().style.fg, Some(RatatuiColor::Rgb(240, 128, 136))); - assert_eq!(bar[2].style.fg, Some(RatatuiColor::Rgb(248, 152, 184))); - assert_eq!(bar[4].style.fg, Some(RatatuiColor::Rgb(184, 136, 224))); - assert_eq!(bar.last().unwrap().style.fg, Some(RatatuiColor::Rgb(136, 72, 192))); - } - - #[test] - fn colored_package_bar_uses_braille_subcells() { - let bar = render_package_bar_track(PackageAppearance::Ansi, 2, 1, 3, 0, false); - - assert_eq!(bar[0].content, "⣇"); - assert_eq!(bar[1].content, "⠀"); - } - - #[test] - fn true_color_package_bar_animates_a_highlight() { - let appearance = - PackageAppearance::TrueColor { foreground: (240, 240, 240), background: (20, 20, 20) }; - let first = render_package_bar_track(appearance, 6, 3, 6, 0, false); - let second = render_package_bar_track(appearance, 6, 3, 6, 1, false); - - assert_ne!(first[0].style.fg, second[0].style.fg); - assert_ne!(first[1].style.fg, second[1].style.fg); - } -} diff --git a/compiler-bin/src/project.rs b/compiler-bin/src/project.rs deleted file mode 100644 index dd87de66e..000000000 --- a/compiler-bin/src/project.rs +++ /dev/null @@ -1,347 +0,0 @@ -use std::path::{Path, PathBuf}; -use std::process::{self, Command, ExitStatus}; -use std::{env, fs, io}; - -use itertools::Itertools; -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)] - SpagoLock(#[from] spago::LockfileGlobSetError), - #[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 diagnostics: bool, -} - -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 { - if !matches!(&error, ProjectError::Compile(CompileError::Diagnostics { reported: false })) { - 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)?; - let packages = spago::source_files_by_package(&workspace.root)?; - let packages = packages.into_iter().map(|(name, package)| compile::PackageInput { - name: name.to_string(), - sources: package.sources, - dependencies: package.dependencies.into_iter().map(|name| name.to_string()).collect_vec(), - }); - let packages = packages.collect_vec(); - compile::compile_package_inputs( - &workspace.root, - &output, - &sources, - packages, - config.quiet, - config.color, - config.diagnostics, - 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(), !config.quiet)?; - let sources = spago.source_globs(workspace.selected.as_deref(), !config.quiet)?; - 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/walk.rs b/compiler-bin/src/walk.rs deleted file mode 100644 index 687f5bad9..000000000 --- a/compiler-bin/src/walk.rs +++ /dev/null @@ -1,216 +0,0 @@ -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(root: &Path, paths: impl IntoIterator>) -> Result { - walk_filtered(root, paths, std::iter::empty::<&Path>()) -} - -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; - } - - for entry in WalkDir::new(root) { - let path = entry?.into_path(); - if globs.is_match(&path) && !excludes.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 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 mut files = files - .into_iter() - .map(|file| file.strip_prefix(root).unwrap().to_string_lossy().replace('\\', "/")) - .collect::>(); - 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 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/watch.rs b/compiler-bin/src/watch.rs deleted file mode 100644 index 0020a0d04..000000000 --- a/compiler-bin/src/watch.rs +++ /dev/null @@ -1,570 +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, - diagnostics: true, - 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-bin/src/workspace.rs b/compiler-bin/src/workspace.rs deleted file mode 100644 index c39942a90..000000000 --- a/compiler-bin/src/workspace.rs +++ /dev/null @@ -1,372 +0,0 @@ -use std::collections::BTreeMap; -use std::path::{Path, PathBuf}; -use std::{fs, io}; - -use ignore::WalkBuilder; -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(Debug, Clone, Deserialize)] -pub struct Manifest { - pub workspace: Option, - pub package: Option, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PackageManifest { - pub name: String, - pub run: Option, - pub test: Option, -} - -#[derive(Debug, Clone, Default, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ExecutionConfig { - pub main: Option, - #[serde(default)] - pub exec_args: Vec, -} - -#[derive(Debug, Clone)] -pub struct WorkspacePackage { - pub root: PathBuf, - pub manifest: PackageManifest, - pub has_tests: bool, -} - -#[derive(Debug)] -pub struct Workspace { - pub root: PathBuf, - pub packages: BTreeMap, - pub 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::>().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 { root, packages, selected }) - } - - pub fn require_selected(&self) -> Result<&WorkspacePackage, WorkspaceError> { - let Some(name) = &self.selected else { - return Err(WorkspaceError::AmbiguousPackage); - }; - Ok(&self.packages[name]) - } -} - -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(|entry| { - 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())) - }); - let mut manifests = manifests.collect::, _>>()?; - manifests.sort_by_key(|path| path.components().count()); - - let mut nested_workspaces = Vec::new(); - let mut packages = BTreeMap::new(); - for path in manifests { - let package_root = path.parent().expect("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.clone(); - 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) { - return Err(WorkspaceError::DuplicatePackage { - name, - first: previous.root, - second: package_root.to_path_buf(), - }); - } - } - Ok(packages) -} - -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 }) -} - -#[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/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 fe236482e..41571d265 100644 --- a/tests-e2e/Cargo.toml +++ b/tests-e2e/Cargo.toml @@ -6,8 +6,7 @@ publish = false autotests = false [dependencies] -purescript-iris = { path = "../compiler-bin" } -iris-cli = { path = "../compiler-bin-v2/iris-cli" } +iris-cli = { path = "../compiler-bin/iris-cli" } [dev-dependencies] async-lsp = { version = "0.2.4", features = ["tokio"] } @@ -25,10 +24,6 @@ url = "2.5.8" name = "iris-e2e" path = "src/bin/iris-e2e.rs" -[[bin]] -name = "iris-v2-e2e" -path = "src/bin/iris-v2-e2e.rs" - [[bin]] name = "spago-e2e" path = "src/bin/spago-e2e.rs" 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/src/bin/iris-v2-e2e.rs b/tests-e2e/src/bin/iris-v2-e2e.rs deleted file mode 100644 index 4662be38d..000000000 --- a/tests-e2e/src/bin/iris-v2-e2e.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - std::process::exit(iris_cli::run()); -} diff --git a/tests-e2e/tests/cli.rs b/tests-e2e/tests/cli.rs index 0f21e3317..e206856d4 100644 --- a/tests-e2e/tests/cli.rs +++ b/tests-e2e/tests/cli.rs @@ -27,40 +27,17 @@ 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,267 +49,35 @@ fn prints_version_to_stdout() { } #[test] -fn invalid_option_values_point_to_the_supplied_argument() { +fn rejects_unpromoted_commands() { 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); + for command in ["compile", "run", "test", "docs"] { + let output = workspace.command(&[command]); + assert_eq!(output.status.code(), Some(2), "{command} was accepted"); + assert!(output.stdout.is_empty(), "{command} wrote stdout"); } } #[test] -fn rejects_unknown_flags_and_duplicate_scalar_options() { +fn add_requires_dependencies() { 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); - } + 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 rejects_missing_required_arguments() { +fn rejects_invalid_lsp_configuration_before_starting() { 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"]), + ("config_invalid", &["lsp", "--config", "{"]), + ("config_conflict", &["lsp", "--config", "{}", "--config-file", "missing.json"]), + ("config_file_missing", &["lsp", "--config-file", "missing.json"]), ]; - 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() { - 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); - } -} - -#[test] -fn run_and_test_require_separator_before_trailing_arguments() { - let workspace = TestWorkspace::empty(); - for (name, arguments) in [ - ("run_requires_separator", &["run", "argument"][..]), - ("test_requires_separator", &["test", "argument"][..]), - ] { - let output = workspace.command(arguments); - assert!(!output.status.success(), "{name} unexpectedly succeeded"); - snapshot_output(name, &output); - } -} - -#[test] -fn lsp_configuration_options_reject_invalid_arguments() { - 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); - } -} - -#[test] -fn lsp_rejects_invalid_json_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}}"#, - ), - ]; - - 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()); - 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); - }); - } -} - -#[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()); -} - -#[test] -fn v2_exposes_ported_commands_only() { - let workspace = TestWorkspace::empty(); - for (name, arguments) in [ - ("v2_help_root", &["--help"][..]), - ("v2_help_new", &["new", "--help"][..]), - ("v2_help_add", &["add", "--help"][..]), - ("v2_help_build", &["build", "--help"][..]), - ("v2_help_watch", &["watch", "--help"][..]), - ("v2_help_lsp", &["lsp", "--help"][..]), - ] { - let output = workspace.v2_command(arguments); - assert!(output.status.success(), "{name}"); - assert!(!output.stdout.is_empty(), "{name} did not write stdout"); - assert!(output.stderr.is_empty(), "{name} wrote stderr"); - snapshot_output(name, &output); - } - - for command in ["compile", "docs"] { - let output = workspace.v2_command(&[command]); - assert_eq!(output.status.code(), Some(2), "{command}"); - assert!(output.stdout.is_empty(), "{command} wrote stdout"); - } - - let output = workspace.v2_command(&["add"]); - assert_eq!(output.status.code(), Some(2)); - assert!(output.stdout.is_empty()); - snapshot_output("v2_add_requires_dependencies", &output); -} - -#[test] -fn v2_rejects_invalid_lsp_configuration_before_starting() { - let workspace = TestWorkspace::empty(); - let cases: &[(&str, &[&str])] = &[ - ("v2_config_invalid", &["lsp", "--config", "{"]), - ("v2_config_conflict", &["lsp", "--config", "{}", "--config-file", "missing.json"]), - ("v2_config_file_missing", &["lsp", "--config-file", "missing.json"]), - ]; - for (name, arguments) in cases { - let output = workspace.v2_command(arguments); - assert_eq!(output.status.code(), Some(2), "{name}"); + 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]"), diff --git a/tests-e2e/tests/lsp.rs b/tests-e2e/tests/lsp.rs index 45dadb294..2192a1759 100644 --- a/tests-e2e/tests/lsp.rs +++ b/tests-e2e/tests/lsp.rs @@ -27,7 +27,7 @@ use url::Url; #[path = "support.rs"] mod support; -use support::{IrisExecutable, TestWorkspace}; +use support::TestWorkspace; struct ClientState { configuration: Mutex>, @@ -50,14 +50,12 @@ struct LanguageServer { impl LanguageServer { fn start( workspace: &TestWorkspace, - executable: IrisExecutable, directory: &str, arguments: &[&str], root: &Path, ) -> LanguageServer { LanguageServer::start_with_capabilities( workspace, - executable, directory, arguments, root, @@ -68,7 +66,6 @@ impl LanguageServer { fn start_with_capabilities( workspace: &TestWorkspace, - executable: IrisExecutable, directory: &str, arguments: &[&str], root: &Path, @@ -132,7 +129,7 @@ impl LanguageServer { }); let runtime = Builder::new_multi_thread().worker_threads(1).enable_all().build().unwrap(); - let command = workspace.command_builder_for(executable, directory, &arguments); + let command = workspace.command_builder(directory, &arguments); let mut command = tokio::process::Command::from(command); command .stdin(Stdio::piped()) @@ -166,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()); @@ -318,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(); @@ -326,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) => {} @@ -335,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!( @@ -400,53 +397,48 @@ fn assert_diagnostic_triggers_for( #[test] fn empty_configuration_preserves_spago_and_default_diagnostics() { - for executable in IrisExecutable::ALL { - let workspace = TestWorkspace::empty(); - workspace.write( - "spago.lock", - r#"{"workspace":{"packages":{"application":{"path":"."}}},"packages":{}}"#, - ); - workspace.write("src/Library.purs", "module Library where\nfromSpago = 42\n"); - workspace.write("config/empty.json", "{}"); + let workspace = TestWorkspace::empty(); + workspace.write( + "spago.lock", + r#"{"workspace":{"packages":{"application":{"path":"."}}},"packages":{}}"#, + ); + workspace.write("src/Library.purs", "module Library where\nfromSpago = 42\n"); + workspace.write("config/empty.json", "{}"); - let cases: &[&[&str]] = &[ - &["lsp"], - &["lsp", "--stdio"], - &["lsp", "--config", "null"], - &["lsp", "--config-file", "config/empty.json"], - &[ - "lsp", - "--config", - r#"{"sources":{"kind":"spago"},"diagnostics":{"onOpen":null,"onSave":null,"onChange":null}}"#, - ], - ]; - for arguments in cases { - let mut server = - LanguageServer::start(&workspace, executable, "", arguments, workspace.path()); - let symbols = server.request("workspace/symbol", json!({"query": "fromSpago"})); - assert_eq!(symbols.as_array().unwrap().len(), 1, "{symbols}"); - assert_eq!(symbols[0]["name"], "fromSpago"); - assert_diagnostic_triggers(&mut server, workspace.path(), true, true, false); - server.shutdown(); - } + let cases: &[&[&str]] = &[ + &["lsp"], + &["lsp", "--stdio"], + &["lsp", "--config", "null"], + &["lsp", "--config-file", "config/empty.json"], + &[ + "lsp", + "--config", + r#"{"sources":{"kind":"spago"},"diagnostics":{"onOpen":null,"onSave":null,"onChange":null}}"#, + ], + ]; + for arguments in cases { + let mut server = LanguageServer::start(&workspace, "", arguments, workspace.path()); + let symbols = server.request("workspace/symbol", json!({"query": "fromSpago"})); + assert_eq!(symbols.as_array().unwrap().len(), 1, "{symbols}"); + assert_eq!(symbols[0]["name"], "fromSpago"); + assert_diagnostic_triggers(&mut server, workspace.path(), true, true, false); + server.shutdown(); } } #[test] fn json_inputs_configure_source_commands_and_diagnostic_triggers() { - for executable in IrisExecutable::ALL { - let workspace = TestWorkspace::empty(); - workspace - .write("project/selected/Library.purs", "module Library where\nfromCommand = 42\n"); - workspace.write( - "launcher/source command.mjs", - r#" + let workspace = TestWorkspace::empty(); + workspace.write("project/selected/Library.purs", "module Library where\nfromCommand = 42\n"); + workspace.write( + "launcher/source command.mjs", + r#" import { writeFileSync } from "node:fs"; writeFileSync("arguments.json", JSON.stringify(process.argv.slice(2))); console.log("selected/*.purs"); "#, - ); - let configuration = json!({ + ); + let configuration = json!({ "sources": { "kind": "command", "program": "node", @@ -454,193 +446,175 @@ console.log("selected/*.purs"); }, "diagnostics": {"onOpen": false, "onSave": false, "onChange": true} }).to_string(); - let absolute_path = workspace.path().join("settings/server config.json"); - let root = workspace.path().join("project"); - let cases: &[&[&str]] = &[ - &["lsp", "--config", &configuration], - &["lsp", "--config-file", "../settings/server config.json"], - &["lsp", "--config-file", absolute_path.to_str().unwrap()], - ]; - for arguments in cases { - workspace.write("settings/server config.json", &configuration); - let mut server = - LanguageServer::start(&workspace, executable, "launcher", arguments, &root); - workspace.write("settings/server config.json", "invalid after startup"); - let symbols = server.request("workspace/symbol", json!({"query": "fromCommand"})); - assert_eq!(symbols.as_array().unwrap().len(), 1, "{symbols}"); - assert_eq!(symbols[0]["name"], "fromCommand"); - let expected_uri = Url::from_file_path(root.join("selected/Library.purs")).unwrap(); - assert_eq!(symbols[0]["location"]["uri"], expected_uri.as_str()); - let arguments: Value = - serde_json::from_str(&workspace.read("launcher/arguments.json")).unwrap(); - assert_eq!(arguments, json!(["", "path with spaces", "--flag", "λ", "$(not-a-shell)"])); - assert_diagnostic_triggers(&mut server, &root, false, false, true); - server.shutdown(); - } + let absolute_path = workspace.path().join("settings/server config.json"); + let root = workspace.path().join("project"); + let cases: &[&[&str]] = &[ + &["lsp", "--config", &configuration], + &["lsp", "--config-file", "../settings/server config.json"], + &["lsp", "--config-file", absolute_path.to_str().unwrap()], + ]; + for arguments in cases { + workspace.write("settings/server config.json", &configuration); + let mut server = LanguageServer::start(&workspace, "launcher", arguments, &root); + workspace.write("settings/server config.json", "invalid after startup"); + let symbols = server.request("workspace/symbol", json!({"query": "fromCommand"})); + assert_eq!(symbols.as_array().unwrap().len(), 1, "{symbols}"); + assert_eq!(symbols[0]["name"], "fromCommand"); + let expected_uri = Url::from_file_path(root.join("selected/Library.purs")).unwrap(); + assert_eq!(symbols[0]["location"]["uri"], expected_uri.as_str()); + let arguments: Value = + serde_json::from_str(&workspace.read("launcher/arguments.json")).unwrap(); + assert_eq!(arguments, json!(["", "path with spaces", "--flag", "λ", "$(not-a-shell)"])); + assert_diagnostic_triggers(&mut server, &root, false, false, true); + server.shutdown(); } } #[test] fn partial_diagnostic_configuration_preserves_omitted_triggers() { - for executable in IrisExecutable::ALL { - let workspace = TestWorkspace::empty(); - workspace.write("spago.lock", r#"{"workspace":{"packages":{}},"packages":{}}"#); - let mut server = LanguageServer::start( - &workspace, - executable, - "", - &["lsp", "--config", r#"{"diagnostics":{"onOpen":false}}"#], - workspace.path(), - ); - assert_diagnostic_triggers(&mut server, workspace.path(), false, true, false); - server.shutdown(); - } + let workspace = TestWorkspace::empty(); + workspace.write("spago.lock", r#"{"workspace":{"packages":{}},"packages":{}}"#); + let mut server = LanguageServer::start( + &workspace, + "", + &["lsp", "--config", r#"{"diagnostics":{"onOpen":false}}"#], + workspace.path(), + ); + assert_diagnostic_triggers(&mut server, workspace.path(), false, true, false); + server.shutdown(); } #[test] fn workspace_configuration_applies_initial_and_runtime_snapshots() { - for executable in IrisExecutable::ALL { - let workspace = TestWorkspace::empty(); - workspace.write("project/startup/Library.purs", "module Library where\nfromStartup = 1\n"); - workspace.write("project/runtime/Library.purs", "module Library where\nfromRuntime = 2\n"); - workspace.write("launcher/startup.mjs", "console.log('../project/startup/*.purs');\n"); - workspace.write("launcher/runtime.mjs", "console.log('../project/runtime/*.purs');\n"); - let startup = - r#"{"sources":{"kind":"command","program":"node","arguments":["startup.mjs"]}}"#; - let runtime = json!({ - "sources": {"kind": "command", "program": "node", "arguments": ["runtime.mjs"]}, - "diagnostics": {"onOpen": false, "onSave": false, "onChange": true} - }); - let root = workspace.path().join("project"); - let mut server = LanguageServer::start_with_capabilities( - &workspace, - executable, - "launcher", - &["lsp", "--config", startup], - &root, - json!({"workspace": {"configuration": true}}), - Some(runtime), - ); + let workspace = TestWorkspace::empty(); + workspace.write("project/startup/Library.purs", "module Library where\nfromStartup = 1\n"); + workspace.write("project/runtime/Library.purs", "module Library where\nfromRuntime = 2\n"); + workspace.write("launcher/startup.mjs", "console.log('../project/startup/*.purs');\n"); + workspace.write("launcher/runtime.mjs", "console.log('../project/runtime/*.purs');\n"); + let startup = r#"{"sources":{"kind":"command","program":"node","arguments":["startup.mjs"]}}"#; + let runtime = json!({ + "sources": {"kind": "command", "program": "node", "arguments": ["runtime.mjs"]}, + "diagnostics": {"onOpen": false, "onSave": false, "onChange": true} + }); + let root = workspace.path().join("project"); + let mut server = LanguageServer::start_with_capabilities( + &workspace, + "launcher", + &["lsp", "--config", startup], + &root, + json!({"workspace": {"configuration": true}}), + Some(runtime), + ); - server.wait_for_symbol("fromRuntime", true); - server.wait_for_symbol("fromStartup", false); - assert_diagnostic_triggers(&mut server, &root, false, false, true); - let runtime_uri = Url::from_file_path(root.join("runtime/Library.purs")).unwrap(); - server.notify( - "textDocument/didOpen", - json!({ - "textDocument": { - "uri": runtime_uri, - "languageId": "purescript", - "version": 1, - "text": "module Library where\nunsavedRuntime = 3\n" - } - }), - ); - server.wait_for_symbol("unsavedRuntime", true); + server.wait_for_symbol("fromRuntime", true); + server.wait_for_symbol("fromStartup", false); + assert_diagnostic_triggers(&mut server, &root, false, false, true); + let runtime_uri = Url::from_file_path(root.join("runtime/Library.purs")).unwrap(); + server.notify( + "textDocument/didOpen", + json!({ + "textDocument": { + "uri": runtime_uri, + "languageId": "purescript", + "version": 1, + "text": "module Library where\nunsavedRuntime = 3\n" + } + }), + ); + server.wait_for_symbol("unsavedRuntime", true); - server.set_configuration(json!({"diagnostics": {"onOpen": true}})); - server.wait_for_symbol("fromStartup", true); - server.wait_for_symbol("fromRuntime", false); - server.wait_for_symbol("unsavedRuntime", true); - server.notify("textDocument/didClose", json!({"textDocument": {"uri": runtime_uri}})); - server.wait_for_symbol("unsavedRuntime", false); - assert_diagnostic_triggers_for(&mut server, &root, "AfterUpdate.purs", true, true, false); - assert!(server.client.configuration_requests.load(Ordering::Relaxed) >= 2); - server.shutdown(); - } + server.set_configuration(json!({"diagnostics": {"onOpen": true}})); + server.wait_for_symbol("fromStartup", true); + server.wait_for_symbol("fromRuntime", false); + server.wait_for_symbol("unsavedRuntime", true); + server.notify("textDocument/didClose", json!({"textDocument": {"uri": runtime_uri}})); + server.wait_for_symbol("unsavedRuntime", false); + assert_diagnostic_triggers_for(&mut server, &root, "AfterUpdate.purs", true, true, false); + assert!(server.client.configuration_requests.load(Ordering::Relaxed) >= 2); + server.shutdown(); } #[test] fn invalid_runtime_configuration_preserves_the_previous_workspace() { - for executable in IrisExecutable::ALL { - let workspace = TestWorkspace::empty(); - workspace.write( - "spago.lock", - r#"{"workspace":{"packages":{"application":{"path":"."}}},"packages":{}}"#, - ); - workspace.write("src/Library.purs", "module Library where\nstillLoaded = 42\n"); - workspace.write( - "slow failure.mjs", - r#" + let workspace = TestWorkspace::empty(); + workspace.write( + "spago.lock", + r#"{"workspace":{"packages":{"application":{"path":"."}}},"packages":{}}"#, + ); + workspace.write("src/Library.purs", "module Library where\nstillLoaded = 42\n"); + workspace.write( + "slow failure.mjs", + r#" setTimeout(() => { process.stderr.write("slow failure\n"); process.exit(1); }, 1000); "#, - ); - let mut server = LanguageServer::start_with_capabilities( - &workspace, - executable, - "", - &["lsp"], - workspace.path(), - json!({ - "workspace": { - "configuration": true, - "didChangeConfiguration": {"dynamicRegistration": true} - } - }), - Some(json!({})), - ); - server.wait_for_symbol("stillLoaded", true); + ); + let mut server = LanguageServer::start_with_capabilities( + &workspace, + "", + &["lsp"], + workspace.path(), + json!({ + "workspace": { + "configuration": true, + "didChangeConfiguration": {"dynamicRegistration": true} + } + }), + Some(json!({})), + ); + server.wait_for_symbol("stillLoaded", true); - server.set_configuration(json!({"diagnostics": {"onOpen": "invalid"}})); - let message = server.wait_for_notification("window/showMessage"); - assert_eq!(message["params"]["type"], 1); - assert!( - message["params"]["message"] - .as_str() - .unwrap() - .contains("previous Iris settings remain active") - ); - server.wait_for_symbol("stillLoaded", true); + server.set_configuration(json!({"diagnostics": {"onOpen": "invalid"}})); + let message = server.wait_for_notification("window/showMessage"); + assert_eq!(message["params"]["type"], 1); + assert!( + message["params"]["message"] + .as_str() + .unwrap() + .contains("previous Iris settings remain active") + ); + server.wait_for_symbol("stillLoaded", true); - server.set_configuration(json!({ - "sources": { - "kind": "command", - "program": "node", - "arguments": ["slow failure.mjs"] - } - })); - let message = server.wait_for_notification("window/showMessage"); - assert!( - message["params"]["message"] - .as_str() - .unwrap() - .contains("Failed to apply Iris settings") - ); - server.wait_for_symbol("stillLoaded", true); - assert!( - server - .client - .registrations - .lock() - .unwrap() - .iter() - .any(|registration| registration.method == "workspace/didChangeConfiguration") - ); - server.shutdown(); - } + server.set_configuration(json!({ + "sources": { + "kind": "command", + "program": "node", + "arguments": ["slow failure.mjs"] + } + })); + let message = server.wait_for_notification("window/showMessage"); + assert!( + message["params"]["message"].as_str().unwrap().contains("Failed to apply Iris settings") + ); + server.wait_for_symbol("stillLoaded", true); + assert!( + server + .client + .registrations + .lock() + .unwrap() + .iter() + .any(|registration| registration.method == "workspace/didChangeConfiguration") + ); + server.shutdown(); } #[test] fn clients_without_workspace_configuration_keep_startup_settings() { - for executable in IrisExecutable::ALL { - let workspace = TestWorkspace::empty(); - workspace.write( - "spago.lock", - r#"{"workspace":{"packages":{"application":{"path":"."}}},"packages":{}}"#, - ); - workspace.write("src/Library.purs", "module Library where\nstartupOnly = 42\n"); - let mut server = - LanguageServer::start(&workspace, executable, "", &["lsp"], workspace.path()); - server.notify( - "workspace/didChangeConfiguration", - json!({"settings": {"sources": {"kind": "command", "program": "missing"}}}), - ); - server.wait_for_symbol("startupOnly", true); - assert_eq!(server.client.configuration_requests.load(Ordering::Relaxed), 0); - server.shutdown(); - } + let workspace = TestWorkspace::empty(); + workspace.write( + "spago.lock", + r#"{"workspace":{"packages":{"application":{"path":"."}}},"packages":{}}"#, + ); + workspace.write("src/Library.purs", "module Library where\nstartupOnly = 42\n"); + let mut server = LanguageServer::start(&workspace, "", &["lsp"], workspace.path()); + server.notify( + "workspace/didChangeConfiguration", + json!({"settings": {"sources": {"kind": "command", "program": "missing"}}}), + ); + server.wait_for_symbol("startupOnly", true); + assert_eq!(server.client.configuration_requests.load(Ordering::Relaxed), 0); + server.shutdown(); } diff --git a/tests-e2e/tests/package_manager.rs b/tests-e2e/tests/package_manager.rs index 4405d9ce8..6df78b83d 100644 --- a/tests-e2e/tests/package_manager.rs +++ b/tests-e2e/tests/package_manager.rs @@ -2,15 +2,9 @@ mod add; #[path = "package_manager/build.rs"] mod build; -#[path = "package_manager/build_v2.rs"] -mod build_v2; #[path = "package_manager/new.rs"] mod new; -#[path = "package_manager/run.rs"] -mod run; #[path = "support.rs"] mod support; -#[path = "package_manager/test.rs"] -mod test; #[path = "package_manager/watch.rs"] mod watch; diff --git a/tests-e2e/tests/package_manager/add.rs b/tests-e2e/tests/package_manager/add.rs index 60432f4d4..afff95a18 100644 --- a/tests-e2e/tests/package_manager/add.rs +++ b/tests-e2e/tests/package_manager/add.rs @@ -1,40 +1,34 @@ -use super::support::{IrisExecutable, TestWorkspace, assert_success}; +use super::support::{TestWorkspace, assert_success}; #[test] fn adds_a_workspace_dependency_with_real_spago() { - let mut manifests = vec![]; - for executable in IrisExecutable::ALL { - let workspace = TestWorkspace::empty(); - workspace.write( - "spago.yaml", - r#"workspace: {} + let workspace = TestWorkspace::empty(); + workspace.write( + "spago.yaml", + r#"workspace: {} "#, - ); - workspace.write( - "packages/application/spago.yaml", - r#"package: + ); + workspace.write( + "packages/application/spago.yaml", + r#"package: name: application dependencies: [] "#, - ); - workspace.write( - "packages/library/spago.yaml", - r#"package: + ); + workspace.write( + "packages/library/spago.yaml", + r#"package: name: library dependencies: [] "#, - ); + ); - let output = - workspace.command_for(executable, &["add", "--package", "application", "library"]); - assert_success(&output); - manifests.push(workspace.read("packages/application/spago.yaml")); - assert!(workspace.path().join("spago.lock").is_file()); - workspace.assert_spago_calls("", &[&["fetch", "-p", "application", "library"]]); - } - assert!(manifests.windows(2).all(|pair| pair[0] == pair[1])); + 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!(manifests[0], @r#" + insta::assert_snapshot!(workspace.read("packages/application/spago.yaml"), @r#" package: name: application dependencies: diff --git a/tests-e2e/tests/package_manager/build.rs b/tests-e2e/tests/package_manager/build.rs index 6f5d1144e..e68491523 100644 --- a/tests-e2e/tests/package_manager/build.rs +++ b/tests-e2e/tests/package_manager/build.rs @@ -14,252 +14,103 @@ fn snapshot_output(name: &str, output: &Output) { }); } -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 -} - #[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( - "", - &[&["fetch", "-p", "application"], &["sources", "--json", "-p", "application"]], - ); -} - -#[test] -fn builds_resilient_output_despite_diagnostics() { - let workspace = TestWorkspace::empty(); - workspace.write( - "spago.yaml", - r#"workspace: {} -package: - name: application - dependencies: [] -"#, - ); - workspace.write( - "src/Main.purs", - r#"module Main where - -usable = 42 - -broken = missing -"#, - ); - - let output = workspace.command(&["build", "--quiet", "--resilient"]); - let _settings = diagnostic_settings(&workspace).bind_to_scope(); - snapshot_output("resilient_source_diagnostics", &output); - - 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( - "", + "packages/application/src", &[&["fetch", "-p", "application"], &["sources", "--json", "-p", "application"]], ); } #[test] -fn suppresses_error_diagnostics_without_changing_failure_status() { +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 - -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", "--no-diagnostics"]); + let output = workspace.command_in("src", &["build", "--quiet"]); - snapshot_output("suppressed_error_diagnostics", &output); + 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 suppresses_warning_diagnostics_without_changing_success_status() { +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"); -data Choice = Present Int | Absent + let output = workspace.command(&["build", "--quiet", "--package", "ignored"]); -value (Present number) = number -"#, + 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" ); - - let reported = workspace.command(&["build", "--quiet"]); - assert_success(&reported); - let reported_stderr = String::from_utf8_lossy(&reported.stderr); - assert!(reported_stderr.contains("Warning! · [MissingPatterns]")); - - let output = workspace.command(&["build", "--quiet", "--no-diagnostics"]); - snapshot_output("suppressed_warning_diagnostics", &output); + workspace.assert_spago_calls("", &[]); } #[test] -fn gates_initializer_cycle_output_on_resilience() { +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: [] -"#, - ); - workspace.write( - "src/Main.purs", - r#"module Main where - -first :: Int -first = second - -second :: Int -second = first -"#, + "nested/spago.yaml", + "workspace: {}\npackage:\n name: nested\n dependencies: []\n", ); + workspace + .write("nested/packages/child/spago.yaml", "package:\n name: child\n dependencies: []\n"); - let strict = workspace.command(&["build", "--quiet"]); - let _settings = diagnostic_settings(&workspace).bind_to_scope(); - snapshot_output("strict_initializer_cycle_diagnostics", &strict); - assert!(!workspace.path().join("output/Main/index.js").exists()); + let output = workspace.command(&["build", "--quiet", "--package", "child"]); - let resilient = workspace.command(&["build", "--quiet", "--resilient"]); - snapshot_output("resilient_initializer_cycle_diagnostics", &resilient); - 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 'child' was not found; available packages: application\n" ); + workspace.assert_spago_calls("", &[]); } #[test] -fn reports_backend_failures_as_diagnostics() { +fn suppresses_diagnostics_without_changing_failure_status() { let workspace = TestWorkspace::empty(); - workspace.write( - "spago.yaml", - r#"workspace: {} -package: - name: application - dependencies: [] -"#, - ); - workspace.write( - "src/Main.purs", - r#"module Main where - -import Iris.StyleX (Props, Style, props) - -partialProps :: Style -> Props -partialProps = props -"#, - ); - - let output = workspace.command(&["build", "--quiet"]); - let _settings = diagnostic_settings(&workspace).bind_to_scope(); - snapshot_output("backend_failure_diagnostics", &output); - assert!(!workspace.path().join("output/Main/index.js").exists()); - workspace.assert_spago_calls( - "", - &[&["fetch", "-p", "application"], &["sources", "--json", "-p", "application"]], - ); -} + workspace + .write("spago.yaml", "workspace: {}\npackage:\n name: application\n dependencies: []\n"); + workspace.write("src/Main.purs", "module Main where\n\nbroken = missing\n"); -#[test] -fn builds_the_whole_workspace_from_a_root_package_subdirectory() { - let workspace = TestWorkspace::empty(); - workspace.write( - "spago.yaml", - r#"workspace: {} -package: - name: application - dependencies: [library] -"#, - ); - workspace.write( - "src/Application.purs", - "module Application where\n\nimport Library (library)\n\napplication = library\n", - ); - workspace.write( - "packages/library/spago.yaml", - r#"package: - name: library - dependencies: [] -"#, - ); - workspace.write("packages/library/src/Library.purs", "module Library where\n\nlibrary = 42\n"); + let output = workspace.command(&["build", "--quiet", "--no-diagnostics"]); - 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"]]); + snapshot_output("suppressed_error_diagnostics", &output); } diff --git a/tests-e2e/tests/package_manager/build_v2.rs b/tests-e2e/tests/package_manager/build_v2.rs deleted file mode 100644 index 00e9927a8..000000000 --- a/tests-e2e/tests/package_manager/build_v2.rs +++ /dev/null @@ -1,116 +0,0 @@ -use std::process::Output; - -use super::support::{TestWorkspace, assert_success}; - -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_the_selected_workspace_package() { - let workspace = TestWorkspace::empty(); - workspace.write("spago.yaml", "workspace: {}\n"); - workspace.write( - "packages/application/spago.yaml", - "package:\n name: application\n dependencies: []\n", - ); - 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"); - - let output = workspace.v2_command_in("packages/application/src", &["build", "--quiet"]); - - assert_success(&output); - 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_the_whole_workspace_from_a_root_package_subdirectory() { - let workspace = TestWorkspace::empty(); - workspace.write( - "spago.yaml", - "workspace: {}\npackage:\n name: application\n dependencies: [library]\n", - ); - workspace.write( - "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.v2_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"]]); -} - -#[test] -fn excludes_gitignored_packages_from_selection() { - let workspace = TestWorkspace::empty(); - 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"); - - let output = workspace.v2_command(&["build", "--quiet", "--package", "ignored"]); - - 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 excludes_nested_workspaces_from_selection() { - let workspace = TestWorkspace::empty(); - workspace - .write("spago.yaml", "workspace: {}\npackage:\n name: application\n dependencies: []\n"); - workspace.write( - "nested/spago.yaml", - "workspace: {}\npackage:\n name: nested\n dependencies: []\n", - ); - workspace - .write("nested/packages/child/spago.yaml", "package:\n name: child\n dependencies: []\n"); - - let output = workspace.v2_command(&["build", "--quiet", "--package", "child"]); - - 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 suppresses_diagnostics_without_changing_failure_status() { - 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\nbroken = missing\n"); - - let output = workspace.v2_command(&["build", "--quiet", "--no-diagnostics"]); - - snapshot_output("v2_suppressed_error_diagnostics", &output); -} diff --git a/tests-e2e/tests/package_manager/new.rs b/tests-e2e/tests/package_manager/new.rs index e5a567b2d..e9cca6c6c 100644 --- a/tests-e2e/tests/package_manager/new.rs +++ b/tests-e2e/tests/package_manager/new.rs @@ -1,18 +1,13 @@ -use super::support::{IrisExecutable, TestWorkspace, assert_success}; +use super::support::{TestWorkspace, assert_success}; #[test] fn creates_a_spago_project_without_running_spago() { - let mut summaries = vec![]; - for executable in IrisExecutable::ALL { - let workspace = TestWorkspace::empty(); - let output = workspace.command_for(executable, &["new", "--name", "example"]); - assert_success(&output); - workspace.assert_spago_calls("", &[]); - summaries.push(workspace.summary()); - } - assert!(summaries.windows(2).all(|pair| pair[0] == pair[1])); + let workspace = TestWorkspace::empty(); + let output = workspace.command(&["new", "--name", "example"]); + assert_success(&output); + workspace.assert_spago_calls("", &[]); - insta::assert_snapshot!(summaries[0], @r#" + insta::assert_snapshot!(workspace.summary(), @r#" --- .gitignore .spago/ output/ @@ -56,48 +51,38 @@ fn creates_a_spago_project_without_running_spago() { #[test] fn does_not_overwrite_existing_project_files() { - for executable in IrisExecutable::ALL { - let workspace = TestWorkspace::empty(); - workspace.write("src/Main.purs", "original"); + let workspace = TestWorkspace::empty(); + workspace.write("src/Main.purs", "original"); - let output = workspace.command_for(executable, &["new", "--name", "example"]); - assert!(!output.status.success()); - assert_eq!(workspace.read("src/Main.purs"), "original"); - assert!(!workspace.path().join("spago.yaml").exists()); - } + let output = workspace.command(&["new", "--name", "example"]); + assert!(!output.status.success()); + assert_eq!(workspace.read("src/Main.purs"), "original"); + assert!(!workspace.path().join("spago.yaml").exists()); } #[test] fn does_not_create_a_partial_project_when_a_source_directory_is_a_file() { - for executable in IrisExecutable::ALL { - let workspace = TestWorkspace::empty(); - workspace.write("src", "original"); + let workspace = TestWorkspace::empty(); + workspace.write("src", "original"); - let output = workspace.command_for(executable, &["new", "--name", "example"]); - assert!(!output.status.success()); - assert_eq!(workspace.read("src"), "original"); - assert!(!workspace.path().join("spago.yaml").exists()); - } + let output = workspace.command(&["new", "--name", "example"]); + assert!(!output.status.success()); + assert_eq!(workspace.read("src"), "original"); + assert!(!workspace.path().join("spago.yaml").exists()); } #[test] fn does_not_create_a_nested_workspace() { - for executable in IrisExecutable::ALL { - let workspace = TestWorkspace::empty(); - workspace.write( - "spago.yaml", - r#"workspace: {} + let workspace = TestWorkspace::empty(); + workspace.write( + "spago.yaml", + r#"workspace: {} package: name: root "#, - ); + ); - let output = workspace.command_in_for( - executable, - "packages/application", - &["new", "--name", "application"], - ); - assert!(!output.status.success()); - assert!(!workspace.path().join("packages/application/spago.yaml").exists()); - } + let output = workspace.command_in("packages/application", &["new", "--name", "application"]); + assert!(!output.status.success()); + assert!(!workspace.path().join("packages/application/spago.yaml").exists()); } diff --git a/tests-e2e/tests/package_manager/run.rs b/tests-e2e/tests/package_manager/run.rs deleted file mode 100644 index 1c5e11ecf..000000000 --- a/tests-e2e/tests/package_manager/run.rs +++ /dev/null @@ -1,39 +0,0 @@ -use super::support::{TestWorkspace, assert_success}; - -#[test] -fn runs_the_configured_module_with_node() { - let workspace = TestWorkspace::empty(); - workspace.write( - "spago.yaml", - r#"workspace: {} -package: - name: application - dependencies: [] - run: - main: Configured -"#, - ); - workspace.write( - "src/Configured.purs", - r#"module Configured where - -data Unit = Unit -foreign import data Effect :: Type -> Type -foreign import main :: Effect Unit -"#, - ); - workspace.write( - "src/Configured.js", - "export const main = () => console.log(process.argv.slice(2).join(\",\"));\n", - ); - - let output = - workspace.command(&["run", "--output", "generated", "--quiet", "--", "first", "second"]); - assert_success(&output); - assert_eq!(String::from_utf8_lossy(&output.stdout), "first,second\n"); - assert!(workspace.path().join("generated/Configured/index.js").is_file()); - workspace.assert_spago_calls( - "", - &[&["fetch", "-p", "application"], &["sources", "--json", "-p", "application"]], - ); -} 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 c63671f61..000000000 --- a/tests-e2e/tests/package_manager/snapshots/package_manager__build__backend_failure_diagnostics.snap +++ /dev/null @@ -1,21 +0,0 @@ ---- -source: tests-e2e/tests/package_manager/build.rs ---- -status: 1 ---- stdout ---- 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 f4a8428db..000000000 --- a/tests-e2e/tests/package_manager/snapshots/package_manager__build__resilient_initializer_cycle_diagnostics.snap +++ /dev/null @@ -1,22 +0,0 @@ ---- -source: tests-e2e/tests/package_manager/build.rs ---- -status: 1 ---- stdout ---- 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 60faf7b87..000000000 --- a/tests-e2e/tests/package_manager/snapshots/package_manager__build__resilient_source_diagnostics.snap +++ /dev/null @@ -1,26 +0,0 @@ ---- -source: tests-e2e/tests/package_manager/build.rs ---- -status: 1 ---- stdout ---- 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 f4a8428db..000000000 --- a/tests-e2e/tests/package_manager/snapshots/package_manager__build__strict_initializer_cycle_diagnostics.snap +++ /dev/null @@ -1,22 +0,0 @@ ---- -source: tests-e2e/tests/package_manager/build.rs ---- -status: 1 ---- stdout ---- 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_warning_diagnostics.snap b/tests-e2e/tests/package_manager/snapshots/package_manager__build__suppressed_warning_diagnostics.snap deleted file mode 100644 index 031c32610..000000000 --- a/tests-e2e/tests/package_manager/snapshots/package_manager__build__suppressed_warning_diagnostics.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: tests-e2e/tests/package_manager/build.rs ---- -status: 0 ---- stdout ---- stderr diff --git a/tests-e2e/tests/package_manager/snapshots/package_manager__build_v2__v2_suppressed_error_diagnostics.snap b/tests-e2e/tests/package_manager/snapshots/package_manager__build_v2__v2_suppressed_error_diagnostics.snap deleted file mode 100644 index 925abec7f..000000000 --- a/tests-e2e/tests/package_manager/snapshots/package_manager__build_v2__v2_suppressed_error_diagnostics.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: tests-e2e/tests/package_manager/build_v2.rs ---- -status: 1 ---- stdout ---- stderr diff --git a/tests-e2e/tests/package_manager/snapshots/package_manager__watch__watch_single_package_v2.snap b/tests-e2e/tests/package_manager/snapshots/package_manager__watch__watch_single_package.snap similarity index 100% rename from tests-e2e/tests/package_manager/snapshots/package_manager__watch__watch_single_package_v2.snap rename to tests-e2e/tests/package_manager/snapshots/package_manager__watch__watch_single_package.snap diff --git a/tests-e2e/tests/package_manager/snapshots/package_manager__watch__watch_single_package_v1.snap b/tests-e2e/tests/package_manager/snapshots/package_manager__watch__watch_single_package_v1.snap deleted file mode 100644 index 6aff3b6c8..000000000 --- a/tests-e2e/tests/package_manager/snapshots/package_manager__watch__watch_single_package_v1.snap +++ /dev/null @@ -1,4 +0,0 @@ ---- -source: tests-e2e/tests/package_manager/watch.rs ---- -[TIME] 1 file changed: Main diff --git a/tests-e2e/tests/package_manager/snapshots/package_manager__watch__watch_workspace_v2.snap b/tests-e2e/tests/package_manager/snapshots/package_manager__watch__watch_workspace.snap similarity index 100% rename from tests-e2e/tests/package_manager/snapshots/package_manager__watch__watch_workspace_v2.snap rename to tests-e2e/tests/package_manager/snapshots/package_manager__watch__watch_workspace.snap diff --git a/tests-e2e/tests/package_manager/snapshots/package_manager__watch__watch_workspace_v1.snap b/tests-e2e/tests/package_manager/snapshots/package_manager__watch__watch_workspace_v1.snap deleted file mode 100644 index 9ca00dc31..000000000 --- a/tests-e2e/tests/package_manager/snapshots/package_manager__watch__watch_workspace_v1.snap +++ /dev/null @@ -1,4 +0,0 @@ ---- -source: tests-e2e/tests/package_manager/watch.rs ---- -[TIME] 2 files changed: Application, Library diff --git a/tests-e2e/tests/package_manager/test.rs b/tests-e2e/tests/package_manager/test.rs deleted file mode 100644 index 32892c56a..000000000 --- a/tests-e2e/tests/package_manager/test.rs +++ /dev/null @@ -1,56 +0,0 @@ -use super::support::{TestWorkspace, assert_success}; - -#[test] -fn tests_the_configured_module_with_node() { - let workspace = TestWorkspace::empty(); - workspace.write( - "spago.yaml", - r#"workspace: {} -package: - name: application - dependencies: [] - test: - main: Application.Test - dependencies: [] -"#, - ); - workspace.write( - "test/Application/Test.purs", - r#"module Application.Test where - -data Unit = Unit -foreign import data Effect :: Type -> Type -foreign import main :: Effect Unit -"#, - ); - workspace.write( - "test/Application/Test.js", - "export const main = () => console.log(\"tests ran\");\n", - ); - - let output = workspace.command(&["test", "--quiet"]); - assert_success(&output); - assert_eq!(String::from_utf8_lossy(&output.stdout), "tests ran\n"); - workspace.assert_spago_calls( - "", - &[&["fetch", "-p", "application"], &["sources", "--json", "-p", "application"]], - ); -} - -#[test] -fn rejects_a_selected_package_without_tests_before_fetching() { - let workspace = TestWorkspace::empty(); - workspace.write( - "spago.yaml", - r#"workspace: {} -package: - name: application - dependencies: [] -"#, - ); - workspace.write("src/Main.purs", "module Main where\n"); - - let output = workspace.command(&["test", "--quiet"]); - assert!(!output.status.success()); - workspace.assert_spago_calls("", &[]); -} diff --git a/tests-e2e/tests/package_manager/watch.rs b/tests-e2e/tests/package_manager/watch.rs index 95b39b6cc..83ab4485b 100644 --- a/tests-e2e/tests/package_manager/watch.rs +++ b/tests-e2e/tests/package_manager/watch.rs @@ -7,92 +7,79 @@ use std::time::{Duration, Instant}; use itertools::Itertools; -use super::support::{IrisExecutable, TestWorkspace}; +use super::support::TestWorkspace; #[test] fn watches_a_single_package_with_real_spago() { - for executable in IrisExecutable::ALL { - let workspace = TestWorkspace::empty(); - workspace.write( - "spago.yaml", - r#"workspace: {} + 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( + "src/Main.purs", + r#"module Main where value = 42 "#, - ); + ); - let mut watch = WatchProcess::new(workspace.spawn_for(executable, &["watch"])); - let output = workspace.path().join("output/Main/index.js"); - let expected = match executable { - IrisExecutable::V1 => "1 file changed: Main", - IrisExecutable::V2 => "Loaded 1 input: Main", - }; - watch.wait_for("initial compilation", |stdout, _| { - stdout.contains(expected) && output.is_file() - }); - watch.stop(); - let snapshot = format!("watch_single_package_{executable:?}").to_lowercase(); - insta::with_settings!({omit_expression => true}, { - insta::assert_snapshot!(snapshot, normalized_watch_log(&watch.stdout())); - }); + 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() + }); + watch.stop(); + insta::with_settings!({omit_expression => true}, { + insta::assert_snapshot!("watch_single_package", normalized_watch_log(&watch.stdout())); + }); - workspace.assert_spago_calls( - "", - &[&["fetch", "-p", "application"], &["sources", "--json", "-p", "application"]], - ); - } + workspace.assert_spago_calls( + "", + &[&["fetch", "-p", "application"], &["sources", "--json", "-p", "application"]], + ); } #[test] fn watches_the_whole_workspace_from_a_root_package_subdirectory() { - for executable in IrisExecutable::ALL { - let workspace = TestWorkspace::empty(); - workspace.write( - "spago.yaml", - r#"workspace: {} + 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: + ); + 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"); - - let mut watch = WatchProcess::new(workspace.spawn_in_for(executable, "src", &["watch"])); - let outputs = [ - workspace.path().join("output/Application/index.js"), - workspace.path().join("output/Library/index.js"), - ]; - let expected = match executable { - IrisExecutable::V1 => "2 files changed: Application, Library", - IrisExecutable::V2 => "Loaded 2 inputs: Application, Library", - }; - watch.wait_for("initial workspace compilation", |stdout, _| { - stdout.contains(expected) && outputs.iter().all(|output| output.is_file()) - }); - watch.stop(); - let snapshot = format!("watch_workspace_{executable:?}").to_lowercase(); - insta::with_settings!({omit_expression => true}, { - insta::assert_snapshot!(snapshot, normalized_watch_log(&watch.stdout())); - }); + ); + workspace.write("packages/library/src/Library.purs", "module Library where\n"); + + 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"), + ]; + 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"]]); - } + workspace.assert_spago_calls("src", &[&["fetch"], &["sources", "--json"]]); } #[test] @@ -102,7 +89,7 @@ fn rebuilds_after_edits_and_recovers_from_diagnostics() { .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_for(IrisExecutable::V2, &["watch"])); + 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() @@ -134,7 +121,7 @@ fn suppresses_diagnostics_without_hiding_watch_summaries() { .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_for(IrisExecutable::V2, &["watch", "--no-diagnostics"]); + 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") @@ -155,7 +142,7 @@ fn adds_and_removes_sources_and_stale_outputs() { .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_for(IrisExecutable::V2, &["watch"])); + 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() @@ -181,7 +168,7 @@ fn rebuilds_for_ffi_changes_and_reconciles_foreign_outputs() { 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_for(IrisExecutable::V2, &["watch"])); + 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") @@ -211,7 +198,7 @@ fn waits_for_a_deleted_input_directory_and_builds_when_it_returns() { .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_for(IrisExecutable::V2, &["watch"])); + 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() @@ -236,11 +223,8 @@ fn resolves_relative_output_from_the_invocation_directory_and_excludes_it() { 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_for( - IrisExecutable::V2, - "src", - &["watch", "--package", "application", "--output", "generated"], - ); + 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, _| { @@ -257,7 +241,7 @@ fn reports_output_failures_separately_and_recovers_on_a_later_change() { 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_for(IrisExecutable::V2, &["watch", "--quiet"]); + 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| { 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__v2_config_conflict.snap b/tests-e2e/tests/snapshots/cli__config_conflict.snap similarity index 86% rename from tests-e2e/tests/snapshots/cli__v2_config_conflict.snap rename to tests-e2e/tests/snapshots/cli__config_conflict.snap index bd0a68351..8d9298046 100644 --- a/tests-e2e/tests/snapshots/cli__v2_config_conflict.snap +++ b/tests-e2e/tests/snapshots/cli__config_conflict.snap @@ -6,6 +6,6 @@ status: 2 --- stderr error: the argument '--config' cannot be used with '--config-file' -Usage: iris-v2 lsp [FLAGS] +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__v2_config_invalid.snap b/tests-e2e/tests/snapshots/cli__config_invalid.snap similarity index 100% rename from tests-e2e/tests/snapshots/cli__v2_config_invalid.snap rename to tests-e2e/tests/snapshots/cli__config_invalid.snap 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 9097d54c9..f827b265d 100644 --- a/tests-e2e/tests/snapshots/cli__help_build.snap +++ b/tests-e2e/tests/snapshots/cli__help_build.snap @@ -8,19 +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. - --no-diagnostics Suppress compiler warnings and errors without hiding build progress. - -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..384006601 100644 --- a/tests-e2e/tests/snapshots/cli__help_root.snap +++ b/tests-e2e/tests/snapshots/cli__help_root.snap @@ -4,30 +4,19 @@ 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. + 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 deleted file mode 100644 index 4cca69817..000000000 --- a/tests-e2e/tests/snapshots/cli__help_run.snap +++ /dev/null @@ -1,28 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 0 ---- stdout -Build and run a Spago package with Node.js. - -Usage: iris run [FLAGS] [-- ARGUMENTS]… - -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 ---- stderr diff --git a/tests-e2e/tests/snapshots/cli__help_test.snap b/tests-e2e/tests/snapshots/cli__help_test.snap deleted file mode 100644 index a925ccbd6..000000000 --- a/tests-e2e/tests/snapshots/cli__help_test.snap +++ /dev/null @@ -1,28 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 0 ---- stdout -Build and test one or more Spago packages with Node.js. - -Usage: iris test [FLAGS] [-- ARGUMENTS]… - -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 ---- 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 deleted file mode 100644 index b9142b3bd..000000000 --- a/tests-e2e/tests/snapshots/cli__run_requires_separator.snap +++ /dev/null @@ -1,18 +0,0 @@ ---- -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 - │ ╰──────────────── - • diff --git a/tests-e2e/tests/snapshots/cli__test_requires_separator.snap b/tests-e2e/tests/snapshots/cli__test_requires_separator.snap deleted file mode 100644 index 65c15eca5..000000000 --- a/tests-e2e/tests/snapshots/cli__test_requires_separator.snap +++ /dev/null @@ -1,18 +0,0 @@ ---- -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 - │ ╰───────────────── - • 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/snapshots/cli__v2_add_requires_dependencies.snap b/tests-e2e/tests/snapshots/cli__v2_add_requires_dependencies.snap deleted file mode 100644 index 5c6f0ae92..000000000 --- a/tests-e2e/tests/snapshots/cli__v2_add_requires_dependencies.snap +++ /dev/null @@ -1,12 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -error: the following required arguments were not provided: - … - -Usage: iris-v2 add [-p --package ] [--test] … - -For more information, try '--help'. diff --git a/tests-e2e/tests/snapshots/cli__v2_config_file_missing.snap b/tests-e2e/tests/snapshots/cli__v2_config_file_missing.snap deleted file mode 100644 index 7f8d7770e..000000000 --- a/tests-e2e/tests/snapshots/cli__v2_config_file_missing.snap +++ /dev/null @@ -1,7 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 2 ---- stdout ---- stderr -error: failed to read configuration file missing.json: [FILE NOT FOUND] diff --git a/tests-e2e/tests/snapshots/cli__v2_help_add.snap b/tests-e2e/tests/snapshots/cli__v2_help_add.snap deleted file mode 100644 index 9429a1559..000000000 --- a/tests-e2e/tests/snapshots/cli__v2_help_add.snap +++ /dev/null @@ -1,17 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 0 ---- stdout -Add dependencies to a Spago package. - -Usage: iris-v2 add [-p --package ] [--test] … - -Arguments: - … Packages to add. - -Flags: - -p, --package Workspace package whose dependencies should change. - --test Add packages as test dependencies. - -h, --help Print help ---- stderr diff --git a/tests-e2e/tests/snapshots/cli__v2_help_build.snap b/tests-e2e/tests/snapshots/cli__v2_help_build.snap deleted file mode 100644 index daff971d9..000000000 --- a/tests-e2e/tests/snapshots/cli__v2_help_build.snap +++ /dev/null @@ -1,20 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 0 ---- stdout -Build a Spago workspace or package. - -Usage: iris-v2 build [FLAGS] - -Flags: - -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__v2_help_lsp.snap b/tests-e2e/tests/snapshots/cli__v2_help_lsp.snap deleted file mode 100644 index da8196af7..000000000 --- a/tests-e2e/tests/snapshots/cli__v2_help_lsp.snap +++ /dev/null @@ -1,24 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 0 ---- stdout -Run the language server over standard input and output. - -Usage: iris-v2 lsp [FLAGS] - -Flags: - --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__v2_help_new.snap b/tests-e2e/tests/snapshots/cli__v2_help_new.snap deleted file mode 100644 index d6b722b39..000000000 --- a/tests-e2e/tests/snapshots/cli__v2_help_new.snap +++ /dev/null @@ -1,13 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 0 ---- stdout -Create a Spago project in the current directory. - -Usage: iris-v2 new [--name ] - -Flags: - --name Package name. Defaults to the current directory name. - -h, --help Print help ---- stderr diff --git a/tests-e2e/tests/snapshots/cli__v2_help_root.snap b/tests-e2e/tests/snapshots/cli__v2_help_root.snap deleted file mode 100644 index 26adfaf68..000000000 --- a/tests-e2e/tests/snapshots/cli__v2_help_root.snap +++ /dev/null @@ -1,22 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 0 ---- stdout -iris-v2 0.1.0 -Experimental PureScript compiler command-line interface. - -Usage: iris-v2 - -Commands: - 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. - 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: - -h, --help Print help - -V, --version Print version ---- stderr diff --git a/tests-e2e/tests/snapshots/cli__v2_help_watch.snap b/tests-e2e/tests/snapshots/cli__v2_help_watch.snap deleted file mode 100644 index f29c42807..000000000 --- a/tests-e2e/tests/snapshots/cli__v2_help_watch.snap +++ /dev/null @@ -1,19 +0,0 @@ ---- -source: tests-e2e/tests/cli.rs ---- -status: 0 ---- stdout -Build a Spago workspace or package and rebuild when inputs change. - -Usage: iris-v2 watch [FLAGS] - -Flags: - -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/support.rs b/tests-e2e/tests/support.rs index a70989181..1b3a42a5f 100644 --- a/tests-e2e/tests/support.rs +++ b/tests-e2e/tests/support.rs @@ -10,16 +10,6 @@ pub struct TestWorkspace { temporary: tempfile::TempDir, } -#[derive(Clone, Copy, Debug)] -pub enum IrisExecutable { - V1, - V2, -} - -impl IrisExecutable { - pub const ALL: [IrisExecutable; 2] = [IrisExecutable::V1, IrisExecutable::V2]; -} - impl TestWorkspace { pub fn empty() -> TestWorkspace { TestWorkspace { temporary: tempfile::tempdir().unwrap() } @@ -58,32 +48,11 @@ impl TestWorkspace { } pub fn command(&self, arguments: &[&str]) -> Output { - self.command_for(IrisExecutable::V1, arguments) + self.command_in("", arguments) } pub fn command_in(&self, directory: &str, arguments: &[&str]) -> Output { - self.command_in_for(IrisExecutable::V1, directory, arguments) - } - - pub fn command_for(&self, executable: IrisExecutable, arguments: &[&str]) -> Output { - self.command_in_for(executable, "", arguments) - } - - pub fn command_in_for( - &self, - executable: IrisExecutable, - directory: &str, - arguments: &[&str], - ) -> Output { - self.command_builder_for(executable, directory, arguments).output().unwrap() - } - - pub fn v2_command(&self, arguments: &[&str]) -> Output { - self.command_for(IrisExecutable::V2, arguments) - } - - pub fn v2_command_in(&self, directory: &str, arguments: &[&str]) -> Output { - self.command_in_for(IrisExecutable::V2, directory, arguments) + self.command_builder(directory, arguments).output().unwrap() } pub fn spawn(&self, arguments: &[&str]) -> Child { @@ -91,20 +60,7 @@ impl TestWorkspace { } pub fn spawn_in(&self, directory: &str, arguments: &[&str]) -> Child { - self.spawn_in_for(IrisExecutable::V1, directory, arguments) - } - - pub fn spawn_for(&self, executable: IrisExecutable, arguments: &[&str]) -> Child { - self.spawn_in_for(executable, "", arguments) - } - - pub fn spawn_in_for( - &self, - executable: IrisExecutable, - directory: &str, - arguments: &[&str], - ) -> Child { - self.command_builder_for(executable, directory, arguments) + self.command_builder(directory, arguments) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -113,31 +69,9 @@ impl TestWorkspace { } pub fn command_builder(&self, directory: &str, arguments: &[&str]) -> Command { - self.command_builder_for(IrisExecutable::V1, directory, arguments) - } - - pub fn command_builder_for( - &self, - executable: IrisExecutable, - directory: &str, - arguments: &[&str], - ) -> Command { - let executable = match executable { - IrisExecutable::V1 => env!("CARGO_BIN_EXE_iris-e2e"), - IrisExecutable::V2 => env!("CARGO_BIN_EXE_iris-v2-e2e"), - }; - self.command_builder_with(executable, directory, arguments) - } - - fn command_builder_with( - &self, - executable: &str, - directory: &str, - arguments: &[&str], - ) -> Command { let current_directory = self.path().join(directory); fs::create_dir_all(¤t_directory).unwrap(); - let mut command = Command::new(executable); + let mut command = Command::new(env!("CARGO_BIN_EXE_iris-e2e")); command .args(arguments) .current_dir(current_directory) From e23b87acffc4c17732fae7e61397e5c1c3231093 Mon Sep 17 00:00:00 2001 From: Justin Garcia Date: Sun, 13 Sep 2026 04:49:58 +0000 Subject: [PATCH 19/26] Normalize Windows workspace paths Amp-Thread-ID: https://ampcode.com/threads/T-01a09469-c4cf-723d-9923-3b8452bea9f2 Co-authored-by: Amp --- compiler-bin/iris-build/src/workspace.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compiler-bin/iris-build/src/workspace.rs b/compiler-bin/iris-build/src/workspace.rs index a8b3d17f7..74f35e839 100644 --- a/compiler-bin/iris-build/src/workspace.rs +++ b/compiler-bin/iris-build/src/workspace.rs @@ -71,7 +71,7 @@ impl Workspace { 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)?; From 5290f41cbe1f35192142dcc32c8f6e4ac00d41d2 Mon Sep 17 00:00:00 2001 From: Justin Garcia Date: Sun, 13 Sep 2026 05:00:19 +0000 Subject: [PATCH 20/26] Normalize diagnostic path separators Amp-Thread-ID: https://ampcode.com/threads/T-01a09469-c4cf-723d-9923-3b8452bea9f2 Co-authored-by: Amp --- compiler-bin/iris-build/src/compile.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compiler-bin/iris-build/src/compile.rs b/compiler-bin/iris-build/src/compile.rs index a482ffcf9..5975e2aa8 100644 --- a/compiler-bin/iris-build/src/compile.rs +++ b/compiler-bin/iris-build/src/compile.rs @@ -218,7 +218,7 @@ fn report_diagnostics( 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).display().to_string() + file_path.strip_prefix(root).unwrap_or(&file_path).to_string_lossy().replace('\\', "/") } else { source_path.to_owned() } From e09a070539e5a8d325cab2a51456530839ae5bc7 Mon Sep 17 00:00:00 2001 From: Justin Garcia Date: Sun, 13 Sep 2026 05:30:33 +0000 Subject: [PATCH 21/26] Add run and test commands Amp-Thread-ID: https://ampcode.com/threads/T-01a09469-c4cf-723d-9923-3b8452bea9f2 Co-authored-by: Amp --- compiler-bin/iris-build/bundled/runner.mjs | 8 + compiler-bin/iris-build/src/lib.rs | 3 +- compiler-bin/iris-build/src/project.rs | 207 ++++++++++++++++-- compiler-bin/iris-build/src/workspace.rs | 32 ++- compiler-bin/iris-cli/src/cli.rs | 95 ++++++-- compiler-bin/iris-cli/src/lib.rs | 14 ++ tests-e2e/tests/cli.rs | 17 +- tests-e2e/tests/package_manager.rs | 4 + tests-e2e/tests/package_manager/run.rs | 68 ++++++ tests-e2e/tests/package_manager/test.rs | 57 +++++ tests-e2e/tests/snapshots/cli__help_root.snap | 2 + tests-e2e/tests/snapshots/cli__help_run.snap | 22 ++ tests-e2e/tests/snapshots/cli__help_test.snap | 22 ++ .../cli__run_requires_separator.snap | 11 + .../cli__test_requires_separator.snap | 11 + 15 files changed, 542 insertions(+), 31 deletions(-) create mode 100644 compiler-bin/iris-build/bundled/runner.mjs create mode 100644 tests-e2e/tests/package_manager/run.rs create mode 100644 tests-e2e/tests/package_manager/test.rs create mode 100644 tests-e2e/tests/snapshots/cli__help_run.snap create mode 100644 tests-e2e/tests/snapshots/cli__help_test.snap create mode 100644 tests-e2e/tests/snapshots/cli__run_requires_separator.snap create mode 100644 tests-e2e/tests/snapshots/cli__test_requires_separator.snap diff --git a/compiler-bin/iris-build/bundled/runner.mjs b/compiler-bin/iris-build/bundled/runner.mjs new file mode 100644 index 000000000..c42cd6dde --- /dev/null +++ b/compiler-bin/iris-build/bundled/runner.mjs @@ -0,0 +1,8 @@ +if (process.argv[2] === "--") { + process.argv.splice(2, 1); +} +const target = await import(process.argv[1]); +if (typeof target.main !== "function") { + throw new Error(`Module does not export a main function: ${process.argv[1]}`); +} +await target.main(); diff --git a/compiler-bin/iris-build/src/lib.rs b/compiler-bin/iris-build/src/lib.rs index 542199602..637d65523 100644 --- a/compiler-bin/iris-build/src/lib.rs +++ b/compiler-bin/iris-build/src/lib.rs @@ -10,7 +10,8 @@ mod walk; mod workspace; pub use project::{ - BuildConfig, BuildError, PreparedProject, ProjectConfig, ProjectError, build, prepare_project, + BuildConfig, BuildError, ExecutionError, PreparedProject, ProjectConfig, ProjectError, + RunConfig, TestConfig, build, prepare_project, run, test, }; pub use session::{ BuildSession, BuildSessionConfig, InputChange, InputChanges, RebuildOutcome, SessionError, diff --git a/compiler-bin/iris-build/src/project.rs b/compiler-bin/iris-build/src/project.rs index 11ad45176..fee57e94e 100644 --- a/compiler-bin/iris-build/src/project.rs +++ b/compiler-bin/iris-build/src/project.rs @@ -1,16 +1,20 @@ -use std::path::PathBuf; +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}; 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, @@ -26,6 +30,20 @@ pub struct ProjectConfig { 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)] @@ -40,6 +58,24 @@ enum ProjectFailure { 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)] @@ -56,6 +92,19 @@ impl BuildError { #[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, @@ -76,12 +125,24 @@ pub fn prepare_project(config: ProjectConfig) -> Result 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: config.package, - output: config.output, + 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 package_sources = spago::source_files_by_package(&project.root)?; let progress = ProgressRuntime::start(!config.quiet, config.color); @@ -109,22 +170,142 @@ fn build_project(config: BuildConfig) -> Result<(), ProjectFailure> { Ok(()) } +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 spago = spago::SpagoCommand::new(¤t_directory)?; - spago.fetch(workspace.selected.as_deref(), !config.quiet)?; - let source_globs = spago.source_globs(workspace.selected.as_deref(), !config.quiet)?; - let output = if let Some(output) = config.output { + 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: PathBuf::clone(&output), - error, - })? + .map_err(|error| ProjectFailure::NormalizeOutput { path: output.to_path_buf(), error })? .into_owned() } else { - workspace.root.join("output") + root.join("output") }; - Ok(PreparedProject { root: workspace.root, output, source_globs }) + 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/workspace.rs b/compiler-bin/iris-build/src/workspace.rs index 74f35e839..b8081c084 100644 --- a/compiler-bin/iris-build/src/workspace.rs +++ b/compiler-bin/iris-build/src/workspace.rs @@ -19,6 +19,8 @@ pub enum WorkspaceError { 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}")] @@ -53,16 +55,29 @@ pub struct Manifest { #[serde(rename_all = "camelCase")] pub struct PackageManifest { pub name: String, + pub run: Option, + pub test: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExecutionConfig { + pub main: Option, + #[serde(default)] + pub exec_args: Vec, } #[derive(Debug, Clone)] pub struct WorkspacePackage { pub root: PathBuf, + pub manifest: PackageManifest, + pub has_tests: bool, } #[derive(Debug)] pub struct Workspace { pub root: PathBuf, + pub packages: BTreeMap, pub selected: Option, } @@ -97,7 +112,14 @@ impl Workspace { None }; - Ok(Workspace { root, selected }) + Ok(Workspace { root, packages, selected }) + } + + pub fn require_selected(&self) -> Result<&WorkspacePackage, WorkspaceError> { + let Some(name) = &self.selected else { + return Err(WorkspaceError::AmbiguousPackage); + }; + Ok(&self.packages[name]) } } @@ -144,7 +166,7 @@ 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![]; @@ -167,7 +189,11 @@ fn discover_packages(root: &Path) -> Result, continue; }; let name = String::clone(&package.name); - let workspace_package = WorkspacePackage { root: package_root.to_path_buf() }; + 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(String::clone(&name), workspace_package) { return Err(WorkspaceError::DuplicatePackage { name, diff --git a/compiler-bin/iris-cli/src/cli.rs b/compiler-bin/iris-cli/src/cli.rs index d7fb36670..e272fa35f 100644 --- a/compiler-bin/iris-cli/src/cli.rs +++ b/compiler-bin/iris-cli/src/cli.rs @@ -4,7 +4,7 @@ use std::path::PathBuf; use std::{env, fs}; use configuration::{Configuration, ConfigurationSettings}; -use iris_build::{BuildConfig, ProjectConfig}; +use iris_build::{BuildConfig, ProjectConfig, RunConfig, TestConfig}; use iris_package::{AddConfig, NewConfig}; use itertools::Itertools; use thiserror::Error; @@ -38,6 +38,16 @@ pub enum Command { 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 { @@ -93,6 +103,21 @@ impl AddOptions { #[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, @@ -108,14 +133,6 @@ pub struct BuildOptions { /// When to use colors in diagnostics and progress output. #[usage(long, value_enum, default = "auto")] color: ColorChoice, - - /// 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(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] @@ -128,16 +145,68 @@ pub enum ColorChoice { impl BuildOptions { pub fn into_config(self) -> BuildConfig { BuildConfig { - package: self.package, - output: self.output, - quiet: self.quiet, - color: use_color(self.color), + 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 { diff --git a/compiler-bin/iris-cli/src/lib.rs b/compiler-bin/iris-cli/src/lib.rs index e7311d7ae..05bbd8898 100644 --- a/compiler-bin/iris-cli/src/lib.rs +++ b/compiler-bin/iris-cli/src/lib.rs @@ -70,5 +70,19 @@ pub fn run() -> i32 { } } } + 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/tests-e2e/tests/cli.rs b/tests-e2e/tests/cli.rs index e206856d4..196213670 100644 --- a/tests-e2e/tests/cli.rs +++ b/tests-e2e/tests/cli.rs @@ -27,6 +27,8 @@ fn prints_help_for_every_command_path() { ("help_build", &["build", "--help"]), ("help_watch", &["watch", "--help"]), ("help_lsp", &["lsp", "--help"]), + ("help_run", &["run", "--help"]), + ("help_test", &["test", "--help"]), ]; for (name, arguments) in paths { @@ -51,13 +53,26 @@ fn prints_version_to_stdout() { #[test] fn rejects_unpromoted_commands() { let workspace = TestWorkspace::empty(); - for command in ["compile", "run", "test", "docs"] { + 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"); } } +#[test] +fn run_and_test_require_separator_before_trailing_arguments() { + let workspace = TestWorkspace::empty(); + for (name, arguments) in [ + ("run_requires_separator", &["run", "argument"][..]), + ("test_requires_separator", &["test", "argument"][..]), + ] { + let output = workspace.command(arguments); + assert!(!output.status.success(), "{name} unexpectedly succeeded"); + snapshot_output(name, &output); + } +} + #[test] fn add_requires_dependencies() { let workspace = TestWorkspace::empty(); diff --git a/tests-e2e/tests/package_manager.rs b/tests-e2e/tests/package_manager.rs index 6df78b83d..33b387005 100644 --- a/tests-e2e/tests/package_manager.rs +++ b/tests-e2e/tests/package_manager.rs @@ -4,7 +4,11 @@ mod add; mod build; #[path = "package_manager/new.rs"] mod new; +#[path = "package_manager/run.rs"] +mod run; #[path = "support.rs"] mod support; +#[path = "package_manager/test.rs"] +mod test; #[path = "package_manager/watch.rs"] mod watch; diff --git a/tests-e2e/tests/package_manager/run.rs b/tests-e2e/tests/package_manager/run.rs new file mode 100644 index 000000000..3839af817 --- /dev/null +++ b/tests-e2e/tests/package_manager/run.rs @@ -0,0 +1,68 @@ +use super::support::{TestWorkspace, assert_success}; + +#[test] +fn runs_the_configured_module_with_node() { + let workspace = TestWorkspace::empty(); + workspace.write( + "spago.yaml", + r#"workspace: {} +package: + name: application + dependencies: [] + run: + main: Configured +"#, + ); + workspace.write( + "src/Configured.purs", + r#"module Configured where + +data Unit = Unit +foreign import data Effect :: Type -> Type +foreign import main :: Effect Unit +"#, + ); + workspace.write( + "src/Configured.js", + "export const main = () => console.log(process.argv.slice(2).join(\",\"));\n", + ); + + let output = + workspace.command(&["run", "--output", "generated", "--quiet", "--", "first", "second"]); + assert_success(&output); + assert_eq!(String::from_utf8_lossy(&output.stdout), "first,second\n"); + assert!(workspace.path().join("generated/Configured/index.js").is_file()); + workspace.assert_spago_calls( + "", + &[&["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/test.rs b/tests-e2e/tests/package_manager/test.rs new file mode 100644 index 000000000..e2978368f --- /dev/null +++ b/tests-e2e/tests/package_manager/test.rs @@ -0,0 +1,57 @@ +use super::support::{TestWorkspace, assert_success}; + +#[test] +fn tests_the_configured_module_with_node() { + let workspace = TestWorkspace::empty(); + workspace.write( + "spago.yaml", + r#"workspace: {} +package: + name: application + dependencies: [] + test: + main: Application.Test + dependencies: [] + execArgs: [from-manifest] +"#, + ); + workspace.write( + "test/Application/Test.purs", + r#"module Application.Test where + +data Unit = Unit +foreign import data Effect :: Type -> Type +foreign import main :: Effect Unit +"#, + ); + workspace.write( + "test/Application/Test.js", + "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: from-manifest\n"); + workspace.assert_spago_calls( + "", + &[&["fetch", "-p", "application"], &["sources", "--json", "-p", "application"]], + ); +} + +#[test] +fn rejects_a_selected_package_without_tests_before_fetching() { + let workspace = TestWorkspace::empty(); + workspace.write( + "spago.yaml", + r#"workspace: {} +package: + name: application + dependencies: [] +"#, + ); + workspace.write("src/Main.purs", "module Main where\n"); + + let output = workspace.command(&["test", "--quiet"]); + assert!(!output.status.success()); + workspace.assert_spago_calls("", &[]); +} diff --git a/tests-e2e/tests/snapshots/cli__help_root.snap b/tests-e2e/tests/snapshots/cli__help_root.snap index 384006601..64edda2d7 100644 --- a/tests-e2e/tests/snapshots/cli__help_root.snap +++ b/tests-e2e/tests/snapshots/cli__help_root.snap @@ -13,6 +13,8 @@ Commands: 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) diff --git a/tests-e2e/tests/snapshots/cli__help_run.snap b/tests-e2e/tests/snapshots/cli__help_run.snap new file mode 100644 index 000000000..bd70b4a97 --- /dev/null +++ b/tests-e2e/tests/snapshots/cli__help_run.snap @@ -0,0 +1,22 @@ +--- +source: tests-e2e/tests/cli.rs +--- +status: 0 +--- stdout +Build and run a Spago package with Node.js. + +Usage: iris run [FLAGS] [-- ARGUMENTS]… + +Arguments: + [-- ARGUMENTS]… Arguments passed to the program. + +Flags: + -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 new file mode 100644 index 000000000..b3d0bc0e5 --- /dev/null +++ b/tests-e2e/tests/snapshots/cli__help_test.snap @@ -0,0 +1,22 @@ +--- +source: tests-e2e/tests/cli.rs +--- +status: 0 +--- stdout +Build and test one or more Spago packages with Node.js. + +Usage: iris test [FLAGS] [-- ARGUMENTS]… + +Arguments: + [-- ARGUMENTS]… Arguments passed to each test program. + +Flags: + -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__run_requires_separator.snap b/tests-e2e/tests/snapshots/cli__run_requires_separator.snap new file mode 100644 index 000000000..db24d9f35 --- /dev/null +++ b/tests-e2e/tests/snapshots/cli__run_requires_separator.snap @@ -0,0 +1,11 @@ +--- +source: tests-e2e/tests/cli.rs +--- +status: 2 +--- stdout +--- stderr +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 new file mode 100644 index 000000000..a42b84a72 --- /dev/null +++ b/tests-e2e/tests/snapshots/cli__test_requires_separator.snap @@ -0,0 +1,11 @@ +--- +source: tests-e2e/tests/cli.rs +--- +status: 2 +--- stdout +--- stderr +error: '[-- ARGUMENTS]…' can only be given after '--' + +Usage: iris test [FLAGS] [-- ARGUMENTS]… + +For more information, try '--help'. From 463f54bfccb144bf5e6f256956fe35afff629d56 Mon Sep 17 00:00:00 2001 From: Justin Garcia Date: Sun, 13 Sep 2026 07:55:08 +0000 Subject: [PATCH 22/26] Materialize Prim compilation sources Co-authored-by: Amp Amp-Thread-ID: https://ampcode.com/threads/T-01a09469-c4cf-723d-9923-3b8452bea9f2 --- Cargo.lock | 1 + compiler-bin/iris-build/Cargo.toml | 1 + compiler-bin/iris-build/src/compilation.rs | 100 ++++++++++++++------- compiler-bin/iris-build/src/compile.rs | 16 ++-- compiler-bin/iris-build/src/lib.rs | 2 +- compiler-bin/iris-build/src/session.rs | 9 +- 6 files changed, 85 insertions(+), 44 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4a7d446e8..e58715cfa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1748,6 +1748,7 @@ dependencies = [ "serde", "serde_yml", "spago", + "tempfile", "thiserror", "url", "walkdir", diff --git a/compiler-bin/iris-build/Cargo.toml b/compiler-bin/iris-build/Cargo.toml index 135a1a01e..eeb7ff06e 100644 --- a/compiler-bin/iris-build/Cargo.toml +++ b/compiler-bin/iris-build/Cargo.toml @@ -25,6 +25,7 @@ rayon = "1.12.0" serde = { version = "1.0.229", features = ["derive"] } serde_yml = "0.0.12" 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/iris-build/src/compilation.rs b/compiler-bin/iris-build/src/compilation.rs index 853cff78e..ccfefb6f3 100644 --- a/compiler-bin/iris-build/src/compilation.rs +++ b/compiler-bin/iris-build/src/compilation.rs @@ -1,39 +1,65 @@ -//! Compiler query state used by build execution. +//! 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 itertools::Itertools; use prim_constants::MODULE_MAP; +use tempfile::TempDir; +use url::Url; -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum SourceRole { - Prim, - Input, +pub struct MaterializedPrim { + directory: TempDir, } -pub struct CompilationState { +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<(), SourceRole>, + files: FileLifecycle, + sources: BTreeSet, + prim: MaterializedPrim, } -impl CompilationState { - pub fn new() -> CompilationState { +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 source = format!("prim://localhost/{name}.purs"); - let foreign = format!("prim://localhost/{name}.js"); + 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, foreign), + unit: SourceUnitKey::new(source.as_str(), foreign.as_str()), event: SourceEvent::DiskObserved { disk: DiskObservation::Found(Arc::from(*content)), - metadata: SourceRole::Prim, + metadata: Metadata::clone(&prim_metadata), }, }; @@ -45,19 +71,23 @@ impl CompilationState { engine.set_module_file(name, id); } - CompilationState { engine, files } + 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: SourceRole::Input }, - }; - self.files.apply(&self.engine, event) + 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( @@ -71,14 +101,6 @@ impl CompilationState { self.files.apply(&self.engine, event) } - pub fn input_sources(&self) -> Vec { - let sources = self - .files - .source_ids() - .filter(|&file_id| self.files.source_metadata(file_id) == Some(&SourceRole::Input)); - sources.collect_vec() - } - pub fn source_content(&self, locator: &str) -> Result>, QueryError> { let Some(file_id) = self.files.source_id(locator) else { return Ok(None); @@ -95,6 +117,14 @@ impl CompilationState { self.engine.snapshot() } + 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) } @@ -124,10 +154,14 @@ mod tests { use super::*; #[test] - fn prim_modules_are_not_input_sources() { - let compilation = CompilationState::new(); - - assert!(compilation.input_sources().is_empty()); - assert!(compilation.snapshot().module_file("Prim").is_some()); + 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 index 5975e2aa8..3b862eb04 100644 --- a/compiler-bin/iris-build/src/compile.rs +++ b/compiler-bin/iris-build/src/compile.rs @@ -12,7 +12,7 @@ use rayon::prelude::*; use thiserror::Error; use url::Url; -use super::compilation::CompilationState; +use super::compilation::{CompilationState, MaterializedPrim}; use super::events::{BuildEvent, BuildEventSink, BuildOutcome}; use super::plan::{BuildPlan, BuildPlanError, PackageInput, SelectedSource}; use super::{executor, walk}; @@ -78,7 +78,8 @@ pub(crate) fn build(config: BuildConfig<'_>) -> Result<(), CompileError> { } events.send(BuildEvent::PlanReady { package_count: plan.package_count() }); - let mut compilation = CompilationState::new(); + let prim = MaterializedPrim::new()?; + let mut compilation = CompilationState::new(prim, ()); let source_paths = plan.packages().flat_map(|package| package.source_paths.iter()).cloned(); let source_paths = source_paths.collect::>(); let mut sources = HashMap::new(); @@ -94,7 +95,7 @@ pub(crate) fn build(config: BuildConfig<'_>) -> Result<(), CompileError> { let duration = started.elapsed(); events.send(BuildEvent::Finalizing { duration }); - let all_sources = compilation.input_sources(); + let all_sources = sources.into_values().collect_vec(); let (diagnostic_collections, has_errors) = collect_diagnostics(&compilation, &all_sources)?; if !has_errors || resilient { let modules = collect_modules(&compilation, &all_sources)?; @@ -119,7 +120,7 @@ pub(crate) fn rebuild( diagnostics: bool, owned_outputs: &mut BTreeSet, ) -> Result { - let sources = compilation.input_sources(); + let sources = compilation.source_ids().collect_vec(); query_package(compilation, &sources)?; let (diagnostic_collections, has_errors) = collect_diagnostics(compilation, &sources)?; if diagnostics { @@ -143,8 +144,11 @@ fn load_source(compilation: &mut CompilationState, path: &Path) -> Result Result { let walked = walk::walk_filtered(&project.root, &project.source_globs, [&project.output])?; let source_roots = walked.roots.into_iter().collect_vec(); + let prim = MaterializedPrim::new()?; Ok(BuildSession { root: project.root, output: project.output, @@ -97,7 +98,7 @@ impl BuildSession { source_globs: walked.globs, source_paths: BTreeSet::new(), generated_outputs: BTreeSet::new(), - compilation: CompilationState::new(), + compilation: CompilationState::new(prim, ()), color: config.color, diagnostics: config.diagnostics, }) @@ -178,7 +179,7 @@ impl BuildSession { } fn rebuild_inputs(&mut self) -> Result { - if self.compilation.input_sources().is_empty() { + if self.compilation.source_ids().next().is_none() { self.reconcile_outputs(BTreeSet::new())?; return Ok(RebuildOutcome::NoInputs); } @@ -251,7 +252,7 @@ fn observe_source_unit( let previous_name = compilation.module_name(unit.source())?; let source = observe_disk(source_path); - let mut lifecycle = compilation.observe_source(SourceUnitKey::clone(&unit), source); + 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)); From 09443394edf59051e2e3af2ab92a507f81646f39 Mon Sep 17 00:00:00 2001 From: Justin Garcia Date: Sun, 13 Sep 2026 07:58:31 +0000 Subject: [PATCH 23/26] Expose package-scheduled initialization Amp-Thread-ID: https://ampcode.com/threads/T-01a09469-c4cf-723d-9923-3b8452bea9f2 Co-authored-by: Amp --- compiler-bin/iris-build/src/compile.rs | 156 ++++++++++++++++++++----- compiler-bin/iris-build/src/lib.rs | 2 +- 2 files changed, 128 insertions(+), 30 deletions(-) diff --git a/compiler-bin/iris-build/src/compile.rs b/compiler-bin/iris-build/src/compile.rs index 3b862eb04..02eb7947c 100644 --- a/compiler-bin/iris-build/src/compile.rs +++ b/compiler-bin/iris-build/src/compile.rs @@ -29,7 +29,7 @@ pub(crate) struct BuildConfig<'a> { } #[derive(Debug, Error)] -pub(crate) enum CompileError { +pub enum CompileError { #[error("compilation failed")] Diagnostics { reported: bool }, #[error("failed to convert path to a file URL: {0}")] @@ -56,22 +56,83 @@ struct ModuleWrite { result: Result<(), CompileError>, } -pub(crate) fn build(config: BuildConfig<'_>) -> Result<(), CompileError> { - let BuildConfig { root, output, source_globs, packages, color, diagnostics, resilient, events } = - config; +#[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 packages: Vec, + pub prim_metadata: Metadata, + pub source_metadata: SourceMetadata, + pub execution: PackageExecution, + pub events: &'a dyn BuildEventSink, +} + +pub struct InitialBuild { + compilation: CompilationState, + sources: Vec, + diagnostics: Vec, + has_errors: bool, + duration: std::time::Duration, +} + +impl InitialBuild { + pub fn compilation(&self) -> &CompilationState { + &self.compilation + } + + pub fn sources(&self) -> &[FileId] { + &self.sources + } + + pub fn has_errors(&self) -> bool { + self.has_errors + } + + pub fn duration(&self) -> std::time::Duration { + self.duration + } + + pub fn into_compilation(self) -> CompilationState { + self.compilation + } +} + +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, + packages, + prim_metadata, + source_metadata, + execution, + events, + } = config; let started = Instant::now(); - let selected_paths = walk::walk(&root, &source_globs)?.files; + let selected_paths = walk::walk(root, source_globs)?.files; let selected_sources = selected_paths.into_iter().map(|path| { let identity = dunce::canonicalize(&path)?; - Ok(SelectedSource { path, identity }) + Ok::<_, io::Error>(SelectedSource { path, identity }) }); - let selected_sources = selected_sources.collect::, io::Error>>()?; + 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.collect::, io::Error>>()?; - Ok(PackageInput { source_identities, ..package }) + let source_identities = + source_identities.process_results(|sources| sources.collect_vec())?; + Ok::<_, io::Error>(PackageInput { source_identities, ..package }) }); - let package_inputs = package_inputs.collect::, io::Error>>()?; + let package_inputs = package_inputs.process_results(|packages| packages.collect_vec())?; let plan = BuildPlan::new(selected_sources, package_inputs)?; if plan.is_empty() { return Err(CompileError::NoInputs); @@ -79,32 +140,57 @@ pub(crate) fn build(config: BuildConfig<'_>) -> Result<(), CompileError> { events.send(BuildEvent::PlanReady { package_count: plan.package_count() }); let prim = MaterializedPrim::new()?; - let mut compilation = CompilationState::new(prim, ()); + let mut compilation = CompilationState::new(prim, prim_metadata); let source_paths = plan.packages().flat_map(|package| package.source_paths.iter()).cloned(); let source_paths = source_paths.collect::>(); let mut sources = HashMap::new(); for path in source_paths { - sources.insert(PathBuf::clone(&path), load_source(&mut compilation, &path)?); + let metadata = source_metadata(&path); + sources.insert( + PathBuf::clone(&path), + load_source(&mut compilation, &path, metadata)?, + ); } - executor::execute_parallel(&plan, events, &|package| { + let engine = compilation.snapshot(); + 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(&compilation, &package_sources) - })?; + query_package(&engine, &package_sources) + }; + 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 all_sources = sources.into_values().collect_vec(); - let (diagnostic_collections, has_errors) = collect_diagnostics(&compilation, &all_sources)?; + let sources = sources.into_values().collect_vec(); + let (diagnostics, has_errors) = collect_diagnostics(&compilation, &sources)?; + Ok(InitialBuild { compilation, sources, diagnostics, has_errors, duration }) +} + +pub(crate) fn build(config: BuildConfig<'_>) -> Result<(), CompileError> { + let BuildConfig { root, output, source_globs, packages, color, diagnostics, resilient, events } = + config; + let initial = build_initial(InitialBuildConfig { + root: &root, + source_globs: &source_globs, + packages, + prim_metadata: (), + source_metadata: |_: &Path| (), + execution: PackageExecution::Parallel, + events, + })?; + let has_errors = initial.has_errors; if !has_errors || resilient { - let modules = collect_modules(&compilation, &all_sources)?; - write_modules(&compilation, &modules, &output, &mut BTreeSet::new())?; + let modules = collect_modules(&initial.compilation, &initial.sources)?; + write_modules(&initial.compilation, &modules, &output, &mut BTreeSet::new())?; } let outcome = if has_errors { BuildOutcome::Diagnostics } else { BuildOutcome::Succeeded }; - events.send(BuildEvent::Finished { duration: started.elapsed(), outcome }); + events.send(BuildEvent::Finished { duration: initial.duration, outcome }); if diagnostics { - report_diagnostics(&compilation, diagnostic_collections, &root, color); + report_diagnostics(&initial.compilation, initial.diagnostics, &root, color); } if has_errors { return Err(CompileError::Diagnostics { reported: diagnostics }); @@ -121,7 +207,8 @@ pub(crate) fn rebuild( owned_outputs: &mut BTreeSet, ) -> Result { let sources = compilation.source_ids().collect_vec(); - query_package(compilation, &sources)?; + let engine = compilation.snapshot(); + query_package(&engine, &sources)?; let (diagnostic_collections, has_errors) = collect_diagnostics(compilation, &sources)?; if diagnostics { report_diagnostics(compilation, diagnostic_collections, root, color); @@ -136,7 +223,15 @@ pub(crate) fn rebuild( Ok(RebuildResult { outcome, outputs }) } -fn load_source(compilation: &mut CompilationState, path: &Path) -> Result { +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"); @@ -147,7 +242,7 @@ fn load_source(compilation: &mut CompilationState, path: &Path) -> Result Result Result<(), CompileError> { +fn query_package(engine: &building::QueryEngine, sources: &[FileId]) -> Result<(), CompileError> { sources.par_iter().try_for_each(|&file_id| { - let engine = compilation.snapshot(); engine.stabilized(file_id)?; engine.indexed(file_id)?; engine.resolved(file_id)?; @@ -179,10 +273,14 @@ fn query_package(compilation: &CompilationState, sources: &[FileId]) -> Result<( }) } -fn collect_diagnostics( - compilation: &CompilationState, +fn collect_diagnostics( + compilation: &CompilationState, sources: &[FileId], -) -> Result<(Vec, bool), CompileError> { +) -> 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 diff --git a/compiler-bin/iris-build/src/lib.rs b/compiler-bin/iris-build/src/lib.rs index 79eceadd5..8ca39b491 100644 --- a/compiler-bin/iris-build/src/lib.rs +++ b/compiler-bin/iris-build/src/lib.rs @@ -3,7 +3,7 @@ pub mod executor; pub mod plan; pub mod compilation; -mod compile; +pub mod compile; mod project; mod session; mod walk; From 64d69fd1a8a2783fc55ac207a30a2b15c72e5675 Mon Sep 17 00:00:00 2001 From: Justin Garcia Date: Sun, 13 Sep 2026 08:04:40 +0000 Subject: [PATCH 24/26] Bootstrap watch through package scheduling Amp-Thread-ID: https://ampcode.com/threads/T-01a09469-c4cf-723d-9923-3b8452bea9f2 Co-authored-by: Amp --- compiler-bin/iris-build/src/compilation.rs | 4 + compiler-bin/iris-build/src/compile.rs | 124 ++++++++++++++++----- compiler-bin/iris-build/src/events.rs | 8 ++ compiler-bin/iris-build/src/lib.rs | 4 +- compiler-bin/iris-build/src/project.rs | 63 +++++++++-- compiler-bin/iris-build/src/session.rs | 65 +++++++++-- compiler-bin/iris-build/src/walk.rs | 4 - compiler-bin/iris-watch/src/lib.rs | 40 +++++-- 8 files changed, 245 insertions(+), 67 deletions(-) diff --git a/compiler-bin/iris-build/src/compilation.rs b/compiler-bin/iris-build/src/compilation.rs index ccfefb6f3..2eeb089ee 100644 --- a/compiler-bin/iris-build/src/compilation.rs +++ b/compiler-bin/iris-build/src/compilation.rs @@ -117,6 +117,10 @@ impl CompilationState self.engine.snapshot() } + pub(crate) fn query_engine(&self) -> &QueryEngine { + &self.engine + } + pub fn source_ids(&self) -> impl Iterator + '_ { self.sources.iter().copied() } diff --git a/compiler-bin/iris-build/src/compile.rs b/compiler-bin/iris-build/src/compile.rs index 02eb7947c..813ee0374 100644 --- a/compiler-bin/iris-build/src/compile.rs +++ b/compiler-bin/iris-build/src/compile.rs @@ -65,6 +65,7 @@ pub enum PackageExecution { 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, @@ -74,32 +75,53 @@ pub struct InitialBuildConfig<'a, Metadata, SourceMetadata> { 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.sources + &self.report.sources } pub fn has_errors(&self) -> bool { - self.has_errors + self.report.has_errors } pub fn duration(&self) -> std::time::Duration { - self.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( @@ -113,6 +135,7 @@ where let InitialBuildConfig { root, source_globs, + excluded, packages, prim_metadata, source_metadata, @@ -120,7 +143,7 @@ where events, } = config; let started = Instant::now(); - let selected_paths = walk::walk(root, source_globs)?.files; + 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 }) @@ -134,63 +157,77 @@ where }); let package_inputs = package_inputs.process_results(|packages| packages.collect_vec())?; let plan = BuildPlan::new(selected_sources, package_inputs)?; - if plan.is_empty() { - return Err(CompileError::NoInputs); - } events.send(BuildEvent::PlanReady { package_count: plan.package_count() }); let prim = MaterializedPrim::new()?; let mut compilation = CompilationState::new(prim, prim_metadata); - let source_paths = plan.packages().flat_map(|package| package.source_paths.iter()).cloned(); - let source_paths = source_paths.collect::>(); + 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)?, - ); + for path in &source_paths { + let metadata = source_metadata(path); + sources.insert(PathBuf::clone(path), load_source(&mut compilation, path, metadata)?); } - let engine = compilation.snapshot(); + 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) }; - match execution { - PackageExecution::Serial => executor::execute_serial(&plan, events, &execute)?, - PackageExecution::Parallel => executor::execute_parallel(&plan, events, &execute)?, - } + 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 (diagnostics, has_errors) = collect_diagnostics(&compilation, &sources)?; - Ok(InitialBuild { compilation, sources, diagnostics, has_errors, duration }) + 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 has_errors = initial.has_errors; + 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(&initial.compilation, &initial.sources)?; - write_modules(&initial.compilation, &modules, &output, &mut BTreeSet::new())?; + 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: initial.duration, outcome }); + events.send(BuildEvent::Finished { duration: started.elapsed(), outcome }); if diagnostics { - report_diagnostics(&initial.compilation, initial.diagnostics, &root, color); + report_diagnostics(&compilation, std::mem::take(&mut report.diagnostics), &root, color); } if has_errors { return Err(CompileError::Diagnostics { reported: diagnostics }); @@ -207,7 +244,7 @@ pub(crate) fn rebuild( owned_outputs: &mut BTreeSet, ) -> Result { let sources = compilation.source_ids().collect_vec(); - let engine = compilation.snapshot(); + let engine = compilation.query_engine(); query_package(&engine, &sources)?; let (diagnostic_collections, has_errors) = collect_diagnostics(compilation, &sources)?; if diagnostics { @@ -223,6 +260,36 @@ pub(crate) fn rebuild( 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, @@ -262,6 +329,7 @@ where 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)?; diff --git a/compiler-bin/iris-build/src/events.rs b/compiler-bin/iris-build/src/events.rs index dc49b26b6..9519f1f05 100644 --- a/compiler-bin/iris-build/src/events.rs +++ b/compiler-bin/iris-build/src/events.rs @@ -10,6 +10,7 @@ use std::sync::Mutex; pub enum BuildOutcome { Succeeded, Diagnostics, + NoInputs, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -26,6 +27,12 @@ pub trait BuildEventSink: Sync { fn send(&self, event: BuildEvent); } +pub struct SilentBuildEvents; + +impl BuildEventSink for SilentBuildEvents { + fn send(&self, _: BuildEvent) {} +} + pub struct ProgressEventSink { reporter: ProgressReporter, } @@ -50,6 +57,7 @@ impl BuildEventSink for ProgressEventSink { outcome: match outcome { BuildOutcome::Succeeded => ProgressOutcome::Succeeded, BuildOutcome::Diagnostics => ProgressOutcome::Diagnostics, + BuildOutcome::NoInputs => ProgressOutcome::Succeeded, }, }, }; diff --git a/compiler-bin/iris-build/src/lib.rs b/compiler-bin/iris-build/src/lib.rs index 8ca39b491..40228895d 100644 --- a/compiler-bin/iris-build/src/lib.rs +++ b/compiler-bin/iris-build/src/lib.rs @@ -10,8 +10,8 @@ mod walk; mod workspace; pub use project::{ - BuildConfig, BuildError, ExecutionError, PreparedProject, ProjectConfig, ProjectError, - RunConfig, TestConfig, build, prepare_project, run, test, + 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/project.rs b/compiler-bin/iris-build/src/project.rs index fee57e94e..8f3a5c899 100644 --- a/compiler-bin/iris-build/src/project.rs +++ b/compiler-bin/iris-build/src/project.rs @@ -9,7 +9,7 @@ use thiserror::Error; use url::Url; use super::compile::{self, CompileError}; -use super::events::{BuildEvent, BuildEventSink, ProgressEventSink}; +use super::events::{BuildEvent, BuildEventSink, ProgressEventSink, SilentBuildEvents}; use super::plan::PackageInput; use super::workspace::{Workspace, WorkspaceError}; @@ -111,10 +111,23 @@ pub struct PreparedProject { 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> { @@ -125,6 +138,10 @@ pub fn prepare_project(config: ProjectConfig) -> Result Result { + initialize_project_inner(project).map_err(ProjectError) +} + pub fn run(config: RunConfig) -> Result<(), ExecutionError> { run_project(config).map_err(ExecutionError) } @@ -143,20 +160,10 @@ fn build_project(config: BuildConfig) -> Result<(), ProjectFailure> { } fn compile_project(project: PreparedProject, config: &BuildConfig) -> Result<(), ProjectFailure> { - let package_sources = spago::source_files_by_package(&project.root)?; - let progress = ProgressRuntime::start(!config.quiet, config.color); let events = ProgressEventSink::new(progress.reporter()); events.send(BuildEvent::Preparing); - let packages = package_sources.into_iter().map(|(name, package)| { - let dependencies = package.dependencies.into_iter().map(|name| name.to_string()); - PackageInput { - name: name.to_string(), - source_identities: package.sources, - dependencies: dependencies.collect_vec(), - } - }); - let packages = packages.collect_vec(); + let packages = package_inputs(&project.root)?; compile::build(compile::BuildConfig { root: project.root, output: project.output, @@ -170,6 +177,38 @@ fn compile_project(project: PreparedProject, config: &BuildConfig) -> Result<(), 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)| { + let dependencies = package.dependencies.into_iter().map(|name| name.to_string()); + PackageInput { + name: name.to_string(), + source_identities: package.sources, + dependencies: dependencies.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())?; diff --git a/compiler-bin/iris-build/src/session.rs b/compiler-bin/iris-build/src/session.rs index a3a66ff2c..d6c5cdd24 100644 --- a/compiler-bin/iris-build/src/session.rs +++ b/compiler-bin/iris-build/src/session.rs @@ -8,10 +8,10 @@ use itertools::Itertools; use thiserror::Error; use url::Url; -use super::compilation::{CompilationState, MaterializedPrim}; -use super::compile::{self, CompileError}; +use super::compilation::CompilationState; +use super::compile::{self, CompileError, InitialBuildReport}; use super::events::BuildOutcome; -use super::project::PreparedProject; +use super::project::InitializedProject; use super::walk; pub struct BuildSessionConfig { @@ -71,34 +71,46 @@ pub struct BuildSession { source_paths: BTreeSet, generated_outputs: BTreeSet, compilation: CompilationState, + initial_report: Option, + initial_inputs: Vec, color: bool, diagnostics: bool, } impl BuildSession { pub fn new( - project: PreparedProject, + project: InitializedProject, config: BuildSessionConfig, ) -> Result { BuildSession::create(project, config).map_err(SessionError) } fn create( - project: PreparedProject, + 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 prim = MaterializedPrim::new()?; + 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: BTreeSet::new(), + source_paths: initial.source_paths, generated_outputs: BTreeSet::new(), - compilation: CompilationState::new(prim, ()), + compilation: initial.compilation, + initial_report: Some(initial.report), + initial_inputs, color: config.color, diagnostics: config.diagnostics, }) @@ -112,12 +124,24 @@ impl BuildSession { &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 { - self.synchronize(paths).map(SessionChange::into_public).map_err(SessionError) + 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 { - self.rescan_inputs().map(SessionChange::into_public).map_err(SessionError) + 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 { @@ -179,6 +203,19 @@ impl BuildSession { } 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); @@ -192,12 +229,20 @@ impl BuildSession { 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), } } diff --git a/compiler-bin/iris-build/src/walk.rs b/compiler-bin/iris-build/src/walk.rs index 9868a7703..b201f3eb7 100644 --- a/compiler-bin/iris-build/src/walk.rs +++ b/compiler-bin/iris-build/src/walk.rs @@ -22,10 +22,6 @@ pub enum Error { WalkDirError(#[from] walkdir::Error), } -pub fn walk(root: &Path, paths: impl IntoIterator>) -> Result { - walk_filtered(root, paths, std::iter::empty::<&Path>()) -} - pub fn walk_filtered( root: &Path, includes: impl IntoIterator>, diff --git a/compiler-bin/iris-watch/src/lib.rs b/compiler-bin/iris-watch/src/lib.rs index fdcfb7c1b..cf1923b91 100644 --- a/compiler-bin/iris-watch/src/lib.rs +++ b/compiler-bin/iris-watch/src/lib.rs @@ -5,8 +5,8 @@ use std::sync::mpsc::{self, Receiver, RecvTimeoutError}; use std::time::{Duration, Instant}; use iris_build::{ - BuildSession, BuildSessionConfig, InputChange, InputChanges, PreparedProject, RebuildOutcome, - SessionError, + BuildSession, BuildSessionConfig, InputChange, InputChanges, PreparedProject, ProjectError, + RebuildOutcome, SessionError, initialize_project, }; use iris_progress::{WatchOutcome, WatchSummary, render_watch_summary}; use itertools::Itertools; @@ -30,6 +30,11 @@ enum WatchFailure { Notify(#[from] notify::Error), #[error(transparent)] Session(#[from] SessionError), + #[error(transparent)] + Project { + #[from] + source: ProjectError, + }, } #[derive(Debug, Error)] @@ -42,22 +47,28 @@ pub fn watch(project: PreparedProject, config: WatchConfig) -> Result<(), WatchE fn watch_project(project: PreparedProject, config: WatchConfig) -> Result<(), WatchFailure> { let root = project.root_directory().to_path_buf(); - let mut session = BuildSession::new( - project, - BuildSessionConfig { color: config.color, diagnostics: config.diagnostics }, - )?; + let source_roots = project.source_roots()?; let (sender, receiver) = mpsc::channel(); let mut watcher = RecommendedWatcher::new(sender, notify::Config::default())?; - for root in session.source_roots() { + 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 = changes.inputs; + 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); + !rebuild_and_report(&mut session, &config, &root, &pending_inputs, true, initial_started); let mut needs_rescan = false; loop { @@ -97,7 +108,14 @@ fn watch_project(project: PreparedProject, config: WatchConfig) -> Result<(), Wa if !pending_rebuild { continue; } - pending_rebuild = !rebuild_and_report(&mut session, &config, &root, &pending_inputs, false); + pending_rebuild = !rebuild_and_report( + &mut session, + &config, + &root, + &pending_inputs, + false, + Instant::now(), + ); } } @@ -107,8 +125,8 @@ fn rebuild_and_report( root: &Path, inputs: &[InputChange], initial: bool, + started: Instant, ) -> bool { - let started = Instant::now(); match session.rebuild() { Ok(outcome) => { let outcome = match outcome { From b3892778a9b01241edc5ee2af01ccf19128993d6 Mon Sep 17 00:00:00 2001 From: Justin Garcia Date: Sun, 13 Sep 2026 08:17:45 +0000 Subject: [PATCH 25/26] Bootstrap LSP through package scheduling Amp-Thread-ID: https://ampcode.com/threads/T-01a09469-c4cf-723d-9923-3b8452bea9f2 Co-authored-by: Amp --- Cargo.lock | 2 +- compiler-bin/iris-lsp/Cargo.toml | 6 +- compiler-bin/iris-lsp/src/server.rs | 542 ++++++++++++++++------ compiler-bin/iris-lsp/src/server/error.rs | 11 + compiler-bin/iris-lsp/src/server/event.rs | 29 +- compiler-bin/iris-lsp/src/server/tests.rs | 75 +-- 6 files changed, 475 insertions(+), 190 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e58715cfa..9c3a7eda8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1782,11 +1782,11 @@ dependencies = [ "dunce", "files", "globset", + "iris-build", "itertools 0.15.0", "lsp-types", "parking_lot", "path-absolutize", - "prim-constants", "rustc-hash", "serde", "serde_json", diff --git a/compiler-bin/iris-lsp/Cargo.toml b/compiler-bin/iris-lsp/Cargo.toml index 0ccb5f51c..646ca1b55 100644 --- a/compiler-bin/iris-lsp/Cargo.toml +++ b/compiler-bin/iris-lsp/Cargo.toml @@ -15,16 +15,15 @@ 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" -prim-constants = { version = "0.1.0", path = "../../compiler-core/prim-constants" } rustc-hash = "2.1.3" serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" spago = { version = "0.1.0", path = "../../compiler-lsp/spago" } -tempfile = "3.27.0" thiserror = "2.0.20" tokio = { version = "1.53.1", features = ["io-std", "macros", "rt", "sync", "time"] } tower = "0.5.3" @@ -32,5 +31,8 @@ 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/server.rs b/compiler-bin/iris-lsp/src/server.rs index e27599ea1..5eb525d64 100644 --- a/compiler-bin/iris-lsp/src/server.rs +++ b/compiler-bin/iris-lsp/src/server.rs @@ -30,14 +30,17 @@ 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 tokio::task; use tower::ServiceBuilder; @@ -48,49 +51,57 @@ use crate::server::capabilities::{ use crate::server::error::{AnalyzerResultExt, LspError}; use crate::{ServerConfig, ServerError, walk}; -fn materialize_prim() -> io::Result { - let directory = TempDir::new()?; - for (name, content) in MODULE_MAP { - let path = directory.path().join(format!("{name}.purs")); - fs::write(path, content)?; - } - Ok(directory) -} - -fn configure_materialized_prim( - engine: &QueryEngine, - files: &mut FileLifecycle, - directory: &TempDir, -) { - for (name, content) in MODULE_MAP { - let path = directory.path().join(format!("{name}.purs")); - 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); +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) enum SourceMetadata { + Builtin, + Package { editable: bool }, + Unmanaged { editable: bool }, +} + +impl SourceMetadata { + fn editable(&self) -> bool { + match self { + SourceMetadata::Builtin => false, + SourceMetadata::Package { editable } | SourceMetadata::Unmanaged { editable } => { + *editable + } + } } } +struct LspWorkspace { + engine: QueryEngine, + files: Arc>>, + source_roots: Vec, + selected_sources: FxHashSet>, + excluded_sources: FxHashSet>, + _prim: MaterializedPrim, +} + +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 { pub startup_config: Arc, pub config: Arc, pub client: ClientSocket, - pub engine: QueryEngine, - pub files: Arc>>, + workspace: WorkspaceState, pub diagnostics: event::DiagnosticScheduler, pub workspace_symbols_cache: Arc>, @@ -100,15 +111,11 @@ 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, - _materialized_prim_directory: Arc, } impl State { @@ -117,13 +124,7 @@ impl State { client: ClientSocket, name: String, version: String, - prim_directory: Arc, ) -> State { - let engine = QueryEngine::default(); - let mut files = FileLifecycle::default(); - configure_materialized_prim(&engine, &mut files, &prim_directory); - - let files = Arc::new(RwLock::new(files)); let diagnostics = event::DiagnosticScheduler::default(); let workspace_symbols_cache = WorkspaceSymbolsCache::default(); @@ -136,9 +137,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; @@ -147,8 +145,7 @@ impl State { startup_config: Arc::clone(&config), config, client, - engine, - files, + workspace: WorkspaceState::WaitingForConfiguration { pending: vec![] }, diagnostics, workspace_symbols_cache, suggestions_cache, @@ -156,34 +153,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, - _materialized_prim_directory: prim_directory, } } + 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, - ) -> task::JoinHandle + ) -> 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 || action(snapshot)) + Ok(task::spawn_blocking(move || action(snapshot))) } fn invalidate_workspace_symbols(&self) { @@ -199,7 +240,7 @@ impl State { struct StateSnapshot { engine: QueryEngine, - files: Arc>>, + files: Arc>>, workspace_symbols_cache: Arc>, suggestions_cache: Arc>, position_encoding: PositionEncoding, @@ -221,7 +262,7 @@ impl StateSnapshot { struct LspAnalyzerHost<'a> { queries: &'a QueryEngine, - files: RwLockReadGuard<'a, FileLifecycle>, + files: RwLockReadGuard<'a, FileLifecycle>, } impl AnalyzerHost for LspAnalyzerHost<'_> { @@ -247,7 +288,7 @@ 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) } } @@ -442,7 +483,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))?; } } @@ -450,7 +491,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)) @@ -471,7 +512,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.") @@ -528,11 +569,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); @@ -545,74 +593,192 @@ 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_vec()) + let metadata = metadata.collect(); + + let package = PackageInput { + name: "unmanaged".to_string(), + 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)| { + let dependencies = package.dependencies.iter().map(ToString::to_string).collect_vec(); + PackageInput { + name: name.to_string(), + source_identities: Vec::clone(&package.sources), + dependencies, + } + }); + 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())); + + let source_globs = metadata.keys().cloned().collect_vec(); + Ok(DiscoveredWorkspace { source_globs, packages: package_inputs, metadata, source_roots }) +} - Ok(files.sorted().collect_vec()) +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 { @@ -624,7 +790,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)?; @@ -632,25 +798,27 @@ 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(()) } @@ -827,20 +995,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))) } @@ -938,13 +1106,13 @@ fn did_open(state: &mut State, parameters: DidOpenTextDocumentParams) -> Result< 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(parameters.text_document.text.as_str()), version: parameters.text_document.version, - metadata: editable, + metadata, }, }; let mut change = apply_lifecycle_event(state, event); @@ -965,11 +1133,8 @@ fn did_close(state: &mut State, parameters: DidCloseTextDocumentParams) -> Resul 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 = @@ -978,7 +1143,7 @@ fn did_close(state: &mut State, parameters: DidCloseTextDocumentParams) -> Resul } 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 }, @@ -1014,14 +1179,14 @@ fn did_change_watched_files( 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); @@ -1034,13 +1199,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 }, @@ -1057,11 +1225,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() }; @@ -1143,15 +1315,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(); @@ -1176,7 +1352,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))?; @@ -1202,19 +1378,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, @@ -1231,6 +1425,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, @@ -1239,12 +1450,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 @@ -1261,6 +1471,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, @@ -1279,10 +1509,14 @@ trait RequestExtension: BorrowMut> { impl RequestExtension for Router {} +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 prim_directory = Arc::new(materialize_prim().map_err(ServerError::new)?); let (server, _) = async_lsp::MainLoop::new_server(move |client| { let client_socket = ClientSocket::clone(&client); let mut router: Router = Router::new(State::new( @@ -1290,7 +1524,6 @@ pub(crate) async fn async_start(config: ServerConfig) -> Result<(), ServerError> client_socket, String::clone(&name), String::clone(&version), - Arc::clone(&prim_directory), )); router @@ -1310,12 +1543,27 @@ pub(crate) async fn async_start(config: ServerConfig) -> Result<(), ServerError> .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); diff --git a/compiler-bin/iris-lsp/src/server/error.rs b/compiler-bin/iris-lsp/src/server/error.rs index 5dba9a5e9..16f5aee7b 100644 --- a/compiler-bin/iris-lsp/src/server/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}")] @@ -58,6 +63,9 @@ impl LspError { } 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/iris-lsp/src/server/event.rs b/compiler-bin/iris-lsp/src/server/event.rs index af012b3c6..75843224b 100644 --- a/compiler-bin/iris-lsp/src/server/event.rs +++ b/compiler-bin/iris-lsp/src/server/event.rs @@ -10,7 +10,7 @@ use rustc_hash::FxHashMap; use tokio::task; use crate::server::error::LspError; -use crate::server::{State, StateSnapshot}; +use crate::server::{SourceMetadata, State, StateSnapshot}; #[derive(Default)] pub struct DiagnosticScheduler { @@ -31,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) => { @@ -104,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) { @@ -115,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))?; } @@ -139,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(()); } @@ -156,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; @@ -205,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 }; @@ -236,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(); @@ -281,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); @@ -294,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/iris-lsp/src/server/tests.rs b/compiler-bin/iris-lsp/src/server/tests.rs index 19d480882..400f0edf9 100644 --- a/compiler-bin/iris-lsp/src/server/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,9 +18,9 @@ use lsp_types::{ use tempfile::tempdir; use super::{ - State, apply_content_changes, apply_lifecycle_event, did_close, document_kind, - materialize_prim, 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 { @@ -29,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, @@ -36,22 +46,14 @@ fn assert_source_close_result( ) { let unit = source_unit_from_source_uri(&source_uri).unwrap(); let config = test_config(); - let prim_directory = Arc::new(materialize_prim().unwrap()); - let (_server, _) = async_lsp::MainLoop::new_server(move |client| { - let mut state = State::new( - Arc::clone(&config), - client, - "iris-lsp".to_string(), - "test".to_string(), - Arc::clone(&prim_directory), - ); + 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); @@ -69,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); @@ -169,22 +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 prim_directory = Arc::new(materialize_prim().unwrap()); - let (_server, _) = async_lsp::MainLoop::new_server(move |client| { - let mut state = State::new( - Arc::clone(&config), - client, - "iris-lsp".to_string(), - "test".to_string(), - Arc::clone(&prim_directory), - ); + 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); @@ -203,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); @@ -240,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(); From 1144c6fb3564ba6f7a023a35acb65a148556ccb8 Mon Sep 17 00:00:00 2001 From: Justin Garcia Date: Sun, 13 Sep 2026 12:51:02 +0000 Subject: [PATCH 26/26] Preserve compact package names through builds Amp-Thread-ID: https://ampcode.com/threads/T-01a09469-c4cf-723d-9923-3b8452bea9f2 Co-authored-by: Amp --- Cargo.lock | 3 ++ compiler-bin/iris-build/Cargo.toml | 1 + compiler-bin/iris-build/src/events.rs | 3 +- compiler-bin/iris-build/src/executor.rs | 21 ++++++----- compiler-bin/iris-build/src/plan.rs | 48 +++++++++++++------------ compiler-bin/iris-build/src/project.rs | 11 +++--- compiler-bin/iris-lsp/Cargo.toml | 1 + compiler-bin/iris-lsp/src/server.rs | 14 ++++---- compiler-bin/iris-progress/Cargo.toml | 1 + compiler-bin/iris-progress/src/lib.rs | 13 +++---- 10 files changed, 64 insertions(+), 52 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9c3a7eda8..aaf7d35ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1747,6 +1747,7 @@ dependencies = [ "rayon", "serde", "serde_yml", + "smol_str", "spago", "tempfile", "thiserror", @@ -1790,6 +1791,7 @@ dependencies = [ "rustc-hash", "serde", "serde_json", + "smol_str", "spago", "tempfile", "thiserror", @@ -1820,6 +1822,7 @@ dependencies = [ "console", "itertools 0.15.0", "ratatui", + "smol_str", "terminal-colorsaurus", ] diff --git a/compiler-bin/iris-build/Cargo.toml b/compiler-bin/iris-build/Cargo.toml index eeb7ff06e..655340b75 100644 --- a/compiler-bin/iris-build/Cargo.toml +++ b/compiler-bin/iris-build/Cargo.toml @@ -24,6 +24,7 @@ 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" diff --git a/compiler-bin/iris-build/src/events.rs b/compiler-bin/iris-build/src/events.rs index 9519f1f05..c619624a4 100644 --- a/compiler-bin/iris-build/src/events.rs +++ b/compiler-bin/iris-build/src/events.rs @@ -3,6 +3,7 @@ use std::time::Duration; use iris_progress::{ProgressEvent, ProgressOutcome, ProgressReporter}; +use smol_str::SmolStr; use std::sync::Mutex; @@ -17,7 +18,7 @@ pub enum BuildOutcome { pub enum BuildEvent { Preparing, PlanReady { package_count: usize }, - PackageCompleted { package_name: String, duration: Duration }, + PackageCompleted { package_name: SmolStr, duration: Duration }, Finalizing { duration: Duration }, Finished { duration: Duration, outcome: BuildOutcome }, } diff --git a/compiler-bin/iris-build/src/executor.rs b/compiler-bin/iris-build/src/executor.rs index 4ccb0e023..df9db2329 100644 --- a/compiler-bin/iris-build/src/executor.rs +++ b/compiler-bin/iris-build/src/executor.rs @@ -4,6 +4,7 @@ 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}; @@ -113,7 +114,7 @@ where let started = Instant::now(); execute(package)?; events.send(BuildEvent::PackageCompleted { - package_name: String::clone(&package.name), + package_name: SmolStr::clone(&package.name), duration: started.elapsed(), }); Ok(()) @@ -148,19 +149,19 @@ mod tests { let selected_sources = selected_sources.collect_vec(); let packages = vec![ PackageInput { - name: "root".to_owned(), + name: SmolStr::new("root"), source_identities: vec![PathBuf::from("root")], dependencies: vec![], }, PackageInput { - name: "independent".to_owned(), + name: SmolStr::new("independent"), source_identities: vec![PathBuf::from("independent")], dependencies: vec![], }, PackageInput { - name: "dependent".to_owned(), + name: SmolStr::new("dependent"), source_identities: vec![PathBuf::from("dependent")], - dependencies: vec!["root".to_owned(), "independent".to_owned()], + dependencies: vec![SmolStr::new("root"), SmolStr::new("independent")], }, ]; BuildPlan::new(selected_sources, packages).unwrap() @@ -185,7 +186,7 @@ mod tests { if package.name == "independent" { independent_completed.store(true, Ordering::Release); } - executed.lock().unwrap().push(String::clone(&package.name)); + executed.lock().unwrap().push(SmolStr::clone(&package.name)); Ok::<_, ()>(()) }) .unwrap(); @@ -195,7 +196,11 @@ mod tests { let executed = executed.into_iter().collect::>(); assert_eq!( executed, - HashSet::from(["root".to_owned(), "independent".to_owned(), "dependent".to_owned()]) + 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), @@ -214,7 +219,7 @@ mod tests { let executed = Mutex::new(vec![]); execute_serial(&plan, events, &|package| { - executed.lock().unwrap().push(String::clone(&package.name)); + executed.lock().unwrap().push(SmolStr::clone(&package.name)); Ok::<_, ()>(()) }) .unwrap(); diff --git a/compiler-bin/iris-build/src/plan.rs b/compiler-bin/iris-build/src/plan.rs index da18810b7..63a6ecede 100644 --- a/compiler-bin/iris-build/src/plan.rs +++ b/compiler-bin/iris-build/src/plan.rs @@ -7,6 +7,7 @@ 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)] @@ -23,14 +24,14 @@ pub struct SelectedSource { #[derive(Clone, Debug, Eq, PartialEq)] pub struct PackageInput { - pub name: String, + pub name: SmolStr, pub source_identities: Vec, - pub dependencies: Vec, + pub dependencies: Vec, } #[derive(Debug)] pub struct PlannedPackage { - pub name: String, + pub name: SmolStr, pub source_paths: Vec, } @@ -51,9 +52,9 @@ pub struct BuildPlan { #[derive(Debug, Error, Eq, PartialEq)] pub enum BuildPlanError { #[error("Spago package '{0}' appears more than once in the build plan")] - DuplicatePackage(String), + DuplicatePackage(SmolStr), #[error("source {path} belongs to both Spago packages {first_package} and {second_package}")] - ConflictingPackageSource { path: PathBuf, first_package: String, second_package: String }, + 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), } @@ -63,33 +64,33 @@ impl BuildPlan { selected_sources: Vec, package_inputs: Vec, ) -> Result { - let mut owners: HashMap = HashMap::new(); + let mut owners: HashMap = HashMap::new(); let mut package_names = HashSet::new(); for package in &package_inputs { - if !package_names.insert(String::clone(&package.name)) { - return Err(BuildPlanError::DuplicatePackage(String::clone(&package.name))); + 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: String::clone(first_package), - second_package: String::clone(&package.name), + first_package: SmolStr::clone(first_package), + second_package: SmolStr::clone(&package.name), }); } } else { - owners.insert(PathBuf::clone(source_identity), String::clone(&package.name)); + owners.insert(PathBuf::clone(source_identity), SmolStr::clone(&package.name)); } } } - let mut selected_by_package: HashMap> = HashMap::new(); + 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(String::clone(owner)).or_default().push(source.path); + selected_by_package.entry(SmolStr::clone(owner)).or_default().push(source.path); } let retained = selected_by_package.keys().cloned().collect::>(); @@ -101,7 +102,7 @@ impl BuildPlan { .filter(|package| retained.contains(&package.name)) .map(|package| { let dependencies = contract_dependencies(package, &retained, &packages_by_name); - (String::clone(&package.name), dependencies) + (SmolStr::clone(&package.name), dependencies) }); let contracted_dependencies = contracted_dependencies.collect::>(); @@ -159,21 +160,21 @@ impl BuildPlan { fn contract_dependencies( package: &PackageInput, - retained: &HashSet, + retained: &HashSet, packages_by_name: &HashMap<&str, &PackageInput>, -) -> Vec { +) -> Vec { let mut dependencies = vec![]; let mut visited = HashSet::new(); - let pending = package.dependencies.iter().rev().map(String::as_str); + 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(dependency.to_owned()); + dependencies.push(SmolStr::new(dependency)); } else if let Some(package) = packages_by_name.get(dependency) { - pending.extend(package.dependencies.iter().rev().map(String::as_str)); + pending.extend(package.dependencies.iter().rev().map(SmolStr::as_str)); } } dependencies @@ -250,9 +251,9 @@ mod tests { 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| (*dependency).to_owned()); + let dependencies = dependencies.iter().map(|dependency| SmolStr::new(dependency)); PackageInput { - name: name.to_owned(), + name: SmolStr::new(name), source_identities, dependencies: dependencies.collect_vec(), } @@ -279,7 +280,10 @@ mod tests { vec![package("duplicate", &["First.purs"], &[]), package("duplicate", &[], &[])], ); - assert_eq!(result.unwrap_err(), BuildPlanError::DuplicatePackage("duplicate".to_owned())); + assert_eq!( + result.unwrap_err(), + BuildPlanError::DuplicatePackage(SmolStr::new("duplicate")) + ); } #[test] diff --git a/compiler-bin/iris-build/src/project.rs b/compiler-bin/iris-build/src/project.rs index 8f3a5c899..0bf459364 100644 --- a/compiler-bin/iris-build/src/project.rs +++ b/compiler-bin/iris-build/src/project.rs @@ -198,13 +198,10 @@ fn initialize_project_inner( 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)| { - let dependencies = package.dependencies.into_iter().map(|name| name.to_string()); - PackageInput { - name: name.to_string(), - source_identities: package.sources, - dependencies: dependencies.collect_vec(), - } + 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()) } diff --git a/compiler-bin/iris-lsp/Cargo.toml b/compiler-bin/iris-lsp/Cargo.toml index 646ca1b55..8c1fd5bfe 100644 --- a/compiler-bin/iris-lsp/Cargo.toml +++ b/compiler-bin/iris-lsp/Cargo.toml @@ -23,6 +23,7 @@ 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"] } diff --git a/compiler-bin/iris-lsp/src/server.rs b/compiler-bin/iris-lsp/src/server.rs index 5eb525d64..88b653e9b 100644 --- a/compiler-bin/iris-lsp/src/server.rs +++ b/compiler-bin/iris-lsp/src/server.rs @@ -41,6 +41,7 @@ use lsp_types::*; use parking_lot::{RwLock, RwLockReadGuard}; use path_absolutize::Absolutize; use rustc_hash::FxHashSet; +use smol_str::SmolStr; use tokio::task; use tower::ServiceBuilder; @@ -601,7 +602,7 @@ fn discover_manual( let metadata = metadata.collect(); let package = PackageInput { - name: "unmanaged".to_string(), + name: SmolStr::new("unmanaged"), source_identities: Vec::clone(&files), dependencies: vec![], }; @@ -619,13 +620,10 @@ fn discover_spago(root: &std::path::Path) -> Result String { pub enum ProgressEvent { Preparing, PlanReady { package_count: usize }, - PackageCompleted { package_name: String, duration: Duration }, + 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: String, + pub package_name: SmolStr, pub duration: Duration, } @@ -670,7 +671,7 @@ mod tests { model.apply(ProgressEvent::PlanReady { package_count }); for name in names { model.apply(ProgressEvent::PackageCompleted { - package_name: (*name).to_owned(), + package_name: SmolStr::new(name), duration: Duration::from_millis(125), }); } @@ -826,7 +827,7 @@ mod tests { let mut model = ProgressModel::default(); model.apply(ProgressEvent::PlanReady { package_count: 2 }); model.apply(ProgressEvent::PackageCompleted { - package_name: "diagnostic-package".to_owned(), + package_name: SmolStr::new("diagnostic-package"), duration: Duration::from_millis(5), }); model.apply(ProgressEvent::Finalizing { duration: Duration::from_millis(750) }); @@ -853,7 +854,7 @@ mod tests { model.apply(ProgressEvent::PlanReady { package_count: names.len() }); for package_name in names { model.apply(ProgressEvent::PackageCompleted { - package_name, + package_name: SmolStr::new(package_name), duration: Duration::from_millis(1), }); } @@ -957,7 +958,7 @@ mod tests { ("a-much-longer-package-name", Duration::from_micros(123_450)), ] { model.apply(ProgressEvent::PackageCompleted { - package_name: package_name.to_owned(), + package_name: SmolStr::new(package_name), duration, }); }